Commit Graph
4425 Commits
Author SHA1 Message Date
binwiederhier abd5ac3bb9 Add regression tests for the auth and account limiters under concurrent logins 2026-10-29 01:37:23 +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
Philipp C. Heckel 3798933838 Merge pull request #2105 from binwiederhier/step2-limit
Shared rate limits, step 2: one limiter model in package limit
2026-10-28 19:04:21 -04:00
binwiederhier 1ba8fa2863 Keep spent tokens on a burst change; account shows the emails sendable now
A tier upgrade used to keep the old bucket's tokens, capped at the new\nburst, so a bigger tier did not add its difference. The bucket now shifts\nby the burst difference both ways, down to a debt of one burst.\n\nemails_remaining is now capped by what the email bucket holds, so it no\nlonger shows emails the visitor cannot send (e.g. after the daily reset).\n\nClaude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-28 22:13:53 +00:00
binwiederhier 6463981f8f One field per line in multi-field struct literals
Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-28 21:35:06 +00:00
binwiederhier cda6f241f9 Hold the limit shards by pointer
Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-28 21:16:08 +00:00
binwiederhier 3cb7a98d01 Revert "Fix dead links in the contributing guide as rendered on GitHub"
This reverts commit dbe33ba495.
2026-10-27 00:31:34 +00:00
binwiederhier dbe33ba495 Fix dead links in the contributing guide as rendered on GitHub 2026-10-27 00:26:30 +00:00
Philipp C. Heckel 87d20d52ad Merge pull request #2104 from binwiederhier/step1-visitor
Shared rate limits, step 1: package visitor for keying and limit resolution
2026-10-26 09:24:17 -04:00
binwiederhier 63d3bb533b Add the limit package to the Docker build
Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-26 13:09:39 +00:00
binwiederhier c443886193 Merge branch 'step1-visitor' into step2-limit 2026-10-26 13:09:39 +00:00
binwiederhier cf20182de7 Add the visitor package to the Docker build
Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
2026-10-26 13:09:38 +00:00
binwiederhier f16baadec4 Let a zero message, call or bandwidth limit admit nothing again
The limit service reads a zero cap as no cap, so a tier with no calls
(or no messages, or no attachment bandwidth on upload) became unlimited
where main's FixedLimiter(0) and bytes limiter admitted nothing. The
visitor now answers zero limits itself before asking the service, like
it already did for requests, emails and bandwidth downloads.
2026-10-25 21:11:36 +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 2243c96812 Move visitor keying and limit resolution into package visitor
The visitor key (ip:<prefix> with the configured prefix bits, or user:<id>
for a user with a tier) and the resolution of a visitor's limits from the
server defaults or the user's tier move out of server/visitor.go into the
new package visitor, unchanged. The server keeps thin wrappers, so its
tests are untouched. No behavior change. First step of the shared rate
limits design (plans/261025-shared-rate-limits.md).
2026-10-25 15:36:10 +00:00
binwiederhier 9f1d2ea44e Release notes and install docs, prep v2.34.0 v2.34.0 2026-10-24 19:08:22 +00:00
Philipp C. Heckel 313b6da7f5 Merge pull request #2095 from binwiederhier/cluster-drain
Drain cluster nodes on shutdown
2026-10-24 14:56:09 -04:00
binwiederhier 8d1e7f129c Call the first drain phase withdrawing, and label both phases in Drain 2026-10-24 18:42:26 +00:00
binwiederhier 7a8c9cf83c A draining node is not ready: let Ready say so, not handleReady
meshCluster.Ready is already false while draining, since the node acts as passive; the extra check in handleReady was redundant. The fake cluster now follows the same contract.
2026-10-24 18:35:10 +00:00
binwiederhier 70781daf2d Merge remote-tracking branch 'origin/main' into cluster-drain
# Conflicts:
#	cluster/cluster_mesh_test.go
#	server/server.go
#	server/server_cluster_test.go
2026-10-24 18:06:11 +00:00
binwiederhier 97666f2897 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web
# Conflicts:
#	web/public/static/langs/sv.json
2026-10-24 18:05:37 +00:00
Philipp C. Heckel 0bcf0bb519 Merge pull request #2077 from binwiederhier/cluster-cancel-relay
Reload the access cache on access changes across cluster nodes
2026-10-24 13:58:09 -04:00
binwiederhier 5323192605 Move the access cache reload loop and coalescing into the cache
accessCache now owns everything about reloading: Reload (synchronous, reports changes to the callback after releasing reloadMu), RequestReload (coalesced, at most one pending), the periodic loop, and Close. The Manager only tells the peers and forwards.
2026-10-24 17:49:02 +00:00
Philipp C. Heckel 3adc7fcc3f Merge pull request #2099 from roypadina/docs/integrations-ntfybar-meetalert
docs: add NtfyBar and MeetAlert to integrations
2026-10-24 13:44:55 -04:00
binwiederhier e57c755e7c Name stored ACL topics escapedTopicPattern, and AccessChange.TopicPattern 2026-10-24 17:21:03 +00:00
binwiederhier 6a0ae705a3 Reload the access cache every 67 s 2026-10-24 17:11:51 +00:00
binwiederhier b327c520a4 Test that a write during a reload reaches the cache after a burst
The coalescing test made its write before the burst, so it passed even if requests arriving during a reload were dropped. It now holds the first reload after its scan, writes and bursts while it is held, and requires the follow-up reload to see the write. Found by a Codex review.
2026-10-24 15:57:42 +00:00
binwiederhier 4ff26f78a6 Scan topics and diff the access cache outside the locks
topicsFromPattern held Server.mu while regex-matching every topic (~313k on ntfy.sh, ~89 ms), blocking every request that creates a visitor; allTopics copies the pointers under the lock and the match runs outside it (also used for deleted users). The access diff (~2.3 ms) no longer runs under the cache's write lock: only reloads replace the maps, and they are serialized. Found by a Codex review.
2026-10-24 15:55:30 +00:00
binwiederhier 0630993ba7 End a user's streams everywhere when they delete their own account
Account self-deletion (MarkUserRemoved) sent no deleted-user signal and did not end the user's local streams; an admin without access entries kept them, here and on peers. Both deletes now go through userDeleted. Found by a Codex review.
2026-10-24 15:52:57 +00:00
binwiederhier bc88121f80 Authorize a new stream once more after it subscribed
A revoke whose re-check ran between a request's authorization and its stream subscribing missed the stream for good: later reloads find no difference. With the access cache on, the subscribe handlers now authorize once more right after subscribing (an in-memory lookup); the re-check runs after the cache swap, so one of the two always sees the other. Found by a Codex review.
2026-10-24 15:51:31 +00:00
binwiederhier 70f35dc33d Re-check each subscription against its own user
SubscriberUsers kept one user per user ID, so streams of one user with different users as of subscribing (e.g. before and after a demotion) were all judged by whichever came last. topic.CancelSubscribersWhere checks each subscription's own user, outside the topic lock; SubscriberUsers and CancelAnonymousSubscribers are gone. Found by a Codex review.
2026-10-24 15:49:08 +00:00
binwiederhier e127f081e3 Test diffACLEntries directly
Added, removed and changed entries, exact and wildcard, per user, and the * form of wildcard topics. The loadCache test helper now builds entries with toACLEntry, as Reload does, so wildcard entries carry their stored topic.
2026-10-24 14:53:55 +00:00
binwiederhier 22aab4e0bb Signal deleted users instead of subscriber cancels
The only cancel still relayed was a deleted user's, so the cluster now signals the event, like AccessChanged: State.DeletedUserIDs (deleted_user_ids on the wire) and Config.UsersDeletedFunc replace SubscriberCancels, the SubscriberCancel type and CancelFunc. The server's handleUsersDeleted ends those users' streams on every topic, for a local delete and a peer's.
2026-10-24 14:37:01 +00:00
binwiederhier 99f85b9e25 Give the cluster's AccessChangedFunc and IsolatedFunc their own types 2026-10-24 14:29:57 +00:00
binwiederhier 01d2ea6e0c Less code: flat access diff, no exported full reload, one cancel struct
The reload diff flattens both snapshots into one map keyed by user and stored topic, replacing four helpers. ReloadAccessCache was only used by tests (the server requests coalesced reloads), so they call maybeReloadAccessCache. killUserSubscriberEverywhere builds the cancel once, applies it locally and broadcasts it.
2026-10-24 13:54:32 +00:00
binwiederhier 5e305791c1 SubscriberCancel only carries the user ID
The only cancel still relayed is a deleted user's, on every topic; access changes go through the access cache reload. Topic and ExceptUserID are gone, and cancelUserSubscribersLocal ends a user's streams on all local topics for both the local delete and the peer cancel. No released version sends these cancels, so the wire format can change.
2026-10-24 13:49:56 +00:00
binwiederhier 04ef414981 Derive a subscriber's user ID from its user, hoist loop expressions, reload every 63 s
topicSubscriber no longer stores the user ID next to the user (userID() derives it), which also brings the struct below its size before the user was added. The cancel loops assign the matching subscribers to a variable instead of calling subscribersMatching inside the for header. The periodic access cache reload runs every 63 s: access changes are rare and peers are told directly, so the interval only bounds a missed signal.
2026-10-24 13:45:18 +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 c296487f64 Rename AccessChangedFunc to AccessCacheChangedFunc
It fires when an access cache reload found a difference, whoever made the change; the cache's content changed, not necessarily because of this node. The cluster's AccessChangedFunc (a peer changed access) keeps its name.
2026-10-24 12:43:27 +00:00
binwiederhier 138922526a Tell the peers about an access write before reloading locally
accessWritten now calls AccessWrittenFunc first, so the other nodes start their reloads without waiting for this node's (about one reload, ~20 ms on prod), then reloads the local cache before the write returns.
2026-10-24 12:42:09 +00:00
binwiederhier c81645755b Give AccessWrittenFunc a type, and explain when each access callback fires 2026-10-24 12:37:36 +00:00
binwiederhier e9d089f00f Access cache: db and query in the constructor, clearer names and order
newAccessCache takes the database and the reload query, so Reload needs no arguments. reloadRequests is now accessCacheReloadRequests, above quit. AccessChangedFunc and AccessWrittenFunc sit below AccessCacheReloadInterval in user.Config, with comments.
2026-10-24 12:33:10 +00:00
Alvaro e65b2fb7b2 Translated using Weblate (Catalan)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/
2026-10-24 01:05:18 +02:00
binwiederhier 3437cf6169 Fix a deadlock when canceling subscribers with debug logging, and a log override race
Canceling a subscriber logs the topic's context, which takes the topic lock. The cancels held that lock while they logged: CancelSubscribersExceptUser held the write lock (deadlock at once), the per-user cancels a read lock (deadlock as soon as a canceled stream's Unsubscribe waited for the write lock). The context is only evaluated with debug logging or log-level-overrides on. In the lab this froze the access cache reload loop after its first re-check. The cancels now collect the matching subscribers under the lock and cancel them after releasing it.

