Limits
This commit is contained in:
parent
fa7a45902f
commit
b775e6dfce
5 changed files with 146 additions and 37 deletions
|
@ -14,36 +14,41 @@ const (
|
||||||
DefaultManagerInterval = time.Minute
|
DefaultManagerInterval = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines the max number of requests, here:
|
// Defines all the limits
|
||||||
// 50 requests bucket, replenished at a rate of 1 per second
|
// - request limit: max number of PUT/GET/.. requests (here: 50 requests bucket, replenished at a rate of 1 per second)
|
||||||
|
// - global topic limit: max number of topics overall
|
||||||
|
// - subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||||
var (
|
var (
|
||||||
defaultRequestLimit = rate.Every(time.Second)
|
defaultGlobalTopicLimit = 5000
|
||||||
defaultRequestLimitBurst = 50
|
defaultVisitorRequestLimit = rate.Every(time.Second)
|
||||||
defaultSubscriptionLimit = 30 // per visitor
|
defaultVisitorRequestLimitBurst = 50
|
||||||
|
defaultVisitorSubscriptionLimit = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenHTTP string
|
ListenHTTP string
|
||||||
FirebaseKeyFile string
|
FirebaseKeyFile string
|
||||||
MessageBufferDuration time.Duration
|
MessageBufferDuration time.Duration
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
RequestLimit rate.Limit
|
GlobalTopicLimit int
|
||||||
RequestLimitBurst int
|
VisitorRequestLimit rate.Limit
|
||||||
SubscriptionLimit int
|
VisitorRequestLimitBurst int
|
||||||
|
VisitorSubscriptionLimit int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New instantiates a default new config
|
// New instantiates a default new config
|
||||||
func New(listenHTTP string) *Config {
|
func New(listenHTTP string) *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
ListenHTTP: listenHTTP,
|
ListenHTTP: listenHTTP,
|
||||||
FirebaseKeyFile: "",
|
FirebaseKeyFile: "",
|
||||||
MessageBufferDuration: DefaultMessageBufferDuration,
|
MessageBufferDuration: DefaultMessageBufferDuration,
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
ManagerInterval: DefaultManagerInterval,
|
ManagerInterval: DefaultManagerInterval,
|
||||||
RequestLimit: defaultRequestLimit,
|
GlobalTopicLimit: defaultGlobalTopicLimit,
|
||||||
RequestLimitBurst: defaultRequestLimitBurst,
|
VisitorRequestLimit: defaultVisitorRequestLimit,
|
||||||
SubscriptionLimit: defaultSubscriptionLimit,
|
VisitorRequestLimitBurst: defaultVisitorRequestLimitBurst,
|
||||||
|
VisitorSubscriptionLimit: defaultVisitorSubscriptionLimit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,6 +81,12 @@
|
||||||
<ul id="topicsList"></ul>
|
<ul id="topicsList"></ul>
|
||||||
<audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio>
|
<audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio>
|
||||||
|
|
||||||
|
<h3>Subscribe via phone</h3>
|
||||||
|
<p>
|
||||||
|
Once it's approved, you can use the <b>Ntfy Android App</b> to receive notifications directly on your phone. Just like
|
||||||
|
the server, this app is also <a href="https://github.com/binwiederhier/ntfy-android">open source</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3>Subscribe via your app, or via the CLI</h3>
|
<h3>Subscribe via your app, or via the CLI</h3>
|
||||||
<p class="smallMarginBottom">
|
<p class="smallMarginBottom">
|
||||||
Using <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a> in JS, you can consume
|
Using <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a> in JS, you can consume
|
||||||
|
@ -142,6 +148,7 @@
|
||||||
$ curl -s "ntfy.sh/mytopic/json?poll=1&since=10m"<br/>
|
$ curl -s "ntfy.sh/mytopic/json?poll=1&since=10m"<br/>
|
||||||
# Returns messages from up to 10 minutes ago and ends the connection
|
# Returns messages from up to 10 minutes ago and ends the connection
|
||||||
</code>
|
</code>
|
||||||
|
|
||||||
<h2>FAQ</h2>
|
<h2>FAQ</h2>
|
||||||
<p>
|
<p>
|
||||||
<b>Isn't this like ...?</b><br/>
|
<b>Isn't this like ...?</b><br/>
|
||||||
|
@ -165,6 +172,28 @@
|
||||||
That said, the logs do not contain any topic names or other details about you. Check the code if you don't believe me.
|
That said, the logs do not contain any topic names or other details about you. Check the code if you don't believe me.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>Why is Firebase used?</b><br/>
|
||||||
|
In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also
|
||||||
|
published to Firebase Cloud Messaging (FCM) (if <tt>FirebaseKeyFile</tt> is set, which it is on ntfy.sh). This
|
||||||
|
is to facilitate instant notifications on Android. I tried really, really hard to avoid using FCM, but newer
|
||||||
|
versions of Android made it impossible to implement <a href="https://developer.android.com/guide/background">background services</a>>.
|
||||||
|
I'm sorry.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Privacy policy</h2>
|
||||||
|
<p>
|
||||||
|
Neither the server nor the app record any personal information, or share any of the messages and topics with
|
||||||
|
any outside service. All data is exclusively used to make the service function properly. The notable exception
|
||||||
|
is the Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||||
|
FAQ for details).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages,
|
||||||
|
aside from a short on-disk cache (up to a day) to support the <tt>since=</tt> feature and service restarts.
|
||||||
|
</p>
|
||||||
|
|
||||||
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
|
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
|
||||||
</div>
|
</div>
|
||||||
<script src="static/js/app.js"></script>
|
<script src="static/js/app.js"></script>
|
||||||
|
|
|
@ -24,6 +24,7 @@ import (
|
||||||
|
|
||||||
// TODO add "max messages in a topic" limit
|
// TODO add "max messages in a topic" limit
|
||||||
// TODO implement persistence
|
// TODO implement persistence
|
||||||
|
// TODO implement "since=<ID>"
|
||||||
|
|
||||||
// Server is the main server
|
// Server is the main server
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
@ -146,7 +147,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
|
||||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||||
return s.handleStatic(w, r)
|
return s.handleStatic(w, r)
|
||||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
|
||||||
return s.handlePublish(w, r)
|
return s.handlePublish(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
|
||||||
return s.handleSubscribeJSON(w, r, v)
|
return s.handleSubscribeJSON(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
|
||||||
|
@ -169,8 +170,11 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
t := s.createTopic(r.URL.Path[1:])
|
t, err := s.topic(r.URL.Path[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
reader := io.LimitReader(r.Body, messageLimit)
|
reader := io.LimitReader(r.Body, messageLimit)
|
||||||
b, err := io.ReadAll(reader)
|
b, err := io.ReadAll(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -223,10 +227,13 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
|
||||||
|
|
||||||
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
|
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
|
||||||
if err := v.AddSubscription(); err != nil {
|
if err := v.AddSubscription(); err != nil {
|
||||||
return err
|
return errHTTPTooManyRequests
|
||||||
}
|
}
|
||||||
defer v.RemoveSubscription()
|
defer v.RemoveSubscription()
|
||||||
t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/"+format)) // Hack
|
t, err := s.topic(strings.TrimSuffix(r.URL.Path[1:], "/"+format)) // Hack
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
since, err := parseSince(r)
|
since, err := parseSince(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -304,16 +311,19 @@ func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createTopic(id string) *topic {
|
func (s *Server) topic(id string) (*topic, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if _, ok := s.topics[id]; !ok {
|
if _, ok := s.topics[id]; !ok {
|
||||||
|
if len(s.topics) >= s.config.GlobalTopicLimit {
|
||||||
|
return nil, errHTTPTooManyRequests
|
||||||
|
}
|
||||||
s.topics[id] = newTopic(id)
|
s.topics[id] = newTopic(id)
|
||||||
if s.firebase != nil {
|
if s.firebase != nil {
|
||||||
s.topics[id].Subscribe(s.firebase)
|
s.topics[id].Subscribe(s.firebase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.topics[id]
|
return s.topics[id], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) updateStatsAndExpire() {
|
func (s *Server) updateStatsAndExpire() {
|
||||||
|
@ -331,7 +341,7 @@ func (s *Server) updateStatsAndExpire() {
|
||||||
for _, t := range s.topics {
|
for _, t := range s.topics {
|
||||||
t.Prune(s.config.MessageBufferDuration)
|
t.Prune(s.config.MessageBufferDuration)
|
||||||
subs, msgs := t.Stats()
|
subs, msgs := t.Stats()
|
||||||
if msgs == 0 && (subs == 0 || (s.firebase != nil && subs == 1)) {
|
if msgs == 0 && (subs == 0 || (s.firebase != nil && subs == 1)) { // Firebase is a subscriber!
|
||||||
delete(s.topics, t.id)
|
delete(s.topics, t.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package server
|
||||||
import (
|
import (
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"heckel.io/ntfy/config"
|
"heckel.io/ntfy/config"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
@ -15,16 +16,17 @@ const (
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
limiter *rate.Limiter
|
limiter *rate.Limiter
|
||||||
subscriptions int
|
subscriptions *util.Limiter
|
||||||
seen time.Time
|
seen time.Time
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVisitor(conf *config.Config) *visitor {
|
func newVisitor(conf *config.Config) *visitor {
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
limiter: rate.NewLimiter(conf.RequestLimit, conf.RequestLimitBurst),
|
limiter: rate.NewLimiter(conf.VisitorRequestLimit, conf.VisitorRequestLimitBurst),
|
||||||
seen: time.Now(),
|
subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||||
|
seen: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,17 +40,16 @@ func (v *visitor) RequestAllowed() error {
|
||||||
func (v *visitor) AddSubscription() error {
|
func (v *visitor) AddSubscription() error {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
if v.subscriptions >= v.config.SubscriptionLimit {
|
if err := v.subscriptions.Add(1); err != nil {
|
||||||
return errHTTPTooManyRequests
|
return errHTTPTooManyRequests
|
||||||
}
|
}
|
||||||
v.subscriptions++
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) RemoveSubscription() {
|
func (v *visitor) RemoveSubscription() {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
v.subscriptions--
|
v.subscriptions.Sub(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Keepalive() {
|
func (v *visitor) Keepalive() {
|
||||||
|
@ -60,6 +61,5 @@ func (v *visitor) Keepalive() {
|
||||||
func (v *visitor) Stale() bool {
|
func (v *visitor) Stale() bool {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
v.seen = time.Now()
|
|
||||||
return time.Since(v.seen) > visitorExpungeAfter
|
return time.Since(v.seen) > visitorExpungeAfter
|
||||||
}
|
}
|
||||||
|
|
65
util/limit.go
Normal file
65
util/limit.go
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached
|
||||||
|
var ErrLimitReached = errors.New("limit reached")
|
||||||
|
|
||||||
|
// Limiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached
|
||||||
|
// ErrLimitReached will be returned. Limiter may be used by multiple goroutines.
|
||||||
|
type Limiter struct {
|
||||||
|
value int64
|
||||||
|
limit int64
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLimiter creates a new Limiter
|
||||||
|
func NewLimiter(limit int64) *Limiter {
|
||||||
|
return &Limiter{
|
||||||
|
limit: limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds n to the limiters internal value, but only if the limit has not been reached. If the limit would be
|
||||||
|
// exceeded after adding n, ErrLimitReached is returned.
|
||||||
|
func (l *Limiter) Add(n int64) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.limit == 0 {
|
||||||
|
l.value += n
|
||||||
|
return nil
|
||||||
|
} else if l.value+n <= l.limit {
|
||||||
|
l.value += n
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return ErrLimitReached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub subtracts a value from the limiters internal value
|
||||||
|
func (l *Limiter) Sub(n int64) {
|
||||||
|
l.Add(-n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sets the value of the limiter to n. This function ignores the limit. It is meant to set the value
|
||||||
|
// based on reality.
|
||||||
|
func (l *Limiter) Set(n int64) {
|
||||||
|
l.mu.Lock()
|
||||||
|
l.value = n
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns the internal value of the limiter
|
||||||
|
func (l *Limiter) Value() int64 {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return l.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit returns the defined limit
|
||||||
|
func (l *Limiter) Limit() int64 {
|
||||||
|
return l.limit
|
||||||
|
}
|
Loading…
Reference in a new issue