mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix(geode): validate config knobs and mirror filter at boot
Fail-loud on config that would silently degrade the running relay: - readers = 0 makes every query hang forever on an empty reader pool; readers < 0 crashes with an unrelated message. optimize_interval_seconds <= 0 busy-loops PRAGMA optimize under the writer mutex. StaticConfig .validate() (called at boot) rejects both. - A typo in [[mirror]].filter (e.g. `kindss`) parsed to an empty match-everything filter through the tolerant deserializer — silently widening a trusted upstream's skip-verify scope to the whole firehose. MirrorFilterValidator strict-checks the filter JSON at boot: unknown keys and non-array list fields fail startup. - The self-mirror guard now compares scheme-insensitively (ws:// vs wss:// for the same host is still us) via displayUrl(). - The maintenance loop rethrows CancellationException and no longer swallows Errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
This commit is contained in:
@@ -127,7 +127,9 @@ require_auth = false
|
||||
# inject events inside the declared scope. `since`/`limit` inside it
|
||||
# are ignored (backfill_seconds owns the time window). Omit to mirror
|
||||
# everything; for several disjoint scopes, repeat [[mirror]] with the
|
||||
# same url.
|
||||
# same url. The keys are validated at boot (a typo like `kindss` or a
|
||||
# scalar where an array belongs fails startup) precisely because this
|
||||
# filter is the trust boundary for `trusted = true`.
|
||||
#
|
||||
# `dir` (strfry-router parity) sets the flow direction: "down" pulls
|
||||
# from the upstream (default), "up" pushes this relay's matching
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.config.BannedEntry
|
||||
import com.vitorpamplona.geode.config.MirrorFilterValidator
|
||||
import com.vitorpamplona.geode.config.RuntimeConfig
|
||||
import com.vitorpamplona.geode.config.RuntimeConfigData
|
||||
import com.vitorpamplona.geode.config.StaticConfig
|
||||
@@ -30,6 +31,7 @@ import com.vitorpamplona.geode.mirror.MirrorWorker
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||
@@ -40,6 +42,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPo
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -95,6 +98,7 @@ fun main(args: Array<String>) {
|
||||
.opt("--config")
|
||||
?.let { StaticConfig.fromFile(File(it)) }
|
||||
?: StaticConfig()
|
||||
config.validate()
|
||||
|
||||
val host = a.opt("--host") ?: config.network.host
|
||||
val port = a.opt("--port")?.toInt() ?: config.network.port
|
||||
@@ -210,6 +214,14 @@ fun main(args: Array<String>) {
|
||||
// (backfill_seconds) and never bounds the subscription.
|
||||
val scope =
|
||||
m.filter?.let { json ->
|
||||
// Strict-validate FIRST: the deserializer is tolerant
|
||||
// (unknown keys skipped, wrong-typed entries dropped),
|
||||
// so a typo like `{"kindss":[4]}` would silently parse
|
||||
// to an empty filter — widening a trusted upstream's
|
||||
// scope to the whole firehose. This filter is the
|
||||
// containment boundary for `trusted = true`, so a typo
|
||||
// must fail the boot, not the boundary.
|
||||
MirrorFilterValidator.validate(m.url, json)
|
||||
val parsed =
|
||||
try {
|
||||
OptimizedJsonMapper.fromJsonTo<Filter>(json)
|
||||
@@ -234,7 +246,14 @@ fun main(args: Array<String>) {
|
||||
direction = direction,
|
||||
)
|
||||
}
|
||||
require(upstreams.none { it.url == advertisedUrl }) {
|
||||
// Never mirror ourselves — a self-URL echoes every local publish
|
||||
// back forever. Compare scheme-insensitively (ws:// vs wss:// for the
|
||||
// same host is still us) and ignoring the trailing slash. This can't
|
||||
// catch a public URL that resolves to this bind behind a proxy, nor a
|
||||
// `--port 0` autobind, so it's a guardrail against the obvious typo,
|
||||
// not a proof of non-self-reference.
|
||||
val advertisedIdentity = advertisedUrl.displayUrl()
|
||||
require(upstreams.none { it.url.displayUrl() == advertisedIdentity }) {
|
||||
"[[mirror]] must not list this relay's own URL ($advertisedUrl)"
|
||||
}
|
||||
val mirror =
|
||||
@@ -252,7 +271,16 @@ fun main(args: Array<String>) {
|
||||
maintenanceScope.launch {
|
||||
while (true) {
|
||||
delay(secs * 1000)
|
||||
runCatching { store.optimize() }
|
||||
try {
|
||||
store.optimize()
|
||||
} catch (e: CancellationException) {
|
||||
throw e // shutdown cancelled us; don't swallow it
|
||||
} catch (e: Exception) {
|
||||
// A missed refresh only means slightly staler planner
|
||||
// stats until the next tick — log and keep the loop.
|
||||
// Errors (OOM, etc.) are NOT swallowed.
|
||||
println("PRAGMA optimize failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
|
||||
/**
|
||||
* Strict boot-time check for a `[[mirror]].filter` JSON string. The
|
||||
* NIP-01 [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]
|
||||
* deserializer is tolerant by design — it skips unknown keys and drops
|
||||
* wrong-typed entries — which is right for untrusted wire input but
|
||||
* wrong for operator config: a typo like `{"kindss":[4]}` would silently
|
||||
* parse to an empty (match-everything) filter. Because this filter is
|
||||
* the containment boundary for a `trusted = true` upstream (it caps what
|
||||
* a skip-verify upstream may inject), a silent mis-parse would widen
|
||||
* that trust to the whole firehose. So we reject the obvious mistakes at
|
||||
* boot instead of degrading the boundary.
|
||||
*/
|
||||
object MirrorFilterValidator {
|
||||
/** NIP-01 filter keys the deserializer recognizes; anything else is a typo. */
|
||||
private val KNOWN_KEYS = setOf("ids", "authors", "kinds", "since", "until", "limit", "search")
|
||||
|
||||
/** Keys whose value must be a JSON array. */
|
||||
private val ARRAY_KEYS = setOf("ids", "authors", "kinds")
|
||||
|
||||
private val mapper = ObjectMapper()
|
||||
|
||||
/**
|
||||
* Throws [IllegalArgumentException] when [json] is not a JSON object,
|
||||
* carries an unrecognized top-level key, or gives a list-typed field
|
||||
* (`ids`/`authors`/`kinds`, or a `#tag`/`&tag`) a non-array value.
|
||||
*/
|
||||
fun validate(
|
||||
url: String,
|
||||
json: String,
|
||||
) {
|
||||
val node =
|
||||
try {
|
||||
mapper.readTree(json)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("[[mirror]] filter for $url is not valid JSON: $json", e)
|
||||
}
|
||||
require(node is ObjectNode) {
|
||||
"[[mirror]] filter for $url must be a JSON object (a NIP-01 filter), got: $json"
|
||||
}
|
||||
node.fieldNames().forEach { field ->
|
||||
val isTagKey = field.length > 1 && (field[0] == '#' || field[0] == '&')
|
||||
require(field in KNOWN_KEYS || isTagKey) {
|
||||
"[[mirror]] filter for $url has unknown key \"$field\" — a NIP-01 filter uses " +
|
||||
"ids/authors/kinds/since/until/limit/search or #tag/&tag"
|
||||
}
|
||||
if (field in ARRAY_KEYS || isTagKey) {
|
||||
require(node.get(field).isArray) {
|
||||
"[[mirror]] filter for $url: \"$field\" must be a JSON array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,10 @@ data class StaticConfig(
|
||||
/**
|
||||
* Keep an always-current in-memory `(created_at, id)` set so
|
||||
* full-corpus NEG-OPENs skip the table scan + seal (strfry
|
||||
* parity). ~40 B per stored event of heap; on by default.
|
||||
* parity). ~140 B per stored event of heap; on by default. Only
|
||||
* built once the first full-corpus NEG-OPEN arrives, and only
|
||||
* when the corpus fits `max_sync_events` (an over-cap corpus
|
||||
* answers NEG-ERR from a capped scan instead).
|
||||
*/
|
||||
val live_index: Boolean = true,
|
||||
)
|
||||
@@ -250,6 +253,25 @@ data class StaticConfig(
|
||||
val state_file: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Boot-time sanity check for values the TOML types can't constrain.
|
||||
* Throws [IllegalArgumentException] (fail-loud at startup) rather
|
||||
* than letting a nonsensical knob degrade the running relay — a zero
|
||||
* reader pool hangs every query, a non-positive optimize interval
|
||||
* busy-loops the writer. Call once after parsing, before building
|
||||
* the store.
|
||||
*/
|
||||
fun validate() {
|
||||
database.readers?.let {
|
||||
require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" }
|
||||
}
|
||||
database.optimize_interval_seconds?.let {
|
||||
require(it > 0) {
|
||||
"[database].optimize_interval_seconds must be > 0 (got $it); a non-positive interval busy-loops PRAGMA optimize under the writer mutex"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val mapper = tomlMapper { }
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -81,6 +82,58 @@ class StaticConfigTest {
|
||||
assertTrue(StaticConfig.fromToml("").mirror.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateRejectsNonPositiveReaders() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
StaticConfig.fromToml("[database]\nreaders = 0").validate()
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
StaticConfig.fromToml("[database]\nreaders = -1").validate()
|
||||
}
|
||||
// A sane pool passes.
|
||||
StaticConfig.fromToml("[database]\nreaders = 1").validate()
|
||||
// Unset passes (quartz default applies).
|
||||
StaticConfig.fromToml("").validate()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateRejectsNonPositiveOptimizeInterval() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
StaticConfig.fromToml("[database]\noptimize_interval_seconds = 0").validate()
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
StaticConfig.fromToml("[database]\noptimize_interval_seconds = -5").validate()
|
||||
}
|
||||
StaticConfig.fromToml("[database]\noptimize_interval_seconds = 3600").validate()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mirrorFilterValidatorRejectsTyposAndScalars() {
|
||||
val url = "wss://up.example/"
|
||||
|
||||
// Unknown key (a typo) — must fail, not silently widen scope.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
MirrorFilterValidator.validate(url, """{"kindss":[4]}""")
|
||||
}
|
||||
// List field given a scalar.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
MirrorFilterValidator.validate(url, """{"authors":"abc"}""")
|
||||
}
|
||||
// Not an object.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
MirrorFilterValidator.validate(url, """["kinds",1]""")
|
||||
}
|
||||
// Malformed JSON.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
MirrorFilterValidator.validate(url, """{"kinds":[1,}""")
|
||||
}
|
||||
|
||||
// Valid shapes pass: recognized scalar + array + tag keys.
|
||||
MirrorFilterValidator.validate(url, """{"kinds":[0,1,3],"#t":["nostr"],"since":123,"limit":5,"search":"x"}""")
|
||||
MirrorFilterValidator.validate(url, """{"&p":["abc"]}""")
|
||||
MirrorFilterValidator.validate(url, "{}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mirrorFilterJsonParsesToANip01Filter() {
|
||||
// The exact parse Main.kt runs on [[mirror]].filter at boot.
|
||||
|
||||
Reference in New Issue
Block a user