Commit Graph
285 Commits
Author SHA1 Message Date
binwiederhier c113e20677 Share daily limits through the usage table, drop the midnight reset and the stats queue
The server builds the usage store on the user database (the shared pool on
Postgres, the auth-file on SQLite) and starts the limiter before serving. A
visitor without a user writes its message count from a tenth of the cap on,
everyone else from the first message; anonymous emails get the daily cap the
replenish interval implies. The account API reports reset_at. The user stats
columns are no longer read or written.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-30 20:41:36 +00:00
binwiederhier 59cee8430a Split daily counters from token buckets, roll each key over on its own 24h period
Daily counts (messages, emails, calls) now live in per-key counters that start
over lazily when the key's period ends; buckets keep their tokens. ResetAt
reports the end of the key's period. Seed, ResetUsage and Config.ResetTime are
gone, and an idle key is forgotten only once its buckets are full again.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-30 20:23:47 +00:00
binwiederhier e7cc914087 Keep a topic's rate visitor alive while any stream is open on it
Liveness no longer comes from keepalives: a topic keeps its rate visitor
while it has an open stream, and for a day after the last one closed or
the last subscribe. This removes the keepalive matching, taking a stale
topic back and its write re-check.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-30 03:35:14 +00:00
binwiederhier f666779ee8 Check that a stream may still write before it takes a topic back
Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 23:39:13 +00:00
binwiederhier 1883645c1a Keep a topic's rate visitor alive by the topic, for any stream with its key
A stream's keepalives refreshed only its own visitor, so a topic that held
the visitor of a newer stream or a poll from the same key lost it after a
day although a stream was still open. The topic now keeps when a
subscriber with its rate visitor's key was last seen, and a stream takes a
stale topic back.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 22:54:19 +00:00
binwiederhier 4ed82ddd69 Do not cap the emails of visitors without a tier at a day's refill
Without a tier, the daily email limit is only derived from the refill, for
display. Enforcing it as a cap cut what the email bucket allowed: with a burst
of 5 and a refill every 288 minutes, 5 emails a day instead of about 10. Only
a tier's daily email limit is a cap now, as before.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 21:10:24 +00:00
binwiederhier 3cae50f18d Keep a stream as the topic's rate visitor when its subscriber polls
Since every request gets its own visitor, a poll from the subscriber of an
open stream replaced the stream's visitor as the topic's rate visitor. Only
the stream's keepalives keep a rate visitor alive, so a day later UnifiedPush
publishes failed with 50701 while the stream was still open. A poll from the
same key now keeps the live rate visitor (and keeps it alive) instead.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 19:46:13 +00:00
binwiederhier 647698c607 Keep the visitor's subscription methods in visitor.go
Only the gauge lives in visitor_subscriptions.go; the tests follow their code.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 13:24:57 +00:00
binwiederhier 89cee1cd11 Keep a topic's rate visitor for as long as its subscriber is, not its key
Stale read the limiter key's last use, which every charged publish refreshes, so a topic kept
a vanished subscriber's limits (and its UnifiedPush subscription) for as long as anyone
published to it. The visitor now carries its own keepalive, set when it is built and by the
subscribe handlers' keepalive loops, like the shared visitor's seen was.

Also from the review: Prune returns and the manager logs the number of forgotten keys, the
unused visitorID wrapper goes, a stale comment and test name are updated, and the release
notes get the #1051 entry.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 03:21:37 +00:00
binwiederhier d61081fe4e Name the shared visitor environment Server.visitors
Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 03:04:24 +00:00
binwiederhier 9693d393c8 Merge branch 'limit-fixes' into visitor-per-request
The three fixes apply as they are (the email guard without the visitor mutex, which this
branch no longer has). Of the new tests, the two that pin the shared visitor's SetUser flush
and the expunge of visitor objects have no counterpart here; the rest compare visitor keys
where they compared object identity.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 02:30:47 +00:00
binwiederhier 62abb9b729 Close three gaps in the limit rework and pin its behavior with tests
A tier with a daily email limit of zero sends no emails (the limit service reads a daily
limit of zero as "no cap"), a tier edited in place flushes the visitor's pending user stats
before resetting its local counts like a tier ID change does, and a bucket refill with a
timestamp older than its last one neither charges tokens nor moves the refill point back.

