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:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user