Commit Graph
682 Commits
Author SHA1 Message Date
binwiederhier b65e642d05 Merge remote-tracking branch 'origin/main' into up-payer
# Conflicts:
#	docs/releases.md
2026-11-01 11:13:01 +00:00
binwiederhier 2e8506f827 Make the admin socket experimental and off by default
Rename listen-admin-unix to experimental-listen-admin-unix and drop the
/run/ntfy/admin.sock default: the CLI side (#2089, #2135) is not merged,
so nothing uses the socket yet. The startup warning no longer points at a
CLI fallback, and the docs say the commands do not use it yet.
2026-10-31 15:00:21 +00:00
binwiederhier ac9c6b2bac Keep the UnifiedPush rate visitors in the topic service, derive the limiter key at push time
The server uses the topic package instead of the limit service for the rate
visitors: a subscribe sets the rate visitor (user ID and masked IP), a poll,
a stream close and the manager's keepalive record subscriber activity, a
refused push notes an unknown topic (insert-only). The limiter key is derived
on every push from the rate visitor's current tier (refreshed in the
background every minute, never on the publish path), so a downgrade or a
deleted account charges the IP. Before a 404 for an abandoned topic the row
is read from the database. The Matrix gateway rejects the pushkey under the
same 7-day rule; its 12 h per-node rule is gone.
2026-10-31 14:05:37 +00:00
Philipp Heckel a11f090df3 Rename the UnifiedPush payer to rate visitor, and its table to topic
"Rate visitor" is the term the docs already use. The table is called topic so it can hold more per-topic, cluster-wide state later. seen_at keeps its name, since a noted row's seen_at is a refused push, not a subscription.
2026-10-31 13:05:33 +00:00
binwiederhier 9c6733d06c Bill UnifiedPush pushes to the topic's payer on every node, answer 404 for topics abandoned for 7 days 2026-10-31 11:31:09 +00:00
binwiederhier 0f3a57a2a2 Merge remote-tracking branch 'origin/main' into shared-daily-limits
# Conflicts:
#	docs/releases.md
#	server/server.go
2026-10-31 03:14:26 +00:00
binwiederhier 8e16b880c8 Close the cluster when the server stops
Server.stop never closed the cluster, so a stopped node stayed registered and its
heartbeat kept running against the closed database, logging from leaked goroutines.
In CI that raced with captureLog's unsynchronized buffer in a later test. captureLog
now uses syncBuffer as well.
2026-10-31 01:55:42 +00:00
binwiederhier 521ca530b5 Open the SQLite user database in the server and pass it to the user manager and the usage store
Like the Postgres pool: user.OpenSQLite opens the auth-file (case-sensitive LIKE),
NewSQLiteManager takes the handle and migrates it, and the usage store gets the same handle
instead of reaching into the manager (Manager.DB is gone). The usage table stays part of the
user schema.
2026-10-31 01:50:10 +00:00
binwiederhier 9ecbf4dc16 Export ntfy_db_up and health-check the primary like the replicas
A node cut off from its database only showed in the logs. The health check loop now always
runs and pings the primary too; ntfy_db_up (1/0, per pool, next to ntfy_db_pool_*) is the
result of the last check, so an alert can fire on ntfy_db_up{role="primary"} == 0.
2026-10-31 01:31:13 +00:00
binwiederhier a2656890d1 Drop the date from the admin socket startup warning 2026-10-31 00:42:49 +00:00
binwiederhier 6cb87c31b4 Rename adminSocketListener to adminUnixListener 2026-10-30 23:51:28 +00:00
binwiederhier 89cd122952 Start the admin socket listener like the unix listener, without a server field 2026-10-30 23:35:33 +00:00
binwiederhier 0e0b2e4c7c Mention the deprecated database fallback in the admin socket startup warning 2026-10-30 23:27:00 +00:00
binwiederhier 0f49391494 Drop the deprecation of starting without the admin socket, just warn 2026-10-30 23:22:08 +00:00
binwiederhier 5737b0bd8c Start the admin socket in Run like the other listeners, and deprecate starting without it 2026-10-30 21:26:40 +00:00
binwiederhier 59775edc72 Rename admin-socket to listen-admin-unix 2026-10-30 20:52:50 +00:00
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 f8f0d87253 Merge branch 'adminsock-api' into adminsock-socket
# Conflicts:
#	docs/releases.md
#	server/server.go
#	server/server_admin_socket.go
#	server/server_admin_test.go
2026-10-30 20:40:33 +00:00
binwiederhier 862bc5e14f Merge remote-tracking branch 'origin/main' into adminsock-api
# Conflicts:
#	docs/releases.md
2026-10-30 20:37:09 +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 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 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 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 e0ee4b090f Merge remote-tracking branch 'origin/main' into adminsock-api
# Conflicts:
#	docs/releases.md
#	server/server_admin.go
#	server/server_admin_test.go
2026-10-29 01:46:43 +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 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 3c7cbc89db Merge branch 'adminsock-api' into adminsock-socket
# Conflicts:
#	docs/releases.md
2026-10-25 00:55:33 +00:00
binwiederhier fe5417b2c9 Price list at /v1/account/billing/tiers, next to the other billing routes
The /v1 API was never published as stable, so the fallback for older
web apps is not a deprecation, just a temporary route.
2026-10-25 00:55:17 +00:00
binwiederhier 64b3be3581 Merge branch 'adminsock-api' into adminsock-socket
# Conflicts:
#	docs/releases.md
#	server/server_admin_socket.go
2026-10-25 00:25:53 +00:00
binwiederhier d0dbd1dcbe Move the price list to /v1/billing/tiers, manage tiers at /v1/tiers
/v1/tiers is now the admin API for tiers (GET/POST/PUT/DELETE), instead
of the unreleased /v1/admin/tiers. The public price list for the upgrade
dialog moved to /v1/billing/tiers; GET /v1/tiers still returns it to
non-admins until 2026-11-22, for web apps from before v2.33.0.
2026-10-25 00:25:35 +00:00
binwiederhier 494962da52 Move the admin socket code into server_admin.go
One file for the admin API, whether it arrives over HTTP or the socket.
2026-10-24 19:24:42 +00:00
binwiederhier cf4de9358b Merge branch 'adminsock-api' into adminsock-socket
# Conflicts:
#	server/server_admin_socket.go
2026-10-24 19:23:37 +00:00
binwiederhier cb34deee5d Keep the admin routes inline in handleInternal
The admin socket reuses handleInternal behind a path allowlist
(adminSocketPaths) instead of a separate adminHandler switch, so all
routes are listed in one place.
2026-10-24 19:23:08 +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 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 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 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 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 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 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
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 62fb43b44b Make the cluster the owner of maintenance mode
The cluster now owns the maintenance state (Cluster.Maintenance) instead of the server keeping its own copy; the server keeps only the draining phase. Role() returns the role the node acts in (passive in maintenance mode), so /v1/ready says "role":"passive","maintenance":true. Cluster.HasActivePeer replaces the server's own walk over Members. A single-node server no longer logs about maintenance mode on every shutdown. The stream registry moves to its own file, with unit tests.
2026-10-23 19:42:18 +00:00
binwiederhier 30eb5db6d8 Maintenance mode: enter, then drain read-only
Maintenance mode now has two phases. Entering (experimental-cluster-drain-delay, default 15s): /v1/ready fails and the node acts as passive in the cluster, but it still serves everything, so the load balancers can stop sending it requests without any request failing. Draining (experimental-cluster-drain-duration, default 1m): read-only, i.e. new subscriptions and all publishes get 503 (email gets SMTP 451, so mail servers retry instead of bouncing), and the streams are closed evenly. The defaults together fit systemd's 90s stop timeout. docs/config.md gets a maintenance mode section with the behavior table.
2026-10-23 13:20:33 +00:00
binwiederhier 0a10a13511 Act as passive in maintenance mode, and claim no delayed messages
A node in maintenance mode now registers as passive (registry.SetRole at runtime), so peers stop counting it as a place for clients and leadership moves to the next active node; it is not ready, does not lead (at once, before its row says passive), and never fences. Its configured role is unchanged. It also claims no due delayed messages, which the next leader sends. Also tests that a drain duration of 0 is accepted.
2026-10-23 12:52:00 +00:00