globalLevelWithOverride iterated the overrides map without the log lock while SetLevelOverride changes it in place; it now holds the read lock while iterating.
2026-10-23 22:38:59 +00:00
binwiederhier b66ad90f84 Coalesce access cache reloads requested by peers
A burst of "access changed" signals no longer queues one full reload per signal on each peer: RequestAccessCacheReload returns at once and hands the request to the reload loop through a channel with room for one, so requests that arrive while a reload runs collapse into one more reload, which still sees every change. The node making the change keeps its synchronous reload (read-after-write).
2026-10-23 22:15:48 +00:00
binwiederhier 1bd90cf924 Reload the whole access cache on every access change, and tell the peers
Access changes are rare and a full reload is cheap (about 15 ms on ntfy.sh's 5,500 entries), so the access cache no longer reloads per user. Every reload is full and serialized (which replaces the sequence-number race guard), and the diff after each reload re-checks the live subscribers on the changed topics.

An access write (reservation, allow, reset, user removal, role change) reloads the local cache and tells the other cluster nodes "access changed" (cluster.State.AccessChanged), which reload theirs; their diff ends the streams that lost access there. This replaces the relayed per-topic cancels and the targeted reloads. Only a deleted user's cancel is still relayed: a user without access entries (e.g. an admin) leaves nothing in the cache to compare. The periodic reload, the backstop for a lost signal and for writes by other processes (ntfy access CLI), now runs every 31 s instead of 87 s.
2026-10-23 21:39:00 +00:00
binwiederhier 1fd5477981 Call it draining on shutdown, not maintenance mode
Other systems keep two things apart: draining before a shutdown (ephemeral, cleared by the restart: RabbitMQ drain, CockroachDB node drain) and an operator's maintenance flag (persistent cluster state: Kubernetes cordon, Nomad eligibility, Consul maint). This PR is the former, so it is named that way: Server.Drain (synchronous again; the on/off API is gone until the persistent maintenance flag exists), Cluster.SetDraining/Draining, the read-only phase flag readOnly, /v1/ready reports "draining": true, error 50301 says the node is shutting down, and docs/config.md has a "Draining on shutdown" section. A drain duration of 0 now closes all streams at once instead of disabling the drain.
2026-10-23 21:20:57 +00:00
binwiederhier 626ec1f4fa Fix lint: one comment per cluster type alias 2026-10-23 21:07:08 +00:00
binwiederhier 86803803b1 Allow turning maintenance mode off again
Maintenance mode is now a state that can be entered and left without a restart: Server.SetMaintenance(on) runs the phases in the background and returns at once; off stops them wherever they are and takes the node back into service (ready, writable, active in the cluster). WaitDrained waits for the drain. On SIGTERM, Drain is SetMaintenance(true) plus WaitDrained, as before. SetMaintenance(true) is idempotent and refuses with drain duration 0, on a passive node, or without another active node. This is what a later 'ntfy cluster maintenance on|off' needs.
2026-10-23 20:54:29 +00:00