refactor(geode): split config into StaticConfig + RuntimeConfig with seeding
Rename RelayConfig → StaticConfig and the persistence/RelayStateStore → config/RuntimeConfig so the two configuration tiers — boot-time TOML versus operator-mutable runtime state — are obvious from the class names. The `persistence/` package is gone; everything related to config lives in `config/`. For overlapping fields (NIP-11 info doc, pubkey / kind allow-deny lists), StaticConfig now seeds RuntimeConfig on first boot via the new RuntimeConfig(seed = …) constructor and an AuthorizationSeed type. The runtime file wins from then on: later edits to [authorization] in the TOML are ignored once an admin RPC has written the snapshot. As a consequence, KindAllowDenyPolicy and PubkeyAllowDenyPolicy are no longer stacked in geode's policy chain — BanListPolicy already reads the same lists from the BanStore, and double-stacking would silently diverge after the first admin mutation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,13 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.config.RelayConfig
|
||||
import com.vitorpamplona.geode.config.AuthorizationSeed
|
||||
import com.vitorpamplona.geode.config.StaticConfig
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
|
||||
@@ -50,8 +49,10 @@ import java.io.File
|
||||
*
|
||||
* Every section is enforced: `[info]` populates the NIP-11 doc,
|
||||
* `[network]` controls the bind, `[database]` chooses the SQLite path,
|
||||
* `[options]` toggles AUTH/verify/future-skew, `[limits]` and
|
||||
* `[authorization]` plug into the relay's policy stack.
|
||||
* `[options]` toggles AUTH/verify/future-skew, `[limits]` plugs into
|
||||
* the relay's policy stack, and `[authorization]` seeds the runtime
|
||||
* [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore] on
|
||||
* first boot (see [com.vitorpamplona.geode.config.RuntimeConfig]).
|
||||
*
|
||||
* CLI flags:
|
||||
* --config <file> TOML config (see config.example.toml)
|
||||
@@ -68,11 +69,11 @@ import java.io.File
|
||||
fun main(args: Array<String>) {
|
||||
val a = parseArgs(args)
|
||||
|
||||
val config: RelayConfig =
|
||||
val config: StaticConfig =
|
||||
a
|
||||
.opt("--config")
|
||||
?.let { RelayConfig.fromFile(File(it)) }
|
||||
?: RelayConfig()
|
||||
?.let { StaticConfig.fromFile(File(it)) }
|
||||
?: StaticConfig()
|
||||
|
||||
val host = a.opt("--host") ?: config.network.host
|
||||
val port = a.opt("--port")?.toInt() ?: config.network.port
|
||||
@@ -110,6 +111,18 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
|
||||
val stateFile = config.admin.state_file?.let { File(it) }
|
||||
// Static [authorization] feeds the runtime BanStore only when no
|
||||
// state file exists yet. As soon as the operator's first admin RPC
|
||||
// writes the file, BanListPolicy consults the persisted lists
|
||||
// exclusively — later edits to [authorization] in the TOML are
|
||||
// ignored at boot.
|
||||
val seedAuthorization =
|
||||
AuthorizationSeed(
|
||||
allowedPubkeys = config.authorization.pubkey_whitelist,
|
||||
bannedPubkeys = config.authorization.pubkey_blacklist,
|
||||
allowedKinds = config.authorization.kind_whitelist,
|
||||
disallowedKinds = config.authorization.kind_blacklist,
|
||||
)
|
||||
val negentropySettings =
|
||||
NegentropySettings(
|
||||
frameSizeLimit = config.negentropy.frame_size_limit,
|
||||
@@ -123,6 +136,7 @@ fun main(args: Array<String>) {
|
||||
info,
|
||||
policyBuilder,
|
||||
stateFile = stateFile,
|
||||
seedAuthorization = seedAuthorization,
|
||||
parallelVerify = parallelVerify,
|
||||
negentropySettings = negentropySettings,
|
||||
)
|
||||
@@ -166,11 +180,18 @@ fun main(args: Array<String>) {
|
||||
*
|
||||
* Order matters — cheap rejection paths run before expensive ones:
|
||||
* 1. AUTH (drops everything if not authenticated)
|
||||
* 2. Future-timestamp + allow/deny lists
|
||||
* 2. Future-timestamp rejection
|
||||
* 3. Signature verification (most expensive — Schnorr verify)
|
||||
*
|
||||
* Pubkey and kind allow / deny lists are NOT installed here — they
|
||||
* live in the runtime [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore],
|
||||
* which `RelayEngine` always wraps with `BanListPolicy`. The static
|
||||
* `[authorization]` section seeds the BanStore on first boot via
|
||||
* [com.vitorpamplona.geode.config.AuthorizationSeed]; afterwards
|
||||
* NIP-86 admin RPCs own those lists.
|
||||
*/
|
||||
private fun composePolicy(
|
||||
config: RelayConfig,
|
||||
config: StaticConfig,
|
||||
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
requireAuth: Boolean,
|
||||
verifySigs: Boolean,
|
||||
@@ -186,14 +207,6 @@ private fun composePolicy(
|
||||
pieces += RejectFutureEventsPolicy(secs)
|
||||
}
|
||||
|
||||
val auth = config.authorization
|
||||
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
|
||||
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
|
||||
}
|
||||
if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) {
|
||||
pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet())
|
||||
}
|
||||
|
||||
if (verifySigs) {
|
||||
// When parallel verify is on, the IngestQueue handles EVENT
|
||||
// verification on the writer's CPU fan-out — but AUTH events
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.persistence.BannedEntry
|
||||
import com.vitorpamplona.geode.persistence.RelayPersistedState
|
||||
import com.vitorpamplona.geode.persistence.RelayStateStore
|
||||
import com.vitorpamplona.geode.config.AuthorizationSeed
|
||||
import com.vitorpamplona.geode.config.BannedEntry
|
||||
import com.vitorpamplona.geode.config.RuntimeConfig
|
||||
import com.vitorpamplona.geode.config.RuntimeConfigData
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
|
||||
@@ -71,6 +72,20 @@ class RelayEngine(
|
||||
* everything in memory only — fine for tests.
|
||||
*/
|
||||
stateFile: File? = null,
|
||||
/**
|
||||
* Static-config-derived allow / deny seed for the [BanStore].
|
||||
* Used only on first boot — i.e. when [stateFile] doesn't yet
|
||||
* exist. Once an admin RPC writes the file, the persisted snapshot
|
||||
* is the sole source of truth and this seed is ignored on every
|
||||
* subsequent boot.
|
||||
*
|
||||
* Pair with omitting `KindAllowDenyPolicy` / `PubkeyAllowDenyPolicy`
|
||||
* from [policyBuilder] — [BanListPolicy] (always installed) reads
|
||||
* the same lists from the [BanStore], so stacking the static
|
||||
* policies would double-enforce the same rules at boot and then
|
||||
* silently diverge after the first admin mutation.
|
||||
*/
|
||||
seedAuthorization: AuthorizationSeed = AuthorizationSeed(),
|
||||
/**
|
||||
* Run Schnorr signature verification in parallel inside the
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.server.IngestQueue]
|
||||
@@ -88,7 +103,27 @@ class RelayEngine(
|
||||
*/
|
||||
negentropySettings: NegentropySettings = NegentropySettings.Default,
|
||||
) : AutoCloseable {
|
||||
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
|
||||
private val runtimeConfig: RuntimeConfig =
|
||||
RuntimeConfig(
|
||||
file = stateFile,
|
||||
seed =
|
||||
RuntimeConfigData(
|
||||
info = info.document,
|
||||
allowedPubkeys = seedAuthorization.allowedPubkeys.map { BannedEntry(it) },
|
||||
bannedPubkeys = seedAuthorization.bannedPubkeys.map { BannedEntry(it) },
|
||||
allowedKinds = seedAuthorization.allowedKinds,
|
||||
disallowedKinds = seedAuthorization.disallowedKinds,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* The runtime state to install at boot: the persisted snapshot if
|
||||
* the file exists, otherwise the seed derived from
|
||||
* [com.vitorpamplona.geode.config.StaticConfig]. 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()
|
||||
|
||||
/**
|
||||
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
|
||||
@@ -96,14 +131,15 @@ class RelayEngine(
|
||||
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
|
||||
* request so changes are visible immediately, no restart needed.
|
||||
*
|
||||
* If a [RelayStateStore] is configured and the snapshot exists,
|
||||
* the persisted info doc takes precedence over the constructor
|
||||
* default — operators expect their last `changerelayname` to
|
||||
* survive a restart.
|
||||
* Initialized from [effectiveAtBoot] — a persisted admin override
|
||||
* wins, otherwise we fall back to the static-config seed. The
|
||||
* `!!` is load-bearing: the seed is always built with a non-null
|
||||
* info above, 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.
|
||||
*/
|
||||
@Volatile
|
||||
var info: RelayInfo =
|
||||
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
|
||||
var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!)
|
||||
private set
|
||||
|
||||
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
|
||||
@@ -120,33 +156,31 @@ class RelayEngine(
|
||||
val banStore: BanStore = BanStore(onMutation = { snapshot() })
|
||||
|
||||
init {
|
||||
// Seed the in-memory ban state from disk *without* triggering
|
||||
// [snapshot] on every entry — the snapshot is exactly what we
|
||||
// just loaded.
|
||||
stateStore?.load()?.let { snap ->
|
||||
banStore.seedFromSnapshot(
|
||||
bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason },
|
||||
allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason },
|
||||
bannedEvents = snap.bannedEvents.map { it.key to it.reason },
|
||||
allowedKinds = snap.allowedKinds,
|
||||
disallowedKinds = snap.disallowedKinds,
|
||||
)
|
||||
}
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current state (NIP-11 doc + ban lists) to disk.
|
||||
* No-op when no `stateFile` was configured.
|
||||
* 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.
|
||||
*/
|
||||
fun snapshot() {
|
||||
val s = stateStore ?: return
|
||||
runCatching {
|
||||
s.save(
|
||||
RelayPersistedState(
|
||||
runtimeConfig.save(
|
||||
RuntimeConfigData(
|
||||
info = info.document,
|
||||
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
|
||||
@@ -45,7 +45,7 @@ data class RelayInfo(
|
||||
|
||||
/**
|
||||
* NIPs this relay implements out of the box. Single source of
|
||||
* truth — both [default] and [com.vitorpamplona.geode.config.RelayConfig.resolveInfo]
|
||||
* truth — both [default] and [com.vitorpamplona.geode.config.StaticConfig.resolveInfo]
|
||||
* consult this list. Add a NIP here when its handler is wired
|
||||
* into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]
|
||||
* (or in this module's policy stack).
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.config
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* On-disk handle for the relay's **runtime** configuration — the
|
||||
* operator-mutable bits that [StaticConfig] deliberately can't carry
|
||||
* because they change while the relay is running:
|
||||
*
|
||||
* - the live NIP-11 info doc (so `changerelayname/description/icon`
|
||||
* survive a restart), and
|
||||
* - the NIP-86 ban / allow / kind lists for pubkeys, events, and
|
||||
* kinds (NIP-86 admin RPC mutates these directly).
|
||||
*
|
||||
* One JSON file per relay. Lives next to the SQLite event store by
|
||||
* convention, but the path is configurable independently via
|
||||
* [StaticConfig.AdminSection.state_file]. Atomic write via temp +
|
||||
* atomic rename so a crash mid-save can never leave the file
|
||||
* half-written.
|
||||
*
|
||||
* **Precedence with [StaticConfig].** [StaticConfig] supplies the
|
||||
* [seed] used the first time the relay boots without a persisted
|
||||
* file: the NIP-11 `[info]` section becomes the initial info doc,
|
||||
* and `[authorization]` lists become the initial pubkey / kind
|
||||
* allow / deny lists. As soon as the file exists on disk — written
|
||||
* by the first NIP-86 RPC mutation — the seed is never consulted
|
||||
* again. From then on the file is the only source of truth; later
|
||||
* edits to `[authorization]` in the TOML are ignored at boot.
|
||||
*
|
||||
* Pass `file = null` to keep everything in memory (useful for tests):
|
||||
* [load] then returns `null` and [save] becomes a no-op, so the
|
||||
* relay falls back to the [seed] forever.
|
||||
*
|
||||
* The [RuntimeConfigData] schema intentionally mirrors NIP-86 list
|
||||
* responses (`pubkey + reason`, `id + reason`) so a future
|
||||
* operator-tools CLI can read these straight from disk without
|
||||
* translation.
|
||||
*/
|
||||
class RuntimeConfig(
|
||||
val file: File?,
|
||||
/**
|
||||
* Initial values derived from [StaticConfig] — the baseline the
|
||||
* relay uses on first boot, before any NIP-86 RPC has produced a
|
||||
* file. Once [save] writes the file, [effective] returns the
|
||||
* persisted snapshot instead and this seed is discarded.
|
||||
*/
|
||||
private val seed: RuntimeConfigData,
|
||||
) {
|
||||
/** Load the snapshot from disk, or `null` if no file is configured / yet exists. */
|
||||
@Synchronized
|
||||
fun load(): RuntimeConfigData? {
|
||||
val f = file ?: return null
|
||||
if (!f.exists()) return null
|
||||
return try {
|
||||
json.decodeFromString(RuntimeConfigData.serializer(), f.readText())
|
||||
} catch (e: Exception) {
|
||||
// Corrupt file — log to stderr and refuse to overwrite. The
|
||||
// operator chooses whether to fix or delete; we don't blow
|
||||
// away their state silently.
|
||||
System.err.println("warning: failed to read relay state file ${f.absolutePath}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write the snapshot. No-op when `file == null` — the
|
||||
* caller is running in memory-only mode (tests, ephemeral relays)
|
||||
* and there's nothing to persist.
|
||||
*/
|
||||
@Synchronized
|
||||
fun save(state: RuntimeConfigData) {
|
||||
val f = file ?: return
|
||||
f.parentFile?.let { if (!it.exists()) it.mkdirs() }
|
||||
val tmp = File(f.parentFile ?: f.absoluteFile.parentFile, "${f.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(RuntimeConfigData.serializer(), state))
|
||||
Files.move(
|
||||
tmp.toPath(),
|
||||
f.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The state the relay should serve right now: the persisted
|
||||
* snapshot if the file exists, otherwise the [seed] from
|
||||
* [StaticConfig]. Call this once at boot to initialize in-memory
|
||||
* caches (NIP-11 doc, ban store); subsequent admin RPCs mutate
|
||||
* those caches in place and call [save], they don't re-read from
|
||||
* disk.
|
||||
*/
|
||||
fun effective(): RuntimeConfigData = load() ?: seed
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
prettyPrint = true
|
||||
encodeDefaults = false
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RuntimeConfigData(
|
||||
val info: Nip11RelayInformation? = null,
|
||||
val bannedPubkeys: List<BannedEntry> = emptyList(),
|
||||
val allowedPubkeys: List<BannedEntry> = emptyList(),
|
||||
val bannedEvents: List<BannedEntry> = emptyList(),
|
||||
val allowedKinds: List<Int> = emptyList(),
|
||||
val disallowedKinds: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BannedEntry(
|
||||
val key: String,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Static-config-derived seed for the operator allow / deny lists.
|
||||
* Built from [StaticConfig.AuthorizationSection] in `Main.kt` and
|
||||
* handed to [com.vitorpamplona.geode.RelayEngine], which composes it
|
||||
* with the static-derived NIP-11 doc into a [RuntimeConfigData] seed
|
||||
* for [RuntimeConfig]. Used only when no runtime file exists yet —
|
||||
* once an admin RPC has written one, the file wins forever.
|
||||
*/
|
||||
data class AuthorizationSeed(
|
||||
val allowedPubkeys: List<HexKey> = emptyList(),
|
||||
val bannedPubkeys: List<HexKey> = emptyList(),
|
||||
val allowedKinds: List<Int> = emptyList(),
|
||||
val disallowedKinds: List<Int> = emptyList(),
|
||||
)
|
||||
+14
-9
@@ -29,13 +29,17 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Operator-facing configuration. Section layout matches nostr-rs-relay's
|
||||
* `config.toml` so existing configs can be ported with little churn.
|
||||
* Operator-facing **boot-time** configuration. Parsed once from a TOML
|
||||
* file at startup and treated as immutable thereafter — anything that
|
||||
* 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).
|
||||
*/
|
||||
data class RelayConfig(
|
||||
data class StaticConfig(
|
||||
val info: InfoSection = InfoSection(),
|
||||
val network: NetworkSection = NetworkSection(),
|
||||
val database: DatabaseSection = DatabaseSection(),
|
||||
@@ -213,9 +217,10 @@ data class RelayConfig(
|
||||
val pubkeys: List<String> = emptyList(),
|
||||
val public_url: String? = null,
|
||||
/**
|
||||
* 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.
|
||||
* 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"`
|
||||
@@ -228,10 +233,10 @@ data class RelayConfig(
|
||||
private val mapper = tomlMapper { }
|
||||
|
||||
/** Parse a TOML string. */
|
||||
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
|
||||
fun fromToml(toml: String): StaticConfig = mapper.decode<StaticConfig>(toml)
|
||||
|
||||
/** Load a TOML config file. */
|
||||
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(file.toPath())
|
||||
fun fromFile(file: File): StaticConfig = mapper.decode<StaticConfig>(file.toPath())
|
||||
|
||||
/**
|
||||
* Returns the URL the relay advertises in NIP-11 and NIP-42
|
||||
@@ -240,7 +245,7 @@ data class RelayConfig(
|
||||
* 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`).
|
||||
*/
|
||||
fun advertisedUrl(config: RelayConfig): NormalizedRelayUrl =
|
||||
fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl =
|
||||
(
|
||||
config.info.relay_url
|
||||
?: defaultUrl(config.network)
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.persistence
|
||||
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* On-disk snapshot of the relay's *operator-mutable* state — the
|
||||
* NIP-11 info doc (so `changerelayname/description/icon` survive a
|
||||
* restart) and the NIP-86 ban / allow / kind lists.
|
||||
*
|
||||
* One JSON file per relay. Lives next to the SQLite event store by
|
||||
* convention, but the path is configurable independently. Atomic
|
||||
* write via temp + atomic rename so a crash mid-save can never leave
|
||||
* the file half-written.
|
||||
*
|
||||
* The schema below intentionally mirrors NIP-86 list responses
|
||||
* (`pubkey + reason`, `id + reason`) so a future operator-tools CLI
|
||||
* can read these straight from disk without translation.
|
||||
*/
|
||||
class RelayStateStore(
|
||||
val file: File,
|
||||
) {
|
||||
/** Load the snapshot from disk, or `null` if the file does not yet exist. */
|
||||
@Synchronized
|
||||
fun load(): RelayPersistedState? {
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
json.decodeFromString(RelayPersistedState.serializer(), file.readText())
|
||||
} catch (e: Exception) {
|
||||
// Corrupt file — log to stderr and refuse to overwrite. The
|
||||
// operator chooses whether to fix or delete; we don't blow
|
||||
// away their state silently.
|
||||
System.err.println("warning: failed to read relay state file ${file.absolutePath}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write the snapshot. */
|
||||
@Synchronized
|
||||
fun save(state: RelayPersistedState) {
|
||||
file.parentFile?.let { if (!it.exists()) it.mkdirs() }
|
||||
val tmp = File(file.parentFile ?: file.absoluteFile.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(RelayPersistedState.serializer(), state))
|
||||
Files.move(
|
||||
tmp.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
prettyPrint = true
|
||||
encodeDefaults = false
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RelayPersistedState(
|
||||
val info: Nip11RelayInformation? = null,
|
||||
val bannedPubkeys: List<BannedEntry> = emptyList(),
|
||||
val allowedPubkeys: List<BannedEntry> = emptyList(),
|
||||
val bannedEvents: List<BannedEntry> = emptyList(),
|
||||
val allowedKinds: List<Int> = emptyList(),
|
||||
val disallowedKinds: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BannedEntry(
|
||||
val key: String,
|
||||
val reason: String? = null,
|
||||
)
|
||||
+129
-7
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.persistence
|
||||
package com.vitorpamplona.geode.config
|
||||
|
||||
import com.vitorpamplona.geode.RelayEngine
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -32,7 +32,7 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PersistenceTest {
|
||||
class RuntimeConfigTest {
|
||||
private lateinit var dir: File
|
||||
private lateinit var stateFile: File
|
||||
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
@@ -156,10 +156,10 @@ class PersistenceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateStoreRoundTripsAllSections() {
|
||||
val ss = RelayStateStore(stateFile)
|
||||
fun runtimeConfigRoundTripsAllSections() {
|
||||
val rc = RuntimeConfig(stateFile, seed = RuntimeConfigData(info = Nip11RelayInformation()))
|
||||
val state =
|
||||
RelayPersistedState(
|
||||
RuntimeConfigData(
|
||||
info = Nip11RelayInformation(name = "x", description = "y"),
|
||||
bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)),
|
||||
allowedPubkeys = listOf(BannedEntry("cc", "trusted")),
|
||||
@@ -167,14 +167,136 @@ class PersistenceTest {
|
||||
allowedKinds = listOf(1, 7),
|
||||
disallowedKinds = listOf(4, 1059),
|
||||
)
|
||||
ss.save(state)
|
||||
val loaded = ss.load()!!
|
||||
rc.save(state)
|
||||
val loaded = rc.load()!!
|
||||
assertEquals("x", loaded.info!!.name)
|
||||
assertEquals(2, loaded.bannedPubkeys.size)
|
||||
assertEquals(listOf(1, 7), loaded.allowedKinds)
|
||||
assertEquals(listOf(4, 1059), loaded.disallowedKinds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveFallsBackToStaticSeedWhenNothingPersisted() {
|
||||
// Fresh file → no override → static seed wins.
|
||||
val seed =
|
||||
RuntimeConfigData(
|
||||
info = Nip11RelayInformation(name = "from-toml", description = "seeded"),
|
||||
allowedKinds = listOf(1, 7),
|
||||
bannedPubkeys = listOf(BannedEntry("aa")),
|
||||
)
|
||||
val rc = RuntimeConfig(stateFile, seed = seed)
|
||||
val eff = rc.effective()
|
||||
val info = eff.info!!
|
||||
assertEquals("from-toml", info.name)
|
||||
assertEquals("seeded", info.description)
|
||||
assertEquals(listOf(1, 7), eff.allowedKinds)
|
||||
assertEquals(listOf("aa"), eff.bannedPubkeys.map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectivePrefersPersistedOverrideOverStaticSeed() {
|
||||
val seed =
|
||||
RuntimeConfigData(
|
||||
info = Nip11RelayInformation(name = "from-toml"),
|
||||
allowedKinds = listOf(1, 7),
|
||||
)
|
||||
val rc = RuntimeConfig(stateFile, seed = seed)
|
||||
rc.save(
|
||||
RuntimeConfigData(
|
||||
info = Nip11RelayInformation(name = "admin-renamed"),
|
||||
allowedKinds = listOf(42),
|
||||
),
|
||||
)
|
||||
val eff = rc.effective()
|
||||
assertEquals("admin-renamed", eff.info!!.name)
|
||||
// Static seed is fully ignored — persisted snapshot wins for
|
||||
// every field, not just info.
|
||||
assertEquals(listOf(42), eff.allowedKinds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullFileMakesSaveANoOpAndAlwaysReturnsSeed() {
|
||||
// In-memory mode: even after save(), there's nothing on disk to
|
||||
// load, so the seed continues to win.
|
||||
val seed = RuntimeConfigData(info = Nip11RelayInformation(name = "memory-only"))
|
||||
val rc = RuntimeConfig(file = null, seed = seed)
|
||||
rc.save(RuntimeConfigData(info = Nip11RelayInformation(name = "ignored")))
|
||||
assertNull(rc.load())
|
||||
assertEquals("memory-only", rc.effective().info!!.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorizationSeedFlowsIntoBanStoreOnFirstBootThenFileTakesOver() {
|
||||
val pk = "a".repeat(64)
|
||||
// First boot, no file → BanStore is seeded from static
|
||||
// authorization.
|
||||
val r1 =
|
||||
RelayEngine(
|
||||
url = url,
|
||||
stateFile = stateFile,
|
||||
seedAuthorization =
|
||||
AuthorizationSeed(
|
||||
bannedPubkeys = listOf(pk),
|
||||
allowedKinds = listOf(1, 7),
|
||||
),
|
||||
)
|
||||
try {
|
||||
assertTrue(r1.banStore.isBanned(pk), "static-seed pubkey ban must apply on first boot")
|
||||
assertEquals(listOf(1, 7), r1.banStore.listAllowedKinds())
|
||||
} finally {
|
||||
r1.close()
|
||||
}
|
||||
// First boot wrote nothing to disk yet (we never mutated), so
|
||||
// the file is still absent — seed still wins on restart.
|
||||
assertTrue(!stateFile.exists(), "no admin mutation yet → no snapshot")
|
||||
|
||||
// Trigger a mutation so the file gets written.
|
||||
val r2 = RelayEngine(url = url, stateFile = stateFile)
|
||||
try {
|
||||
r2.banStore.banPubkey("b".repeat(64))
|
||||
} finally {
|
||||
r2.close()
|
||||
}
|
||||
assertTrue(stateFile.exists())
|
||||
|
||||
// Now restart with a *different* static seed: it must be
|
||||
// ignored — the file is authoritative.
|
||||
val r3 =
|
||||
RelayEngine(
|
||||
url = url,
|
||||
stateFile = stateFile,
|
||||
seedAuthorization =
|
||||
AuthorizationSeed(
|
||||
bannedPubkeys = listOf("c".repeat(64)),
|
||||
allowedKinds = listOf(99),
|
||||
),
|
||||
)
|
||||
try {
|
||||
assertTrue(r3.banStore.isBanned("b".repeat(64)), "persisted file ban must survive restart")
|
||||
assertTrue(!r3.banStore.isBanned("c".repeat(64)), "new static seed must be ignored once file exists")
|
||||
assertTrue(r3.banStore.listAllowedKinds().isEmpty(), "new static kind seed must be ignored once file exists")
|
||||
} finally {
|
||||
r3.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedFromStaticConfigFlowsThroughToNip11Reply() {
|
||||
// End-to-end via RelayEngine: a static-derived info reaches the
|
||||
// engine's info property without any persisted runtime override.
|
||||
val seeded =
|
||||
com.vitorpamplona.geode.RelayInfo(
|
||||
Nip11RelayInformation(name = "static-name", description = "static-desc"),
|
||||
)
|
||||
val engine = RelayEngine(url = url, info = seeded, stateFile = stateFile)
|
||||
try {
|
||||
assertEquals("static-name", engine.info.document.name)
|
||||
assertEquals("static-desc", engine.info.document.description)
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual `Nip11RelayInformation.copy` — the class isn't a data
|
||||
* class so Kotlin doesn't generate one. We only need `name` here.
|
||||
+7
-7
@@ -26,10 +26,10 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RelayConfigTest {
|
||||
class StaticConfigTest {
|
||||
@Test
|
||||
fun emptyTomlYieldsAllDefaults() {
|
||||
val c = RelayConfig.fromToml("")
|
||||
val c = StaticConfig.fromToml("")
|
||||
assertEquals("0.0.0.0", c.network.host)
|
||||
assertEquals(7447, c.network.port)
|
||||
assertEquals("/", c.network.path)
|
||||
@@ -42,7 +42,7 @@ class RelayConfigTest {
|
||||
|
||||
@Test
|
||||
fun verifySignaturesCanBeExplicitlyDisabled() {
|
||||
val c = RelayConfig.fromToml("[options]\nverify_signatures = false")
|
||||
val c = StaticConfig.fromToml("[options]\nverify_signatures = false")
|
||||
assertEquals(false, c.options.verify_signatures)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class RelayConfigTest {
|
||||
kind_blacklist = [4, 1059]
|
||||
""".trimIndent()
|
||||
|
||||
val c = RelayConfig.fromToml(toml)
|
||||
val c = StaticConfig.fromToml(toml)
|
||||
|
||||
assertEquals("wss://relay.example.com/", c.info.relay_url)
|
||||
assertEquals("Example", c.info.name)
|
||||
@@ -104,7 +104,7 @@ class RelayConfigTest {
|
||||
@Test
|
||||
fun supportedNipsRenderedAsStringsInNip11Doc() {
|
||||
val c =
|
||||
RelayConfig.fromToml(
|
||||
StaticConfig.fromToml(
|
||||
"""
|
||||
[info]
|
||||
supported_nips = [1, 11, 42]
|
||||
@@ -137,7 +137,7 @@ class RelayConfigTest {
|
||||
"config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}",
|
||||
)
|
||||
|
||||
val c = RelayConfig.fromFile(example)
|
||||
val c = StaticConfig.fromFile(example)
|
||||
|
||||
assertEquals("wss://relay.example.com/", c.info.relay_url)
|
||||
assertEquals(true, c.options.verify_signatures)
|
||||
@@ -147,7 +147,7 @@ class RelayConfigTest {
|
||||
|
||||
@Test
|
||||
fun missingSectionsAreOptional() {
|
||||
val c = RelayConfig.fromToml("[info]\nname = \"only-info\"")
|
||||
val c = StaticConfig.fromToml("[info]\nname = \"only-info\"")
|
||||
assertEquals("only-info", c.info.name)
|
||||
// Defaults preserved for unspecified sections.
|
||||
assertEquals(7447, c.network.port)
|
||||
Reference in New Issue
Block a user