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)
This commit is contained in:
@@ -82,6 +82,7 @@ core = "1.7.0"
|
|||||||
mavenPublish = "0.36.0"
|
mavenPublish = "0.36.0"
|
||||||
sqlite = "2.6.2"
|
sqlite = "2.6.2"
|
||||||
ktor = "3.4.1"
|
ktor = "3.4.1"
|
||||||
|
fourkoma = "1.2.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
|
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
|
||||||
@@ -179,6 +180,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines",
|
|||||||
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
|
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
|
||||||
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
|
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
|
||||||
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
|
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
|
||||||
|
fourkoma = { module = "cc.ekblad:4koma", version.ref = "fourkoma" }
|
||||||
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
|
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
|
||||||
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
|
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
|
||||||
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ dependencies {
|
|||||||
api(libs.ktor.server.cio)
|
api(libs.ktor.server.cio)
|
||||||
api(libs.ktor.server.websockets)
|
api(libs.ktor.server.websockets)
|
||||||
|
|
||||||
|
// TOML parsing for the operator config file. Mirrors the section
|
||||||
|
// layout of nostr-rs-relay's config.toml so existing operators can
|
||||||
|
// port their configs nearly verbatim.
|
||||||
|
implementation(libs.fourkoma)
|
||||||
|
|
||||||
testImplementation(libs.kotlin.test)
|
testImplementation(libs.kotlin.test)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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 = "/"
|
||||||
|
# Set when behind a reverse proxy (nginx/Caddy/Cloudflare). Required
|
||||||
|
# before any IP-based rate limit means anything. Parsed today, enforced
|
||||||
|
# once rate limits land.
|
||||||
|
# remote_ip_header = "X-Forwarded-For"
|
||||||
|
|
||||||
|
[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_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. Parsed today, enforced once the matching policy lands.
|
||||||
|
# reject_future_seconds = 1800
|
||||||
|
|
||||||
|
# --- Sections below are parsed today but NOT YET ENFORCED. They are
|
||||||
|
# accepted for forward compatibility — the matching enforcement code is
|
||||||
|
# tracked separately. The relay logs a warning for each used section. ---
|
||||||
|
|
||||||
|
[limits]
|
||||||
|
# max_event_bytes = 131072
|
||||||
|
# max_ws_message_bytes = 1048576
|
||||||
|
# max_ws_frame_bytes = 1048576
|
||||||
|
# messages_per_sec = 10
|
||||||
|
# subscriptions_per_min = 60
|
||||||
|
# max_subscriptions_per_session = 32
|
||||||
|
# max_filters_per_req = 10
|
||||||
|
|
||||||
|
[authorization]
|
||||||
|
# pubkey_whitelist = []
|
||||||
|
# pubkey_blacklist = []
|
||||||
|
# kind_whitelist = []
|
||||||
|
# kind_blacklist = []
|
||||||
@@ -27,40 +27,65 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||||
|
import com.vitorpamplona.quartz.relay.config.RelayConfig
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standalone entry point. Run with `./gradlew :quartz-relay:run` (when the
|
* Standalone entry point.
|
||||||
* application plugin is configured) or `java -cp ... Main`.
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* Run with:
|
||||||
* --host <addr> bind address (default 0.0.0.0)
|
* ./gradlew :quartz-relay:run --args="--config /etc/quartz-relay.toml"
|
||||||
* --port <n> tcp port (default 7447, 0 to autobind)
|
* or
|
||||||
* --path <p> ws path (default /)
|
* java -cp ... com.vitorpamplona.quartz.relay.MainKt --port 7447 --verify
|
||||||
* --info <file> NIP-11 doc file (default: built-in)
|
*
|
||||||
* --db <file> sqlite db path (default: in-memory)
|
* Configuration precedence (highest to lowest):
|
||||||
* --auth require NIP-42 AUTH for REQ/EVENT/COUNT
|
* 1. CLI flags (`--host`, `--port`, …)
|
||||||
* --verify verify event signatures (recommended for any
|
* 2. TOML file passed via `--config <path>`
|
||||||
* relay accepting traffic from real clients)
|
* 3. Built-in defaults (host=0.0.0.0, port=7447, in-memory db, …)
|
||||||
|
*
|
||||||
|
* Sections currently parsed AND enforced: `[info]`, `[network]`,
|
||||||
|
* `[database]`, `[options]`. Sections parsed but not yet enforced
|
||||||
|
* (forward-compat for the rate-limit / authorization work):
|
||||||
|
* `[limits]`, `[authorization]`.
|
||||||
|
*
|
||||||
|
* CLI flags:
|
||||||
|
* --config <file> TOML config (see config.example.toml)
|
||||||
|
* --host <addr> bind address (default from config or 0.0.0.0)
|
||||||
|
* --port <n> tcp port (default from config or 7447, 0 to autobind)
|
||||||
|
* --path <p> ws path (default from config or /)
|
||||||
|
* --info <file> NIP-11 doc file (overrides [info] section)
|
||||||
|
* --db <file> sqlite db path (overrides [database].file)
|
||||||
|
* --auth require NIP-42 AUTH (sets options.require_auth = true)
|
||||||
|
* --verify verify event signatures (sets options.verify_signatures = true)
|
||||||
*/
|
*/
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
val a = parseArgs(args)
|
val a = parseArgs(args)
|
||||||
val host = a.opt("--host") ?: "0.0.0.0"
|
|
||||||
val port = a.opt("--port")?.toInt() ?: 7447
|
|
||||||
val path = a.opt("--path") ?: "/"
|
|
||||||
val infoFile = a.opt("--info")?.let { File(it) }
|
|
||||||
val dbFile = a.opt("--db")
|
|
||||||
val requireAuth = a.flag("--auth")
|
|
||||||
val verifySigs = a.flag("--verify")
|
|
||||||
|
|
||||||
val urlStr = "ws://$host:$port$path"
|
val config: RelayConfig =
|
||||||
// For binding 0.0.0.0 we still want to scope the relay to a "public" url
|
a
|
||||||
// shape for NIP-42 challenge validation; use the host the operator
|
.opt("--config")
|
||||||
// exposes (--info usually carries the public URL). Fall back to
|
?.let { RelayConfig.fromFile(File(it)) }
|
||||||
// 127.0.0.1 so localhost smoke tests work.
|
?: RelayConfig()
|
||||||
val advertisedUrl = (if (host == "0.0.0.0") "ws://127.0.0.1:$port$path" else urlStr).normalizeRelayUrl()
|
|
||||||
|
|
||||||
val info = infoFile?.let { RelayInfo.fromFile(it) } ?: RelayInfo.default(advertisedUrl)
|
val host = a.opt("--host") ?: config.network.host
|
||||||
|
val port = a.opt("--port")?.toInt() ?: config.network.port
|
||||||
|
val path = a.opt("--path") ?: config.network.path
|
||||||
|
|
||||||
|
val cliInfoFile = a.opt("--info")?.let { File(it) }
|
||||||
|
val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory }
|
||||||
|
val requireAuth = a.flag("--auth") || config.options.require_auth
|
||||||
|
val verifySigs = a.flag("--verify") || config.options.verify_signatures
|
||||||
|
|
||||||
|
// Advertised URL: explicit `info.relay_url` wins, then build from
|
||||||
|
// host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42
|
||||||
|
// challenges are well-formed.
|
||||||
|
val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host
|
||||||
|
val advertisedUrl =
|
||||||
|
(config.info.relay_url ?: "ws://$advertisedHost:$port$path").normalizeRelayUrl()
|
||||||
|
|
||||||
|
val info =
|
||||||
|
cliInfoFile?.let { RelayInfo.fromFile(it) }
|
||||||
|
?: config.resolveInfo(advertisedUrl)
|
||||||
|
|
||||||
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
|
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
|
||||||
|
|
||||||
@@ -72,6 +97,8 @@ fun main(args: Array<String>) {
|
|||||||
else -> { -> EmptyPolicy }
|
else -> { -> EmptyPolicy }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warnUnenforcedSections(config)
|
||||||
|
|
||||||
val relay = Relay(advertisedUrl, store, info, policyBuilder)
|
val relay = Relay(advertisedUrl, store, info, policyBuilder)
|
||||||
val server = LocalRelayServer(relay, host = host, port = port, path = path).start()
|
val server = LocalRelayServer(relay, host = host, port = port, path = path).start()
|
||||||
|
|
||||||
@@ -83,12 +110,43 @@ fun main(args: Array<String>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
println("quartz-relay listening on ${server.url}")
|
println("quartz-relay listening on ${server.url}")
|
||||||
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$host:$port$path")
|
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path")
|
||||||
|
|
||||||
// Park the main thread; shutdown hook handles teardown.
|
// Park the main thread; shutdown hook handles teardown.
|
||||||
Thread.currentThread().join()
|
Thread.currentThread().join()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Surface a warning when the operator has set sections we don't yet enforce. */
|
||||||
|
private fun warnUnenforcedSections(config: RelayConfig) {
|
||||||
|
val warnings = mutableListOf<String>()
|
||||||
|
val l = config.limits
|
||||||
|
if (l.max_event_bytes != null ||
|
||||||
|
l.max_ws_message_bytes != null ||
|
||||||
|
l.max_ws_frame_bytes != null ||
|
||||||
|
l.messages_per_sec != null ||
|
||||||
|
l.subscriptions_per_min != null ||
|
||||||
|
l.max_subscriptions_per_session != null ||
|
||||||
|
l.max_filters_per_req != null
|
||||||
|
) {
|
||||||
|
warnings += "[limits] section is parsed but NOT YET ENFORCED — rate limits / message size caps are pending."
|
||||||
|
}
|
||||||
|
val auth = config.authorization
|
||||||
|
if (auth.pubkey_whitelist.isNotEmpty() ||
|
||||||
|
auth.pubkey_blacklist.isNotEmpty() ||
|
||||||
|
auth.kind_whitelist.isNotEmpty() ||
|
||||||
|
auth.kind_blacklist.isNotEmpty()
|
||||||
|
) {
|
||||||
|
warnings += "[authorization] section is parsed but NOT YET ENFORCED — pubkey/kind allow-deny lists are pending."
|
||||||
|
}
|
||||||
|
if (config.options.reject_future_seconds != null) {
|
||||||
|
warnings += "[options].reject_future_seconds is parsed but NOT YET ENFORCED."
|
||||||
|
}
|
||||||
|
if (config.network.remote_ip_header != null) {
|
||||||
|
warnings += "[network].remote_ip_header is parsed but NOT YET ENFORCED — IP-based limits are pending."
|
||||||
|
}
|
||||||
|
warnings.forEach { System.err.println("warning: $it") }
|
||||||
|
}
|
||||||
|
|
||||||
private class Args(
|
private class Args(
|
||||||
private val opts: Map<String, String>,
|
private val opts: Map<String, String>,
|
||||||
private val flags: Set<String>,
|
private val flags: Set<String>,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/*
|
||||||
|
* 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.quartz.relay.config
|
||||||
|
|
||||||
|
import cc.ekblad.toml.decode
|
||||||
|
import cc.ekblad.toml.tomlMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.relay.RelayInfo
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Every section is optional; values not set fall back to sensible
|
||||||
|
* defaults (or, for fields also exposed on the CLI, the CLI value wins).
|
||||||
|
*
|
||||||
|
* Sections that are parsed but **not yet enforced** by the relay are
|
||||||
|
* marked below; they're accepted so configs remain forward-compatible
|
||||||
|
* once the matching policy is implemented (rate limits, NIP-05, etc.).
|
||||||
|
*/
|
||||||
|
data class RelayConfig(
|
||||||
|
val info: InfoSection = InfoSection(),
|
||||||
|
val network: NetworkSection = NetworkSection(),
|
||||||
|
val database: DatabaseSection = DatabaseSection(),
|
||||||
|
val options: OptionsSection = OptionsSection(),
|
||||||
|
/** Parsed but not yet enforced. */
|
||||||
|
val limits: LimitsSection = LimitsSection(),
|
||||||
|
/** Parsed but not yet enforced. */
|
||||||
|
val authorization: AuthorizationSection = AuthorizationSection(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 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 =
|
||||||
|
RelayInfo(
|
||||||
|
Nip11RelayInformation(
|
||||||
|
name = info.name ?: "quartz-relay",
|
||||||
|
description = info.description ?: "Embedded Nostr relay from the Amethyst quartz library.",
|
||||||
|
pubkey = info.pubkey,
|
||||||
|
contact = info.contact,
|
||||||
|
icon = info.icon,
|
||||||
|
software =
|
||||||
|
info.software
|
||||||
|
?: "https://github.com/vitorpamplona/amethyst/tree/main/quartz-relay",
|
||||||
|
version = info.version ?: "1.08.0",
|
||||||
|
supported_nips =
|
||||||
|
info.supported_nips?.map(Int::toString)
|
||||||
|
?: listOf("1", "9", "11", "40", "42", "45", "50", "62"),
|
||||||
|
privacy_policy = info.privacy_policy,
|
||||||
|
terms_of_service = info.terms_of_service,
|
||||||
|
relay_countries = info.relay_countries,
|
||||||
|
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,
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val pubkey: String? = null,
|
||||||
|
val contact: String? = null,
|
||||||
|
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<Int>? = null,
|
||||||
|
val privacy_policy: String? = null,
|
||||||
|
val terms_of_service: String? = null,
|
||||||
|
val relay_countries: List<String>? = null,
|
||||||
|
val language_tags: List<String>? = null,
|
||||||
|
val tags: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NetworkSection(
|
||||||
|
val host: String = "0.0.0.0",
|
||||||
|
val port: Int = 7447,
|
||||||
|
val path: String = "/",
|
||||||
|
/**
|
||||||
|
* When set, the relay reads the client IP from this header
|
||||||
|
* (typically `X-Forwarded-For` behind a reverse proxy). Required
|
||||||
|
* once IP-based rate limits land.
|
||||||
|
*/
|
||||||
|
val remote_ip_header: String? = 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. */
|
||||||
|
val verify_signatures: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LimitsSection(
|
||||||
|
val max_event_bytes: Int? = null,
|
||||||
|
val max_ws_message_bytes: Int? = null,
|
||||||
|
val max_ws_frame_bytes: Int? = null,
|
||||||
|
val messages_per_sec: Int? = null,
|
||||||
|
val subscriptions_per_min: Int? = null,
|
||||||
|
val max_subscriptions_per_session: Int? = null,
|
||||||
|
val max_filters_per_req: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthorizationSection(
|
||||||
|
val pubkey_whitelist: List<String> = emptyList(),
|
||||||
|
val pubkey_blacklist: List<String> = emptyList(),
|
||||||
|
val kind_whitelist: List<Int> = emptyList(),
|
||||||
|
val kind_blacklist: List<Int> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val mapper = tomlMapper { }
|
||||||
|
|
||||||
|
/** Parse a TOML string. */
|
||||||
|
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
|
||||||
|
|
||||||
|
/** Load a TOML config file. */
|
||||||
|
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(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`).
|
||||||
|
*/
|
||||||
|
fun advertisedUrl(config: RelayConfig): 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
|
||||||
|
return "ws://$host:${net.port}${net.path}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* 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.quartz.relay.config
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class RelayConfigTest {
|
||||||
|
@Test
|
||||||
|
fun emptyTomlYieldsAllDefaults() {
|
||||||
|
val c = RelayConfig.fromToml("")
|
||||||
|
assertEquals("0.0.0.0", c.network.host)
|
||||||
|
assertEquals(7447, c.network.port)
|
||||||
|
assertEquals("/", c.network.path)
|
||||||
|
assertEquals(true, c.database.in_memory)
|
||||||
|
assertEquals(false, c.options.require_auth)
|
||||||
|
assertEquals(false, c.options.verify_signatures)
|
||||||
|
assertTrue(c.authorization.pubkey_whitelist.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesAllSectionsTogether() {
|
||||||
|
val toml =
|
||||||
|
"""
|
||||||
|
[info]
|
||||||
|
relay_url = "wss://relay.example.com/"
|
||||||
|
name = "Example"
|
||||||
|
contact = "ops@example.com"
|
||||||
|
supported_nips = [1, 9, 11, 42]
|
||||||
|
|
||||||
|
[network]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 9988
|
||||||
|
path = "/relay"
|
||||||
|
remote_ip_header = "X-Forwarded-For"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
in_memory = false
|
||||||
|
file = "/var/lib/quartz-relay/events.db"
|
||||||
|
|
||||||
|
[options]
|
||||||
|
verify_signatures = true
|
||||||
|
require_auth = true
|
||||||
|
reject_future_seconds = 1800
|
||||||
|
|
||||||
|
[limits]
|
||||||
|
max_event_bytes = 131072
|
||||||
|
messages_per_sec = 10
|
||||||
|
max_filters_per_req = 12
|
||||||
|
|
||||||
|
[authorization]
|
||||||
|
pubkey_blacklist = ["aaaa", "bbbb"]
|
||||||
|
kind_blacklist = [4, 1059]
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val c = RelayConfig.fromToml(toml)
|
||||||
|
|
||||||
|
assertEquals("wss://relay.example.com/", c.info.relay_url)
|
||||||
|
assertEquals("Example", c.info.name)
|
||||||
|
assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips)
|
||||||
|
|
||||||
|
assertEquals("127.0.0.1", c.network.host)
|
||||||
|
assertEquals(9988, c.network.port)
|
||||||
|
assertEquals("/relay", c.network.path)
|
||||||
|
assertEquals("X-Forwarded-For", c.network.remote_ip_header)
|
||||||
|
|
||||||
|
assertEquals(false, c.database.in_memory)
|
||||||
|
assertEquals("/var/lib/quartz-relay/events.db", c.database.file)
|
||||||
|
|
||||||
|
assertEquals(true, c.options.verify_signatures)
|
||||||
|
assertEquals(true, c.options.require_auth)
|
||||||
|
assertEquals(1800, c.options.reject_future_seconds)
|
||||||
|
|
||||||
|
assertEquals(131072, c.limits.max_event_bytes)
|
||||||
|
assertEquals(10, c.limits.messages_per_sec)
|
||||||
|
assertEquals(12, c.limits.max_filters_per_req)
|
||||||
|
|
||||||
|
assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist)
|
||||||
|
assertEquals(listOf(4, 1059), c.authorization.kind_blacklist)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supportedNipsRenderedAsStringsInNip11Doc() {
|
||||||
|
val c =
|
||||||
|
RelayConfig.fromToml(
|
||||||
|
"""
|
||||||
|
[info]
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertEquals(listOf("1", "11", "42"), info.document.supported_nips)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadsTheBundledExampleConfigCleanly() {
|
||||||
|
// The example file lives at the module root so operators have a
|
||||||
|
// canonical reference. Read it via a relative path resolved
|
||||||
|
// against the working directory (gradle runs tests from the
|
||||||
|
// module dir).
|
||||||
|
val candidates =
|
||||||
|
listOf(
|
||||||
|
File("config.example.toml"),
|
||||||
|
File("quartz-relay/config.example.toml"),
|
||||||
|
)
|
||||||
|
val example =
|
||||||
|
candidates.firstOrNull { it.exists() }
|
||||||
|
?: error(
|
||||||
|
"config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}",
|
||||||
|
)
|
||||||
|
|
||||||
|
val c = RelayConfig.fromFile(example)
|
||||||
|
|
||||||
|
assertEquals("wss://relay.example.com/", c.info.relay_url)
|
||||||
|
assertEquals(true, c.options.verify_signatures)
|
||||||
|
assertEquals(false, c.database.in_memory)
|
||||||
|
assertNotNull(c.database.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingSectionsAreOptional() {
|
||||||
|
val c = RelayConfig.fromToml("[info]\nname = \"only-info\"")
|
||||||
|
assertEquals("only-info", c.info.name)
|
||||||
|
// Defaults preserved for unspecified sections.
|
||||||
|
assertEquals(7447, c.network.port)
|
||||||
|
assertEquals(true, c.database.in_memory)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user