Commit Graph

5 Commits

Author SHA1 Message Date
Claude 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.
2026-05-07 03:41:49 +00:00
Claude 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.
2026-05-07 03:22:56 +00:00
Claude 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.
2026-05-07 02:55:47 +00:00
Claude 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)
2026-05-07 01:21:58 +00:00
Claude 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.
2026-05-07 00:56:57 +00:00