The tests pin what the rework changed and what it must keep: shared keys count once in the
user stats, an expunged visitor continues its counts, the daily reset keeps the buckets,
zero bursts admit nothing, seeds apply once, prefix keying, tier changes in all directions,
concurrency on a shared key, and the account page's remaining values matching admissions.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-29 02:28:25 +00:00
binwiederhier a6cd0e4d7e Build the shared visitor config and IP limits once
Visitors are built per request, so what they share (the keying and limit
config, the config-based limits of visitors without a tier, the limiter and
the subscription gauge) is built once in New. Keys are built with string
concatenation instead of fmt.Sprintf.
2026-10-29 02:06:26 +00:00
binwiederhier ccd4dd9d85 Build the visitor per request instead of sharing it
Requests from one IP (anonymous and untiered users) shared a single visitor
object with a mutable user, so overlapping requests could be authorized as
each other's user. The visitor is now built per request and never changed;
the limit service, keyed by visitor key, still shares the buckets and counts.
The Firebase penalty becomes a limiter kind, open subscriptions move to a
node-local gauge per key, and tier users' stats are enqueued per admitted
unit.

Fixes #1051
2026-10-29 01:28:12 +00:00
binwiederhier 601900cf16 Reject Matrix push keys that do not name a valid topic
The Matrix gateway built its publish request from the push key without the
router's topic path check. Publishing sanitizes the topic after authorization
(it strips NUL bytes and invalid UTF-8), so an anonymous push key like
/sec%00ret was authorized against an unmatched topic and stored under the
protected topic "secret".
2026-10-29 00:36:23 +00:00
binwiederhier 4bbb4418a5 One limiter model in package limit, replacing the three limiter types in the visitor
Allow(key, rule, n), Refund, Tokens and Usage on a service that keeps a
token bucket and a daily count per key and kind, sharded by key, with the
rule passed in on every call. The server's visitor keeps its identity and
the resolved limits and asks the service; rate.Limiter, util.RateLimiter
and util.FixedLimiter are gone from it (subscriptions stay a per-node
gauge). util.RateLimiter, NewBytesLimiter and BurnTokens had no users
left and are removed.

Behavior changes: refunds work for every kind (a revert on the bandwidth
limiter hands the bytes back), a tier change applies on the next request
without handing back full buckets, a changed tier limit applies without
a tier ID change, and the daily email limit is a real daily cap on top
of the bucket. Step 2 of plans/261025-shared-rate-limits.md.
2026-10-25 15:50:03 +00:00
binwiederhier 4f3037530b Re-check subscribers against their user as of subscribing, without the database
topic.Subscribe now takes the subscriber's *user.User (nil if anonymous) and keeps it on the subscription. reauthorizeSubscribers authorizes that user against the access cache, so an access change no longer costs one UserByID query per distinct subscriber user on every matching topic, on every node (a wildcard change could mean thousands). A deleted user's streams end through the relayed cancel (killUserSubscriberEverywhere), as before.
2026-10-24 13:19:55 +00:00
binwiederhier 49a28e8cdb Merge remote-tracking branch 'origin/main' into cluster-cancel-relay
# Conflicts:
#	docs/releases.md
2026-10-23 20:23:37 +00:00
binwiederhier 6f94317377 Allow several comma-separated addresses in listen-http
A load balancer connects to each upstream address from one source IP, so one address caps it at ~63k connections to a node. listen-http now takes a comma-separated list (whitespace trimmed, empty and duplicate entries rejected, "-" still unsets it); Config.ListenHTTP becomes a slice, Run starts one HTTP server per address on the same handler, and stop closes all of them.
2026-10-23 04:12:25 +00:00
binwiederhier e6e236359d Merge remote-tracking branch 'origin/main' into cluster-cancel-relay
# Conflicts:
#	docs/releases.md
#	server/server.go
2026-10-22 02:03:26 +00:00
binwiederhier b49e2c319a Pool roles as constants 2026-10-22 01:33:48 +00:00
binwiederhier dbeeaa72c1 Export PostgreSQL connection pool metrics
ntfy_db_pool_connections_{max,open,in_use,idle}, ntfy_db_pool_waits_total and
ntfy_db_pool_wait_seconds_total, one series per pool (role primary or
replica, host). Read from database/sql's pool stats at scrape time, so a
burst that exhausts a pool between two scrapes still shows up as waits.

