refactor(quartz, geode): derive NIP-86 publicUrl from relay.url; drop isEnabled branch

Two simplifications:

1. Derive admin URL from RelayEngine.url. NIP-86 spec mandates that
   the admin endpoint is "the same URI as ws(s)://, called via
   http(s)://" — so KtorRelay derives the NIP-98 binding URL by
   calling relay.url.toHttp() instead of accepting publicUrl as a
   separate config. Single source of truth (info.relay_url),
   accidental misconfiguration impossible, no Host-header fallback.
   StaticConfig.AdminSection.public_url is gone.

2. Uniform code path for admin-enabled vs disabled. Empty allow-list
   isn't a special case anywhere: Nip86Server.isAuthorized returns
   false for everyone, dispatch rejects, Nip86HttpHandler returns
   NotAdmin → 403. KtorRelay always assembles the Nip86HttpRoute
   and registers the POST endpoint; Nip86Server.isEnabled() and the
   "is admin on?" branches in the handler and route are deleted.

Nip86EndToEndTest's admin-disabled case is now a behavioral test:
sign a valid token, expect 403 NotAdmin (was 405 with the no-route
variant, was 403 with the fake-disabled-route variant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 18:19:24 -04:00
parent a53b3339b3
commit 445fbf8cda
10 changed files with 192 additions and 129 deletions
@@ -25,18 +25,17 @@ import com.vitorpamplona.geode.server.Nip86HttpRoute
import com.vitorpamplona.geode.server.WebSocketSessionPump
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import io.ktor.http.HttpHeaders
import io.ktor.server.application.install
import io.ktor.server.application.serverConfig
import io.ktor.server.cio.CIO
import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.header
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
@@ -79,20 +78,6 @@ class KtorRelay(
* gated by NIP-98 HTTP-Auth membership in this set.
*/
val adminPubkeys: Set<HexKey> = emptySet(),
/**
* Canonical public URL the relay is reachable at, e.g.
* `https://relay.example.com/`. NIP-98 admin requests must sign
* the **same** URL string they're sending to. When the relay sits
* behind TLS termination or a reverse proxy, the `Host` header
* the relay sees does not match what the client signs, so the
* verifier must compare against this configured value.
*
* `null` (the default) falls back to the request's `Host` header
* with `http://` — fine for local-loopback unit tests, **NOT
* SAFE** in a public deployment because an attacker can spoof
* `Host` and bind their signature to any URL.
*/
val publicUrl: String? = null,
/**
* Ktor CIO acceptor-thread count. `null` keeps Ktor's default.
* Lift on machines with many cores when targeting 10k+
@@ -119,13 +104,25 @@ class KtorRelay(
allowList = adminPubkeys,
)
/**
* Always assembled — when [adminPubkeys] is empty, the route
* still runs the canonical NIP-86 flow but every request fails
* the allow-list check ([Nip86HttpHandler.Response.NotAdmin] →
* 403). Uniform code path: the transport doesn't branch on
* "admin enabled?".
*
* The NIP-98 binding URL is derived from `relay.url` via [toHttp]
* because NIP-86 mandates it: admin requests target the same URI
* as the WebSocket endpoint, just with `http(s)://` instead of
* `ws(s)://`. This makes accidental misconfiguration impossible —
* the operator's configured `[info].relay_url` is the single
* source of truth.
*/
private val nip86Route =
Nip86HttpRoute(
handler = Nip86HttpHandler(server = nip86Server),
signedUrlFor = { call ->
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
},
handler = Nip86HttpHandler(server = nip86Server, publicUrl = relay.url.toHttp()),
)
private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json })
private var engine: CIOApplicationEngine? = null
@@ -190,6 +187,8 @@ class KtorRelay(
}
// NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch.
// Always mounted; an empty [adminPubkeys] just means
// every request fails the allow-list check (403).
post(path) {
nip86Route.handle(call)
}
@@ -151,7 +151,6 @@ fun main(args: Array<String>) {
port = port,
path = path,
adminPubkeys = config.admin.pubkeys.toSet(),
publicUrl = config.admin.public_url,
connectionGroupSize = config.network.connection_group_size,
workerGroupSize = config.network.worker_group_size,
callGroupSize = config.network.call_group_size,
@@ -202,15 +202,15 @@ data class StaticConfig(
* HTTP-Auth. Only requests signed by one of these pubkeys are
* dispatched.
*
* [public_url] is the canonical URL the relay is reachable at,
* e.g. `https://relay.example.com/`. NIP-98's URL binding compares
* the signed `u` tag against this — without it, an attacker can
* spoof the `Host` header to bind their signature to any URL.
* Required when running behind TLS termination or a reverse proxy.
* 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.
*/
data class AdminSection(
val pubkeys: List<String> = emptyList(),
val public_url: String? = null,
/**
* Path for the JSON snapshot that backs [RuntimeConfig] —
* NIP-86 admin state (ban lists + the live NIP-11 doc) that
@@ -32,39 +32,33 @@ import io.ktor.utils.io.readAvailable
/**
* Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by
* [Nip86HttpHandler]. This class is intentionally thin: it pulls the
* Authorization header, signed URL, and body bytes out of the
* [ApplicationCall], hands them to the handler, then maps each
* [Nip86HttpHandler.Response] variant to the Ktor status / header /
* body it expects.
* [Nip86HttpHandler]. Intentionally thin: pull the Authorization
* header and body bytes out of the [ApplicationCall], hand them to
* the handler, map each [Nip86HttpHandler.Response] variant to a
* Ktor status/header/body.
*
* The bounded body read happens here (Ktor exposes `ByteReadChannel`,
* Always wired — when the relay's admin allow-list is empty, every
* request just fails the [Nip86HttpHandler.Response.NotAdmin] check
* and returns 403. No "is admin on?" branch in either the handler
* or the route.
*
* Bounded body read happens here (Ktor exposes `ByteReadChannel`,
* which is framework-specific) — we stop reading as soon as we'd
* exceed [Nip86HttpHandler.maxBodyBytes] so an unauthenticated
* attacker can't OOM the relay with a giant stream.
*
* Audit logging also lives here, off [Nip86HttpHandler.Response.Ok] —
* the handler keeps logging policy out of quartz; the geode adapter
* picks a stderr line format and runs with it.
* Audit logging stays here, off [Nip86HttpHandler.Response.Ok] — the
* handler keeps logging policy out of quartz; the geode adapter picks
* a stderr line format and runs with it.
*/
internal class Nip86HttpRoute(
private val handler: Nip86HttpHandler,
private val signedUrlFor: (ApplicationCall) -> String,
) {
suspend fun handle(call: ApplicationCall) {
val body = readBoundedBody(call) ?: return // 413 already sent
val body = readBoundedBody(call, handler.maxBodyBytes) ?: return // 413 already sent
val authHeader = call.request.header(HttpHeaders.Authorization)
val url = signedUrlFor(call)
when (val r = handler.handle(authHeader, url, body)) {
Nip86HttpHandler.Response.Disabled -> {
call.respondText(
"NIP-86 management API is not enabled on this relay.",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
}
when (val r = handler.handle(authHeader, body)) {
is Nip86HttpHandler.Response.PayloadTooLarge -> {
call.respondText(
"request body exceeds ${r.cap}-byte cap",
@@ -118,12 +112,14 @@ internal class Nip86HttpRoute(
}
/**
* Bounded read using `handler.maxBodyBytes`. Returns null after
* sending a 413 if the request body exceeds the cap — either the
* declared `Content-Length` or what we actually pull off the wire.
* Bounded read using [cap]. Returns null after sending a 413 if
* the request body exceeds the cap — either the declared
* `Content-Length` or what we actually pull off the wire.
*/
private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? {
val cap = handler.maxBodyBytes
private suspend fun readBoundedBody(
call: ApplicationCall,
cap: Int,
): ByteArray? {
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > cap) {
call.respondText(
@@ -72,13 +72,19 @@ class Nip86EndToEndTest {
@BeforeTest
fun setup() {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
relay = RelayEngine(url = placeholder)
// KtorRelay derives the NIP-98 admin URL from relay.url (per
// NIP-86: same URI as the WebSocket, just http(s)://). We must
// therefore pre-allocate the port and set relay.url to match
// — placeholder + OS-assigned port would cause the URL the
// server expects to differ from where it actually listens.
val freePort = java.net.ServerSocket(0).use { it.localPort }
val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
relay = RelayEngine(url = url)
server =
KtorRelay(
relay = relay,
host = "127.0.0.1",
port = 0,
port = freePort,
adminPubkeys = setOf(admin.pubKey),
).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -196,13 +202,16 @@ class Nip86EndToEndTest {
}
@Test
fun adminEndpointDisabledWhenNoPubkeysConfigured() =
fun adminEndpointRejectsValidTokenAsNotAdminWhenNoPubkeysConfigured() =
runBlocking {
// Spin up a *separate* server with no admin pubkeys.
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val openRelay = RelayEngine(url = placeholder)
// Spin up a *separate* server with no admin pubkeys. The
// POST route is still mounted (uniform code path); every
// request just fails the empty allow-list check → 403.
val freePort = java.net.ServerSocket(0).use { it.localPort }
val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
val openRelay = RelayEngine(url = url)
val openServer =
KtorRelay(openRelay, host = "127.0.0.1", port = 0).start()
KtorRelay(openRelay, host = "127.0.0.1", port = freePort).start()
try {
val openHttpUrl = openServer.url.replace("ws://", "http://")
val body =