c4401865759cc7499f31a91138bf5c4f4707f3aa
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d49d4a1025 |
perf(relay): load benchmark + bump per-session outbound buffer
Adds an opt-in load benchmark suite (`-DrunLoadBenchmark=true`)
covering: held-open WebSocket count, single + concurrent EVENT
publish throughput, and live-event fan-out latency to N
subscribers on one connection.
Numbers from a small VM (4 GiB RAM, 4 vCPU, ulimit -n 4096):
Connections (raw WS, one REQ each)
100 : 100 OK, 0.7 s
500 : 500 OK, 1.2 s
1000 : 1000 OK, 2.0 s
2000 : 1993 OK (hits the 4096-fd ceiling: 2 fds per WS)
Single-publisher serial publish + OK
10000 events, 13.2 s, 760 EPS (round-trip latency bound)
Concurrent publishers (each on its own WS)
parallel=2 : 807 EPS
parallel=4 : 1452 EPS
parallel=8 : 1989 EPS ← knee
parallel=16 : 1704 EPS ← SQLite single-writer ceiling
parallel=32 : 1778 EPS
Fanout (one publish, N active subs on a single WS)
100 subs : 67 ms last
500 subs : 52 ms last
1000 subs : 51 ms last
2000 subs : 111 ms last
Bumped SESSION_OUTGOING_BUFFER from 1024 → 8192. The 1024 cap was
hit by the 2000-sub fanout test (2000 outbound frames into one
session's queue), causing the relay to drop the connection as a
slow-consumer protection. 8192 fits the realistic upper bound for
a high-fan-out client (a few thousand subs on one WS) and caps
per-session memory at ~2 MiB before we drop.
Numbers also confirm:
- The SQLite single-writer plateau is around 2000 EPS on this
box. Production behind WAL + a faster disk should beat that.
- Each WebSocket consumes 2 file descriptors. Operators must
raise `ulimit -n` to ~3× their target connection count.
- Fan-out scales sub-linearly (2000 subs ≈ 2× the latency of
100 subs) — the bottleneck is shared (broadcast + SQLite
write), not per-sub.
Total :quartz-relay tests: 99 (4 new benchmarks, opt-in), 0 failures.
|
||
|
|
b7ba3f6d15 |
fix(relay): security + concurrency audit + on-disk persistence
Addresses every Critical/High/Medium finding from the code-quality audit, plus the operator request to persist NIP-86 admin state and NIP-11 doc mutations across restarts. ## Security (audit Critical) - C1 — NIP-98 verifier no longer trusts the client-supplied `Host` header. New `LocalRelayServer.publicUrl` (and `[admin].public_url`) must be set in any production deployment; the verifier compares against this fixed string. Falls back to Host for loopback unit tests with a docstring warning. - C2 — NIP-98 replay protection. Verifier now keeps a bounded LinkedHashMap of recently-accepted event ids (TTL = 2× tolerance, max 1024 entries, LRU evicted) and rejects duplicates. Replay check runs LAST so a one-shot id isn't burned on a request that fails signature/url/method validation. - C3 — NIP-86 admin POST body is now capped at 1 MiB (configurable via `LocalRelayServer.maxAdminBodyBytes`). The `Content-Length` header is honored as a fast pre-read reject; any body that exceeds the cap during streaming returns 413 PayloadTooLarge before being buffered. ## Concurrency / correctness (audit High) - H1 — Per-session writer coroutine + bounded outbound queue (SESSION_OUTGOING_BUFFER = 1024). When the queue fills, the connection is closed cleanly instead of silently `trySend`-ing into a void; subscribers will never silently miss EVENT/EOSE again. - H3 — `BanStore` kind ops serialize through a new `kindLock` so concurrent admin RPCs and policy reads see consistent state. `allowKind` and `disallowKind` are now symmetric: each removes the kind from the opposite set, so `allowKind(K)` after `disallowKind(K)` actually re-allows K. - H4 — `InProcessWebSocket` reconnect after disconnect is now supported. Each `connect()` allocates a fresh CoroutineScope + drainer channel, so a prior `disconnect()` (which cancelled the previous scope) doesn't leave the new connect with a dead drainer. - H5 — `Nip86Server.dispatch` re-throws `CancellationException` through the runCatching's getOrElse so structured concurrency works (cancellation no longer reported as an RPC error). - M1 — `LiveEventStore.query` dedupes events between the historical replay and the live SharedFlow during the in-flight overlap window. Events seen during `store.query` are tracked in a transient set and filtered out of the live stream until EOSE; the set is dropped after EOSE so live-only events don't accumulate memory. ## Operator-facing (audit Medium / Low) - L4 — Audit-trail log: every admin RPC writes a single structured line to stderr (pubkey + method + ok/error). - L3 — RelayConfig.resolveInfo() default supported_nips list now matches RelayInfo.default() (both advertise NIP-86). - M2 — Hex-id and pubkey params validated as 64-char hex on ban/allow/list methods; malformed input returns "invalid params" instead of being silently stored. - M4 — argparse supports `--key=value` form in addition to `--key value`. - M5 — `RelayHub.close()` is idempotent and rejects subsequent `getOrCreate` calls so a relay created mid-shutdown can't leak its store. - M7 — `LocalRelayServer.stop()` sets a `shuttingDown` flag *before* notifying clients; new WebSocket upgrades during the grace window are rejected so they don't miss the NOTICE. - L5 — Shutdown hook runCatching-wraps both `server.stop()` and `relay.close()` so a throw in one doesn't skip the other. - `--verify` was renamed to `--no-verify` semantically (verify is the default). The `--verify` flag is no longer documented. ## On-disk persistence (operator request) New `RelayStateStore` writes a single JSON sidecar file holding the live NIP-11 doc + all NIP-86 ban / allow / kind lists. Atomic write via temp + ATOMIC_MOVE rename so a crash mid-save can never leave the file half-written. - `BanStore` now takes an optional `onMutation: () -> Unit` callback; every mutation fires it. `Relay` wires it to `snapshot()` which captures BanStore + info into a `RelayPersistedState` and calls `RelayStateStore.save`. - `Relay` accepts a new `stateFile: File?` constructor arg. At construction it loads the snapshot via `RelayStateStore.load` and seeds the in-memory state — using a private `seedFromSnapshot` bulk-load that bypasses the mutation callback so we don't write back what we just read. - New config field `[admin].state_file = "/var/lib/quartz-relay/events.db.admin.json"`. Convention: place next to the SQLite event-store file. - Corrupt state files are tolerated: log to stderr and start fresh (refuse to silently overwrite operator data). ## Tests 8 new persistence tests: cold-boot writes nothing, ban/allow/info round-trip across restart, kind allow/deny round-trip, corrupt-file recovery, atomic write (no leftover .tmp), in-memory mode when no state file is configured, full RelayStateStore round-trip across all sections. Total :quartz-relay tests: 95, 0 failures. |
||
|
|
7c7908d373 |
feat(relay): NIP-86 relay management API
Implements the operator-facing JSON-RPC admin protocol on the relay
side, layered on top of the existing NIP-98 HTTP-Auth + NIP-86 wire
types in :quartz. Same path as the NIP-01 WebSocket and NIP-11 GET —
HTTP POST with Content-Type application/nostr+json+rpc, gated by a
NIP-98 Authorization header signed by an operator-listed pubkey.
Architecture:
- BanStore — concurrent in-memory state for runtime
pubkey/event/kind ban + allow lists.
- DynamicBanPolicy — IRelayPolicy that consults BanStore on every
EVENT; auto-prepended to every Relay's policy
stack so admin actions take effect without a
restart.
- Nip98AuthVerifier — parses `Authorization: Nostr <base64-event>`,
verifies kind 27235 + Schnorr sig + ±60 s
clock skew + method/url/payload-hash match.
- Nip86Server — JSON-RPC dispatcher. Mutates BanStore for
ban/allow/list methods, mutates the live
RelayInfo doc for changerelayname/desc/icon
(Relay.info is now @Volatile var, mutation
through Relay.updateInfo).
- LocalRelayServer — adds POST handler at the relay path:
403 if no admin pubkeys configured,
401 if NIP-98 missing/invalid,
403 if signer's pubkey isn't on the admin list,
200 with Nip86Response otherwise.
- RelayConfig.AdminSection — `[admin].pubkeys = [...]` config key.
- RelayInfo.default() now advertises NIP-86 alongside 1/9/11/40/42/45/50/62.
Methods implemented:
supportedmethods, banpubkey, unbanpubkey, listbannedpubkeys,
allowpubkey, unallowpubkey, listallowedpubkeys,
banevent (also deletes from store), allowevent, listbannedevents,
allowkind, disallowkind, listallowedkinds,
changerelayname, changerelaydescription, changerelayicon.
Tests (28 new):
- BanStoreTest (6) — ban/allow round-trips, case-insensitive
pubkey match, kind allow/deny precedence,
audit-trail listing.
- Nip86ServerTest (8) — every method's accept/reject path.
- Nip98AuthVerifierTest (8) — happy path, missing/wrong scheme,
url/method/payload-hash mismatch,
stale created_at, wrong event kind.
- Nip86EndToEndTest (6) — real HTTP POST through LocalRelayServer:
supportedmethods returns 200,
outsider returns 403, no auth header
returns 401 + WWW-Authenticate, banpubkey
blocks subsequent EVENT publish over WS,
changerelayname flows to NIP-11 GET,
admin endpoint is disabled when no
pubkeys configured.
Total :quartz-relay tests: 87, 0 failures.
|
||
|
|
e214143def |
feat(relay): TOML config file (--config /path/to/relay.toml)
Adds operator-facing TOML configuration to :quartz-relay, with the
section layout deliberately mirroring nostr-rs-relay's config.toml so
existing operators can port across with little churn.
Sections parsed AND enforced today:
[info] — NIP-11 doc fields (name, description, contact, pubkey,
software, supported_nips, …); replaces the previous
hardcoded RelayInfo.default()
[network] — host, port, path
[database] — in_memory toggle + file path
[options] — verify_signatures, require_auth (compose to the right
IRelayPolicy stack)
Sections parsed today but NOT YET ENFORCED (forward-compat for the
upcoming rate-limit / authorization work — relay logs a warning when
they're set):
[limits] — max_event_bytes, messages_per_sec, …
[authorization] — pubkey_whitelist/blacklist, kind_whitelist/blacklist
[options].reject_future_seconds
[network].remote_ip_header
CLI flag precedence over the config file is preserved: --host, --port,
--path, --info, --db, --auth, --verify all override the matching field.
Adds:
- cc.ekblad:4koma 1.2.0 for TOML parsing
- quartz-relay/config.example.toml as the canonical operator reference
- 5 unit tests (defaults, full parse, NIP-11 mapping, bundled example
file, optional sections)
|
||
|
|
eb80dbcd15 |
feat(quartz-relay): rename + promote to a real Nostr relay
Renames :quartz-test-relay to :quartz-relay and promotes it from a test-only fixture to a real, runnable Nostr relay that just happens to also be used in tests. Two transports now share the same `Relay` core: - `RelayHub` + `InProcessWebSocket` — no socket, fastest path; ideal for unit tests inside one JVM. - `LocalRelayServer` — Ktor `embeddedServer` (CIO engine) listening on a real `ws://` port. Use for `cli` interop tests, Android instrumented tests, or running the relay standalone. NIPs implemented (all driven through production `NostrClient` in the new `LocalRelayServerTest`): - NIP-01 wire protocol (REQ/EVENT/EOSE/CLOSE) over real WebSockets - NIP-09 deletion (via existing `DeletionRequestModule`) - NIP-11 relay info doc — `RelayInfo` wraps the existing `Nip11RelayInformation` model and is served on HTTP GET when `Accept: application/nostr+json` is requested. Loadable from a JSON config file via `RelayInfo.fromFile(...)`. - NIP-40 expiration (existing `ExpirationModule`) - NIP-42 AUTH (existing `FullAuthPolicy`, opted-in via the `--auth` flag in the standalone runner) - NIP-45 COUNT - NIP-50 search via the existing FTS index - NIP-62 right-to-vanish (existing module) Adds a standalone runner: `./gradlew :quartz-relay:run --args="--port 7447 --verify"` binds the relay to a real port. Flags: --host, --port, --path, --info <file>, --db <file>, --auth, --verify. Test-only event generators moved to a clearly-named `com.vitorpamplona.quartz.relay.fixtures` package so they're still shareable with consumer tests without polluting the production API. Adds `LocalRelayServerTest` covering NIP-01/11/42/45/50 over a real loopback WebSocket. Existing 11-test `Nip01ComplianceTest` (in-process transport) continues to pass. |