Needed to size pool_max_conns for several active cluster nodes: the
managed database allows 97 connections, and the open count alone says
nothing, since the pool rotates through all its connections.
2026-10-22 01:14:41 +00:00
binwiederhier 2734662e79 Relay subscriber cancels to cluster peers and re-check access changes
Reserving a topic or revoking access only canceled the subscribers on the
node that made the change; on other cluster nodes, an anonymous stream kept
receiving a topic that had just become private.

- Relay reservation takeovers and access revocations to peers (cluster
  CancelFunc). The peer reloads its access cache for everyone and the user
  concerned before canceling, so an immediate reconnect is denied.
- Access cache reloads now report the entries they found changed, and the
  server re-checks only the subscribers on matching topics. This catches a
  lost relay and changes made with the ntfy access CLI within one reload
  interval (auth-access-cache-reload-interval).
- CancelSubscriberUser ends all of a user's streams on a topic, not just
  the first one.
2026-10-19 12:14:00 +00:00
binwiederhier 85d7d3f1a4 De-duplicate subscription topics and limit them to 150
Repeating a topic in a subscription replayed its cache once per repetition.
2026-10-18 13:00:55 +00:00
binwiederhier 4e2f9314b7 Merge remote-tracking branch 'origin/main' into sse-incremental-header
# Conflicts:
#	docs/releases.md
2026-10-17 01:01:08 +00:00
binwiederhier 4a23d8d708 Use the request priority when a template file has none
Fixes #2045
2026-10-17 00:40:07 +00:00
binwiederhier 5c55d2a631 Set the Incremental header on streaming subscriptions
Fixes #2056
2026-10-17 00:40:01 +00:00
binwiederhier 0051691221 Test that emails receive the published attachment
Covers both uploaded attachments (with expiry) and external attach= URLs.
2026-10-09 21:10:05 +00:00
binwiederhier f18d1137c6 Assert database round trips instead of wall-clock time in the batch writer tests 2026-10-07 15:09:48 +00:00
binwiederhier 9c709731df Fixes 2026-10-07 14:46:32 +00:00
binwiederhier 87a27188f1 Test that a scheduled message is replaced when the sequence ID comes as a parameter
The existing update tests only set the sequence ID in the path; this covers the ?sid= form,
which takes the same DeleteScheduledBySequenceID path.
2026-10-07 13:41:50 +00:00
binwiederhier 99d55175d4 Keep the message batch writer ahead of publish bursts
On cron bursts (top of the hour), the single batch writer fell behind: it wrote one INSERT round
trip per message, so against a busy remote Postgres it managed ~100 rows/s while ~300/s arrived.
New messages took up to ~40s to reach the database (polls missed them), and publishes blocked on
the unbuffered queue for up to ~25s.

- Postgres: write each batch with one multi-row INSERT (up to 1000 rows per statement) instead of
  one statement per row. SQLite keeps the row-by-row path.
- Skip DeleteScheduledBySequenceID when the client did not set a sequence ID: the sequence ID is
  then the message's own fresh ID, so no scheduled message can match. This saves a transaction on
  the primary for almost every publish.
- Buffer up to 100 batches in the BatchingQueue, so a briefly slow writer does not stall publishes.
- dbtest: add a latency proxy (CreateTestPostgresWithLatency, WithLatency) to test round trips.
2026-10-07 11:54:46 +00:00
Philipp C. Heckel ea5912b640 Merge pull request #1946 from justadityaraj/fix/1940-alertmanager-end-time
Omit zero end times from Alertmanager notifications
2026-10-06 23:58:13 -04:00
binwiederhier fa9ff8c68a Add uppercase Content-Type test and release notes for #1995 2026-10-05 21:14:27 +00:00
cipherprofessor 3e9fe9946b Detect Markdown when Content-Type has parameters, e.g. text/markdown; charset=utf-8
The Content-Type header was compared to "text/markdown" as a whole string, so any media type parameter (most commonly charset) made ntfy treat the message as plain text. Parse the header with mime.ParseMediaType and compare only the media type.

