diff --git a/README.md b/README.md index 45205a1326cf63b941527c9eb6848294bde6c17b..72f48b8d12a92139d518a2444816cd4afca54a2e 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ via scripts. I run a free version of it on *[ntfy.sh](https://ntfy.sh)*. **No si ## Usage ### Subscribe to a topic -You can subscribe to a topic either in a web UI, or in your own app by subscribing to an -[SSE](https://en.wikipedia.org/wiki/Server-sent_events)/[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), +Topics are created on the fly by subscribing to them. You can create and subscribe to a topic either in a web UI, or in +your own app by subscribing to an [SSE](https://en.wikipedia.org/wiki/Server-sent_events)/[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), or a JSON or raw feed. -Here's how to see the raw/json/sse stream in `curl`. This will subscribe to the topic and wait for events. +Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. + +Here's how you can create a topic `mytopic`, subscribe to it topic and wait for events. This is using `curl`, but you +can use any library that can do HTTP GETs: ``` # Subscribe to "mytopic" and output one message per line (\n are replaced with a space) @@ -54,9 +57,11 @@ Best effort. ### Why is the web UI so ugly? I don't particularly like JS or dealing with CSS. I'll make it pretty after it's functional. +## Will you know what topics exist, can you spy on me? +If you don't trust me or your messages are sensitive, run your ntfy on your own server. That said, the logs do not +contain any topic names + ## TODO -- rate limiting / abuse protection -- release/packaging - add HTTPS ## Contributing diff --git a/config/config.go b/config/config.go index 85297910a7f39aadf07ebf4a8ea5eb2c5643b684..ba91f132f5eb905d6cb8d586e1e6dffad0f60694 100644 --- a/config/config.go +++ b/config/config.go @@ -1,18 +1,38 @@ // Package config provides the main configuration package config +import ( + "golang.org/x/time/rate" + "time" +) + +// Defines default config settings const ( - DefaultListenHTTP = ":80" + DefaultListenHTTP = ":80" + defaultManagerInterval = time.Minute +) + +// Defines the max number of requests, here: +// 50 requests bucket, replenished at a rate of 1 per second +var ( + defaultLimit = rate.Every(time.Second) + defaultLimitBurst = 50 ) // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { - ListenHTTP string + ListenHTTP string + Limit rate.Limit + LimitBurst int + ManagerInterval time.Duration } // New instantiates a default new config func New(listenHTTP string) *Config { return &Config{ - ListenHTTP: listenHTTP, + ListenHTTP: listenHTTP, + Limit: defaultLimit, + LimitBurst: defaultLimitBurst, + ManagerInterval: defaultManagerInterval, } } diff --git a/go.mod b/go.mod index 7c9ad4cdf8cd146fbb67c1cf860438797253a4f5..791b7b0fc7c003d784be611abdf9cc1d41f9d362 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,6 @@ require ( github.com/BurntSushi/toml v0.4.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect github.com/urfave/cli/v2 v2.3.0 + golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 2cd4b5df221ebbe0547f3f42c04810224aea67ba..91f925e949c84ca769ce0cce269fec1664a79f86 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,20 @@ -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.3 h1:fvjTMHxHEw/mxHbtzPi3JCcKXQRAnQTBRo6YCJSVHKI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/server/index.html b/server/index.html index 1e3c2318a681ebe16c509e13a7a1b4e64133eff1..ddef43d3a4fd296885972a407d6e688c9f52a154 100644 --- a/server/index.html +++ b/server/index.html @@ -3,37 +3,64 @@ <head> <title>ntfy.sh</title> <style> - body { font-size: 1.3em; line-height: 140%; } + body { font-size: 1.2em; line-height: 130%; } #error { color: darkred; font-style: italic; } - #main { max-width: 800px; margin: 0 auto; } + #main { max-width: 900px; margin: 0 auto 50px auto; } </style> </head> <body> <div id="main"> - <h1>ntfy.sh</h1> - + <h1>ntfy.sh - simple HTTP-based pub-sub</h1> <p> - <b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based pub-sub notification service. It allows you to send desktop and (soon) phone notifications - via scripts, without signup or cost. It's entirely free and open source. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>. + <b>ntfy</b> (pronounce: <i>notify</i>) is a simple <b>HTTP-based pub-sub notification service and tool</b>. + It allows you to send <b>desktop notifications via scripts</b>, entirely <b>without signup or cost</b>. + It's entirely free and open source. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>. </p> + <p id="error"></p> + <h2>Subscribe to a topic</h2> <p> - You can subscribe to a topic either in this web UI, or in your own app by subscribing to an SSE/EventSource - or JSON feed. Once subscribed, you can publish messages via PUT or POST. + Topics are created on the fly by subscribing to them. You can create and subscribe to a topic either in this web UI, or in + your own app by subscribing to an <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>, + a JSON feed, or raw feed. + </p> + <p> + Because there is no sign-up, <b>the topic is essentially a password</b>, so pick something that's not easily guessable. </p> - <p id="error"></p> - + <h3>Subscribe via web</h3> + <p> + If you subscribe to a topic via this web UI in the field below, messages published to any subscribed topic + will show up as <b>desktop notification</b>. + </p> <form id="subscribeForm"> <p> - <input type="text" id="topicField" size="64" placeholder="Topic ID (letters, numbers, _ and -)" pattern="[-_A-Za-z]{1,64}" autofocus /> + <label for="topicField">Topic ID:</label> + <input type="text" id="topicField" size="64" placeholder="Letters, numbers, _ and -" pattern="[-_A-Za-z]{1,64}" autofocus /> <input type="submit" id="subscribeButton" value="Subscribe topic" /> </p> </form> - - <p id="topicsHeader"><b>Subscribed topics:</b></p> + <p id="topicsHeader">Subscribed topics:</p> <ul id="topicsList"></ul> + <h3>Subscribe via your app, or via the CLI</h3> + <tt> + curl -s ntfy.sh/mytopic/raw # one message per line (\n are replaced with a space)<br/> + curl -s ntfy.sh/mytopic/json # one JSON message per line<br/> + curl -s ntfy.sh/mytopic/sse # server-sent events (SSE) stream + </tt> + + <h3>Publishing messages</h3> + <p> + Publishing messages can be done via PUT or POST using. Here's an example using <tt>curl</tt>: + </p> + <tt> + curl -d "long process is done" ntfy.sh/mytopic + </tt> + <p> + Messages published to a non-existing topic or a topic without subscribers will not be delivered later. + There is (currently) no buffering of any kind. If you're not listening, the message won't be delivered. + </p> </div> <script type="text/javascript"> diff --git a/server/server.go b/server/server.go index 77b2e5ac3838c50af672c5ace33d9bc59aaa08c2..c5b0a6b53d22a76260ebf147cf69080808a5e93f 100644 --- a/server/server.go +++ b/server/server.go @@ -4,11 +4,12 @@ import ( "bytes" _ "embed" // required for go:embed "encoding/json" - "errors" "fmt" + "golang.org/x/time/rate" "heckel.io/ntfy/config" "io" "log" + "net" "net/http" "regexp" "strings" @@ -16,19 +17,33 @@ import ( "time" ) +// Server is the main server type Server struct { - config *config.Config - topics map[string]*topic - mu sync.Mutex + config *config.Config + topics map[string]*topic + visitors map[string]*visitor + mu sync.Mutex } -type message struct { - Time int64 `json:"time"` - Message string `json:"message"` +// visitor represents an API user, and its associated rate.Limiter used for rate limiting +type visitor struct { + limiter *rate.Limiter + seen time.Time +} + +// errHTTP is a generic HTTP error for any non-200 HTTP error +type errHTTP struct { + Code int + Status string +} + +func (e errHTTP) Error() string { + return fmt.Sprintf("http: %s", e.Status) } const ( - messageLimit = 1024 + messageLimit = 1024 + visitorExpungeAfter = 30 * time.Minute ) var ( @@ -40,18 +55,26 @@ var ( //go:embed "index.html" indexSource string - errTopicNotFound = errors.New("topic not found") + errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)} + errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)} ) func New(conf *config.Config) *Server { return &Server{ - config: conf, - topics: make(map[string]*topic), + config: conf, + topics: make(map[string]*topic), + visitors: make(map[string]*visitor), } } func (s *Server) Run() error { - go s.runMonitor() + go func() { + ticker := time.NewTicker(s.config.ManagerInterval) + for { + <-ticker.C + s.updateStatsAndExpire() + } + }() return s.listenAndServe() } @@ -61,29 +84,43 @@ func (s *Server) listenAndServe() error { return http.ListenAndServe(s.config.ListenHTTP, nil) } -func (s *Server) runMonitor() { - for { - time.Sleep(30 * time.Second) - s.mu.Lock() - var subscribers, messages int - for _, t := range s.topics { - subs, msgs := t.Stats() - subscribers += subs - messages += msgs +func (s *Server) updateStatsAndExpire() { + s.mu.Lock() + defer s.mu.Unlock() + + // Expire visitors from rate visitors map + for ip, v := range s.visitors { + if time.Since(v.seen) > visitorExpungeAfter { + delete(s.visitors, ip) } - log.Printf("Stats: %d topic(s), %d subscriber(s), %d message(s) sent", len(s.topics), subscribers, messages) - s.mu.Unlock() } + + // Print stats + var subscribers, messages int + for _, t := range s.topics { + subs, msgs := t.Stats() + subscribers += subs + messages += msgs + } + log.Printf("Stats: %d topic(s), %d subscriber(s), %d message(s) sent, %d visitor(s)", + len(s.topics), subscribers, messages, len(s.visitors)) } func (s *Server) handle(w http.ResponseWriter, r *http.Request) { if err := s.handleInternal(w, r); err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = io.WriteString(w, err.Error()+"\n") + if e, ok := err.(*errHTTP); ok { + s.fail(w, r, e.Code, e) + } else { + s.fail(w, r, http.StatusInternalServerError, err) + } } } func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { + v := s.visitor(r.RemoteAddr) + if !v.limiter.Allow() { + return errHTTPTooManyRequests + } if r.Method == http.MethodGet && r.URL.Path == "/" { return s.handleHome(w, r) } else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) { @@ -95,8 +132,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) { return s.handlePublishHTTP(w, r) } - http.NotFound(w, r) - return nil + return errHTTPNotFound } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error { @@ -206,7 +242,7 @@ func (s *Server) topic(topicID string) (*topic, error) { defer s.mu.Unlock() c, ok := s.topics[topicID] if !ok { - return nil, errTopicNotFound + return nil, errHTTPNotFound } return c, nil } @@ -218,3 +254,31 @@ func (s *Server) unsubscribe(t *topic, subscriberID int) { delete(s.topics, t.id) } } + +// visitor creates or retrieves a rate.Limiter for the given visitor. +// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). +func (s *Server) visitor(remoteAddr string) *visitor { + s.mu.Lock() + defer s.mu.Unlock() + ip, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + ip = remoteAddr // This should not happen in real life; only in tests. + } + v, exists := s.visitors[ip] + if !exists { + v = &visitor{ + rate.NewLimiter(s.config.Limit, s.config.LimitBurst), + time.Now(), + } + s.visitors[ip] = v + return v + } + v.seen = time.Now() + return v +} + +func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, err error) { + log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, code, err.Error()) + w.WriteHeader(code) + io.WriteString(w, fmt.Sprintf("%s\n", http.StatusText(code))) +} diff --git a/server/topic.go b/server/topic.go index 7c8f38bf458cf4d748d16ba170e2269f2de26c6c..e83c29667a386ed14b2207e186578e7fd552c1b7 100644 --- a/server/topic.go +++ b/server/topic.go @@ -9,6 +9,8 @@ import ( "time" ) +// topic represents a channel to which subscribers can subscribe, and publishers +// can publish a message type topic struct { id string subscribers map[int]subscriber @@ -19,6 +21,13 @@ type topic struct { mu sync.Mutex } +// message represents a message published to a topic +type message struct { + Time int64 `json:"time"` + Message string `json:"message"` +} + +// subscriber is a function that is called for every new message on a topic type subscriber func(msg *message) error func newTopic(id string) *topic {