diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index ea89b437c..600aa45fe 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -104,7 +104,7 @@ fun main(args: Array) { val info = cliInfoFile?.let { RelayInfo.fromFile(it) } - ?: config.resolveInfo(advertisedUrl) + ?: config.resolveInfo() val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt index c988d3cc7..cbe9a60e9 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.geode -import com.vitorpamplona.geode.config.BannedEntry import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfigData +import com.vitorpamplona.geode.config.seedInto +import com.vitorpamplona.geode.config.snapshotOf import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy @@ -103,67 +104,40 @@ class RelayEngine( */ adminPubkeys: Set = emptySet(), ) : AutoCloseable { - /** - * The runtime state to install at boot: the persisted snapshot if - * the file exists, otherwise the seed baked into [runtimeConfig]. - * Read once here so the `info` property initializer and the - * [banStore] seeding in [init] agree on the same source. - */ - private val effectiveAtBoot: RuntimeConfigData = runtimeConfig.effective() + private val boot: RuntimeConfigData = runtimeConfig.effective() /** - * NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`, - * `changerelaydescription`, `changerelayicon`) can swap the doc - * atomically. Readers (the NIP-11 GET endpoint) re-read on every - * request so changes are visible immediately, no restart needed. - * - * Initialized from [effectiveAtBoot] — a persisted admin override - * wins, otherwise we fall back to the seed baked into - * [runtimeConfig]. The `!!` is load-bearing: the seed is always - * built with a non-null info, so the only way we reach a null - * here is a manually corrupted state file, which we'd rather - * crash on than serve empty NIP-11 from. + * Live NIP-11 doc. Mutable via [updateInfo] so NIP-86 admin RPCs + * can swap it atomically; readers (NIP-11 GET) re-read every + * request so changes are visible immediately. The `!!` is load- + * bearing: the seed always has a non-null info, so a null here + * means a manually corrupted state file — fail loud over serving + * empty NIP-11. */ @Volatile - var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!) + var info: RelayInfo = RelayInfo(boot.info!!) private set - /** Mutates the live NIP-11 doc. Called by [Nip86Server]. */ + /** Mutates the live NIP-11 doc and persists the snapshot. */ fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { info = RelayInfo(transform(info.document)) snapshot() } /** - * Runtime-mutable ban / allow lists. NIP-86 RPC handlers in - * [Nip86Server] mutate this; the policy stack consults it on - * every accept call via [BanListPolicy]. + * Runtime-mutable ban / allow lists. Mutated by [Nip86Server] via + * NIP-86 RPCs; consulted on every EVENT by [BanListPolicy]. Seeded + * at boot from the persisted snapshot (or the static [runtimeConfig] + * seed on first boot) without firing the mutation hook. */ - val banStore: BanStore = BanStore(onMutation = { snapshot() }) - - init { - // Seed the in-memory ban state from [effectiveAtBoot] *without* - // firing [snapshot] on every entry — the snapshot is exactly - // what we just loaded (or, on first boot, the static seed we - // just synthesized). - banStore.seedFromSnapshot( - bannedPubkeys = effectiveAtBoot.bannedPubkeys.map { it.key to it.reason }, - allowedPubkeys = effectiveAtBoot.allowedPubkeys.map { it.key to it.reason }, - bannedEvents = effectiveAtBoot.bannedEvents.map { it.key to it.reason }, - allowedKinds = effectiveAtBoot.allowedKinds, - disallowedKinds = effectiveAtBoot.disallowedKinds, - ) - } + val banStore: BanStore = + BanStore(onMutation = ::snapshot) + .apply { boot.seedInto(this) } /** - * NIP-86 admin RPC dispatcher. Owns the ban / allow / kind list - * mutations, the NIP-11 `changerelay*` mutations (via an - * [Nip86Server.InfoHolder] adapter that wraps [info] / [updateInfo]), - * the event-store purge on each ban method (via `store.delete`), - * and the admin allow-list ([adminPubkeys]). Transport-agnostic: - * `KtorRelay` wraps it with `Nip86HttpHandler` for HTTP/NIP-98; - * an in-process tool could call `dispatch(adminPubkey, req)` - * directly. + * NIP-86 admin RPC dispatcher. Transport-agnostic — `KtorRelay` + * wraps it with `Nip86HttpHandler` for HTTP/NIP-98; an in-process + * tool could call `dispatch(adminPubkey, req)` directly. */ val nip86Server: Nip86Server = Nip86Server( @@ -179,25 +153,14 @@ class RelayEngine( ) /** - * Writes the current state (NIP-11 doc + ban lists) to disk. - * No-op when no `stateFile` was configured (see [RuntimeConfig.save]). - * - * Best-effort: any I/O failure is logged to stderr and swallowed - * so an unwritable disk doesn't take the relay down. Operators - * monitor for missing snapshots out-of-band. + * Writes the current state (NIP-11 doc + ban lists) to disk via + * [RuntimeConfig.save] — no-op when no state file is configured. + * Best-effort: I/O failures are logged to stderr and swallowed so + * an unwritable disk doesn't take the relay down. */ fun snapshot() { runCatching { - runtimeConfig.save( - RuntimeConfigData( - info = info.document, - bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) }, - allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) }, - bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) }, - allowedKinds = banStore.listAllowedKinds(), - disallowedKinds = banStore.listDisallowedKinds(), - ), - ) + runtimeConfig.save(snapshotOf(info.document, banStore)) }.onFailure { System.err.println("warning: failed to write relay state file: ${it.message}") } @@ -206,10 +169,9 @@ class RelayEngine( val server = NostrServer( store, - // Always prepend a BanListPolicy so NIP-86 admin actions - // bite. When the operator-supplied builder returns - // [EmptyPolicy] we use the dynamic policy alone; otherwise - // we stack them so both layers must accept. + // Always prepend BanListPolicy so NIP-86 admin actions bite. + // EmptyPolicy from the caller means "no extra policies" — use + // BanListPolicy alone; otherwise stack so both must accept. policyBuilder = { val user = policyBuilder() if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt index 20e4187c5..5e0d53242 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.geode.config import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.File @@ -139,3 +140,28 @@ data class BannedEntry( val key: String, val reason: String? = null, ) + +/** Bulk-load this snapshot into [banStore] without firing its `onMutation` hook. */ +fun RuntimeConfigData.seedInto(banStore: BanStore) { + banStore.seedFromSnapshot( + bannedPubkeys = bannedPubkeys.map { it.key to it.reason }, + allowedPubkeys = allowedPubkeys.map { it.key to it.reason }, + bannedEvents = bannedEvents.map { it.key to it.reason }, + allowedKinds = allowedKinds, + disallowedKinds = disallowedKinds, + ) +} + +/** Capture the current state of [info] + [banStore] as a fresh snapshot. */ +fun snapshotOf( + info: Nip11RelayInformation, + banStore: BanStore, +): RuntimeConfigData = + RuntimeConfigData( + info = info, + bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) }, + allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) }, + bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) }, + allowedKinds = banStore.listAllowedKinds(), + disallowedKinds = banStore.listDisallowedKinds(), + ) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 7b8f2a549..06f912031 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -34,10 +34,8 @@ import java.io.File * needs to change while the relay is running lives in [RuntimeConfig]. * * Section layout matches nostr-rs-relay's `config.toml` so existing - * configs can be ported with little churn. - * - * Every section is optional; values not set fall back to sensible - * defaults (or, for fields also exposed on the CLI, the CLI value wins). + * configs can be ported with little churn. Every section is optional; + * CLI flags override file values where both exist. */ data class StaticConfig( val info: InfoSection = InfoSection(), @@ -48,11 +46,7 @@ data class StaticConfig( val admin: AdminSection = AdminSection(), val negentropy: NegentropySection = NegentropySection(), ) { - /** - * Maps the `[info]` section into a [RelayInfo] used by the NIP-11 - * endpoint. `relay_url` and CLI overrides take precedence. - */ - fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo = + fun resolveInfo(): RelayInfo = RelayInfo( Nip11RelayInformation( name = info.name ?: RelayInfo.NAME, @@ -71,12 +65,7 @@ data class StaticConfig( language_tags = info.language_tags, tags = info.tags, ), - ).also { - // Touch [advertisedUrl] so the parameter isn't unused — we keep - // it in the signature because future fields (e.g. self-pubkey - // selection, fee URLs) will want it. - advertisedUrl.url - } + ) data class InfoSection( val relay_url: String? = null, @@ -87,7 +76,6 @@ data class StaticConfig( val icon: String? = null, val software: String? = null, val version: String? = null, - /** NIP numbers as ints (e.g. `[1, 9, 11]`). Stringified at render time. */ val supported_nips: List? = null, val privacy_policy: String? = null, val terms_of_service: String? = null, @@ -96,91 +84,50 @@ data class StaticConfig( val tags: List? = null, ) + /** + * Bind config + Ktor CIO thread-pool sizes. The three `*_size` + * fields default to Ktor's per-CPU sizing; lift them on big-VM + * deployments targeting 10k+ concurrent connections. + */ data class NetworkSection( val host: String = "0.0.0.0", val port: Int = 7447, val path: String = "/", - /** - * Ktor CIO acceptor-thread count. `null` (default) keeps Ktor's - * default sizing — fine up to a few thousand concurrent - * connections. On big-VM deployments targeting 10k+ - * connections, lift this to roughly half the available cores - * so the acceptor doesn't starve workers. - */ val connection_group_size: Int? = null, - /** - * Ktor CIO worker-thread count (handles socket I/O). `null` - * keeps Ktor's default. Each connection's WebSocket read/write - * is dispatched onto this pool; for many idle long-lived - * connections the pool can stay small, but 10k+ connections - * benefit from sizing this to the full CPU count. - */ val worker_group_size: Int? = null, - /** - * Ktor CIO call-handling thread count. `null` keeps Ktor's - * default. Sized higher than [worker_group_size] because each - * call (incl. WebSocket upgrade) may suspend on I/O — at - * 10k+ connections, ~4× cores is a reasonable starting point. - */ val call_group_size: Int? = null, ) data class DatabaseSection( /** True keeps an in-memory SQLite db (default — events vanish on restart). */ val in_memory: Boolean = true, - /** Filesystem path for a persistent SQLite db. Ignored when [in_memory] is true. */ val file: String? = null, ) data class OptionsSection( - /** Reject events whose `created_at` is more than this many seconds in the future. */ val reject_future_seconds: Int? = null, - /** Require NIP-42 AUTH for REQ/EVENT/COUNT. */ val require_auth: Boolean = false, /** - * Drop events whose Schnorr signature does not verify. **Defaults - * to `true`**: any relay accepting traffic from real clients - * should verify signatures, and verifying-by-default closes the - * footgun of forgetting the flag. Set explicitly to `false` only - * for trusted-input scenarios (test fixtures, mirror replays). + * Defaults to `true`: any relay accepting real traffic should + * verify Schnorr signatures, and verifying-by-default closes + * the footgun of forgetting the flag. Set false only for + * trusted-input scenarios (test fixtures, mirror replays). */ val verify_signatures: Boolean = true, - /** - * Run signature verification in parallel inside the IngestQueue - * (CPU fan-out across `Dispatchers.Default`) instead of serially - * on each connection's WebSocket pump. Tier-3 of the - * `event-ingestion-batching` plan. Wins scale with how many - * EVENTs a single connection sends back-to-back: ~CPU_COUNT× - * verify-step speed-up on burst publishes. Set false to keep - * the legacy in-policy verify path. - * - * Only takes effect when [verify_signatures] is also true. - */ + /** CPU fan-out verification in the IngestQueue. No-op when [verify_signatures] is false. */ val parallel_verify: Boolean = true, ) /** - * NIP-77 negentropy tuning. Defaults track strfry - * (`hoytech/strfry`) so a Geode relay accepts the same workload - * shape and exchanges the same NEG-MSG round-trip size as - * strfry — the de-facto reference implementation. + * NIP-77 negentropy tuning. Defaults track strfry (`hoytech/strfry`) + * so a Geode relay accepts the same workload shape and exchanges + * the same NEG-MSG round-trip size: * - * - [frame_size_limit] mirrors strfry's hard-coded - * `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`. - * Hex-encoded that's ~1 MB on the wire per NEG-MSG. Ktor's - * WebSocket layer does not impose a default frame cap, so this - * payload is delivered intact unless a reverse proxy is - * misconfigured. - * - [max_sync_events] mirrors strfry's - * `relay__negentropy__maxSyncEvents`. NEG-OPEN whose snapshot - * exceeds this returns - * `["NEG-ERR", "", "blocked: too many query results"]`. - * - [max_sessions_per_connection] caps concurrent NEG-OPEN - * sessions held by a single connection. strfry shares its - * 200-cap with REQ subs via `relay__maxSubsPerConnection`; - * Geode counts NEG independently for now (REQ has no cap yet). - * Overflow returns NOTICE - * `"too many concurrent NEG requests"` (matches strfry). + * - [frame_size_limit]: strfry's hard-coded `Negentropy ne(storage, 500'000)`. + * - [max_sync_events]: strfry's `relay__negentropy__maxSyncEvents`; + * NEG-OPEN over this returns `["NEG-ERR", "", "blocked: too many query results"]`. + * - [max_sessions_per_connection]: NEG sessions held by one connection; + * overflow returns NOTICE `"too many concurrent NEG requests"`. */ data class NegentropySection( val frame_size_limit: Long = 500_000L, @@ -196,30 +143,19 @@ data class StaticConfig( ) /** - * NIP-86 relay management API. When [pubkeys] is non-empty, - * `KtorRelay` exposes a POST endpoint at the relay path - * that accepts JSON-RPC admin requests authenticated via NIP-98 - * HTTP-Auth. Only requests signed by one of these pubkeys are - * dispatched. - * - * The NIP-98 URL binding compares the signed `u` tag against the - * `http(s)://` equivalent of the relay's WebSocket URL — i.e. - * `[info].relay_url` with the scheme swapped (per NIP-86's - * "same URI as `ws(s)://`, called via `http(s)://`"). Set - * `[info].relay_url` to the canonical public URL when running - * behind TLS termination or a reverse proxy. + * NIP-86 admin. [pubkeys] non-empty opens the POST endpoint at the + * relay path; only NIP-98 tokens signed by these pubkeys dispatch. + * The NIP-98 URL binding uses `[info].relay_url` with the scheme + * swapped (`ws(s)://` → `http(s)://`) per NIP-86 — set + * `[info].relay_url` to the canonical public URL when behind TLS + * termination or a reverse proxy. */ data class AdminSection( val pubkeys: List = emptyList(), /** - * Path for the JSON snapshot that backs [RuntimeConfig] — - * NIP-86 admin state (ban lists + the live NIP-11 doc) that - * survives restarts. When unset, runtime config is in-memory - * only. - * - * Convention: place next to the SQLite event-store file — - * e.g. `[database].file = "/var/lib/geode/events.db"` - * pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`. + * JSON snapshot path for [RuntimeConfig] (ban lists + live + * NIP-11 doc). Convention: place next to the SQLite event file, + * e.g. `events.db` ↔ `events.db.admin.json`. */ val state_file: String? = null, ) @@ -227,24 +163,16 @@ data class StaticConfig( companion object { private val mapper = tomlMapper { } - /** Parse a TOML string. */ fun fromToml(toml: String): StaticConfig = mapper.decode(toml) - /** Load a TOML config file. */ fun fromFile(file: File): StaticConfig = mapper.decode(file.toPath()) /** - * Returns the URL the relay advertises in NIP-11 and NIP-42 - * challenges. Picks (in order): - * 1. `info.relay_url` from the config - * 2. The `network` section's host/port/path (with 0.0.0.0 → 127.0.0.1) - * 3. The CLI override (handled in `Main.kt`). + * URL the relay advertises in NIP-11 and NIP-42 challenges: + * `info.relay_url` if set, else built from `network.host:port/path` + * with `0.0.0.0` rewritten to `127.0.0.1`. */ - fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl = - ( - config.info.relay_url - ?: defaultUrl(config.network) - ).normalizeRelayUrl() + fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl = (config.info.relay_url ?: defaultUrl(config.network)).normalizeRelayUrl() private fun defaultUrl(net: NetworkSection): String { val host = if (net.host == "0.0.0.0") "127.0.0.1" else net.host diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index 68b2b697c..e21469938 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -105,13 +105,7 @@ class StaticConfigTest { supported_nips = [1, 11, 42] """.trimIndent(), ) - val info = - c.resolveInfo( - "ws://127.0.0.1:7447/".let { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer - .normalize(it) - }, - ) + val info = c.resolveInfo() assertEquals(listOf("1", "11", "42"), info.document.supported_nips) }