Files
amethyst/quartz-relay/config.example.toml
T
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

83 lines
3.1 KiB
TOML

# Example config for quartz-relay. Section layout mirrors
# nostr-rs-relay's config.toml so existing operators can port across.
#
# Run with:
# ./gradlew :quartz-relay:run --args="--config /etc/quartz-relay.toml"
#
# CLI flags override individual values: e.g. `--port 8888` wins over
# `[network].port`.
[info]
# The wss:// URL clients use to reach this relay (mandatory for NIP-42
# AUTH challenges). If not set, the relay synthesises one from the
# [network] section.
relay_url = "wss://relay.example.com/"
name = "Example Quartz Relay"
description = "A quartz-relay deployment."
contact = "admin@example.com"
# Operator pubkey (NIP-11). Optional.
# pubkey = "..."
# Override the supported NIPs advertised on the NIP-11 endpoint. If
# omitted, the relay advertises the NIPs it actually implements.
# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62]
[network]
host = "0.0.0.0"
port = 7447
path = "/"
[database]
# True keeps an in-memory SQLite db (events vanish on restart). Useful
# for tests; set false + `file = "..."` for persistent storage.
in_memory = false
file = "/var/lib/quartz-relay/events.db"
[options]
# Drop events whose Schnorr signature does not verify. Strongly
# recommended for any relay accepting traffic from real clients.
# Verify Schnorr signatures on every EVENT. Default: true. Disable
# only for trusted-input scenarios (test fixtures, mirror replays).
# verify_signatures = true
# Require clients to NIP-42 AUTH before REQ/EVENT/COUNT.
require_auth = false
# Reject events whose `created_at` is more than this many seconds in
# the future. Enforced by RejectFutureEventsPolicy.
# reject_future_seconds = 1800
[limits]
# Maximum WebSocket frame size. Frames larger than this are dropped at
# the WS layer. (max_ws_message_bytes maps to the same setting since
# Ktor's WebSockets plugin only exposes per-frame caps.)
# max_ws_message_bytes = 1048576
# max_ws_frame_bytes = 1048576
[authorization]
# Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
# pubkey_whitelist = ["abcdef...64hex..."]
# pubkey_blacklist = []
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
# kind_blacklist = [4]
[admin]
# NIP-86 relay management API. When `pubkeys` is non-empty, the relay
# accepts HTTP POST application/nostr+json+rpc on the same URL,
# authenticated with NIP-98 HTTP-Auth. Only events signed by one of
# the listed pubkeys can run admin RPCs (banpubkey / banevent /
# changerelayname / …). Empty (the default) disables the endpoint.
# pubkeys = ["abcdef...64hex..."]
#
# Canonical URL the relay is reachable at, e.g. behind a reverse proxy.
# NIP-98 binds requests to this URL via the `u` tag. **Required** in
# any production deployment — without it, an attacker can spoof the
# Host header to bypass URL binding.
# public_url = "https://relay.example.com/"
# Path for the JSON snapshot that persists NIP-86 admin state (ban
# lists + the live NIP-11 doc) across restarts. When unset, admin
# state is in-memory only and forgotten on every restart. Convention
# is to place this next to the SQLite event-store file.
# state_file = "/var/lib/quartz-relay/events.db.admin.json"