Fixes #1992
2026-10-05 11:22:20 +05:30
binwiederhier 2153f646be Keep shutdown bounded, and do not start listeners after Stop
Now that the signal handler waits for Stop, two holes in Stop matter:

- Stop waits for every store to close, and those waits have no deadline of
  their own: attachment.Store.Close waits for the sync loop, whose
  AttachmentsWithSizes query runs against the database (and S3 deletes are in
  there too). A wedged database parked Stop before it ever reached the message
  cache, whose own 5s bound was then useless. Stop now gives up after 10s.
- A signal can arrive before Run has set up its listeners. Stop closed the
  stores and consumed stopOnce, and Run then started listeners against closed
  stores and blocked on the listener error, so the process neither served nor
  exited and a second signal could not stop it. Run bails out when the server
  is already stopped, outside the lock (a stuck shutdown holds it) and again
  under it.
2026-10-03 16:18:06 +00:00
binwiederhier e63ec41b2e Close the message cache before the other stores on shutdown
On Postgres all stores share one pool, and closeDatabases closed the user
manager first, which closed the pool under the message cache's final batch
write ("Cannot write message batch: sql: database is closed").
2026-10-03 15:06:45 +00:00
binwiederhier bc1e879dc4 Persist batched messages on shutdown; graceful SIGTERM in serve
With cache-batch-size/-timeout set (ntfy.sh: 100 / 1500ms), accepted messages
wait in a BatchingQueue before they are written. Nothing drained that queue on
shutdown, and `ntfy serve` did not handle SIGTERM at all (only SIGHUP), so
every restart dropped up to one batch timeout's worth of accepted messages
from the cache: live subscribers got them, pollers and since= replays never
did.

- util.BatchingQueue.Close emits the pending elements and closes the output
  channel (Enqueue after Close is a no-op)
- message.Cache.Close closes the queue and waits for the batch writer before
  closing the database
- serve stops the server on SIGTERM/SIGINT and waits for Stop to finish (Run
  returns as soon as the listeners close); Stop is idempotent now, since the
  signal handler and serve both call it
2026-10-03 15:06:09 +00:00
Aditya 535e154666 Fix Alertmanager template end time 2026-09-10 03:19:47 +05:30
binwiederhier 7826c86f67 Remove message-poll-limit again, hardcode title/tags/etc limits, count polls toward bandwidth limit 2026-08-27 19:42:30 +00:00
binwiederhier cae7816e5d Add message-poll-limit ... 2026-08-27 16:38:21 +00:00
binwiederhier fbcd4b6290 Expands bandwidth limit to also include excessive polling 2026-08-27 14:12:01 +00:00
binwiederhier 2bc145f3ae Merge branch 'release-2.26.x' 2026-07-20 23:42:03 +02:00
binwiederhier c674985699 Redo the banning logic, ban.Service 2026-07-20 21:34:47 +02:00
binwiederhier 469d263a5c Hotfix: Add ban-file/ban-threshold/ban-weights as a more lightweight mechanism abuse counter 2026-07-18 08:41:25 +02:00
binwiederhier f8d2fcd7a6 Move metrics to metrics/ pacakge 2026-07-17 22:08:21 +02:00
binwiederhier 3f56dae54a Template exec context, redone 2026-07-10 13:18:11 +02:00
binwiederhier 165f012ae6 Add AST check to catch calls, remove regex for block and template 2026-07-08 21:56:57 +02:00
binwiederhier 1d69ebaf58 Additional test 2026-07-08 21:18:19 +02:00
binwiederhier a001ac5195 Template execution rework 2026-07-08 21:08:07 +02:00