Rate limiting, docs

This commit is contained in:
Philipp Heckel 2021-10-23 22:49:50 -04:00
parent e1c9fef6dc
commit 23cf77e0b7
7 changed files with 180 additions and 57 deletions

View file

@ -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>
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.
</p>
<p id="error"></p>
<h2>Subscribe to a topic</h2>
<p>
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>
<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">

View file

@ -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)))
}

View file

@ -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 {