diff --git a/geode/config.example.toml b/geode/config.example.toml index 2a4d8ce5ae..b64fd4cbab 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -66,13 +66,6 @@ require_auth = false # the future. Enforced by RejectFutureEventsPolicy. # reject_future_seconds = 1800 -[limits] -# Maximum WebSocket frame size. Frames larger than this are dropped at -# the WS layer. (max_ws_message_bytes maps to the same setting since -# Ktor's WebSockets plugin only exposes per-frame caps.) -# max_ws_message_bytes = 1048576 -# max_ws_frame_bytes = 1048576 - [authorization] # Allow / deny lists. Allow is a permissive ceiling; deny still # removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy. diff --git a/geode/plans/2026-05-07-negentropy-large-corpus.md b/geode/plans/2026-05-07-negentropy-large-corpus.md index b9e550b81d..a6b2b4f763 100644 --- a/geode/plans/2026-05-07-negentropy-large-corpus.md +++ b/geode/plans/2026-05-07-negentropy-large-corpus.md @@ -126,12 +126,13 @@ for unlimited). 500_000 is well above the floor. Pure config change in `NegSessionRegistry.open`; expose via `[negentropy].frame_size_limit` for operators who tune it down to fit smaller WS frame budgets. -Note: `LimitsSection.max_ws_frame_bytes` -(`geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt:148`) -applies to the WebSocket layer. After hex-encoding a 500_000-byte -negentropy payload doubles to ~1_000_000 bytes on the wire; ensure -`max_ws_frame_bytes` is at least 2 MB (or unlimited) in default -config so the response isn't truncated by the WS layer. +Note: Ktor's WebSocket layer does not impose a default frame cap, so a +~1 MB hex-encoded negentropy payload is delivered intact. The +`[limits].max_ws_frame_bytes` operator knob was removed once Ktor's +unbounded default was confirmed to be sufficient — see the +`refactor(geode): drop [limits] section` commit. If a reverse proxy +is in front of the relay, the proxy's frame cap is the only thing +that can still truncate. ### D — concurrent NEG-OPEN cap, shared with REQ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 6894a30734..c63162787e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -71,13 +71,6 @@ class KtorRelay( /** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */ val port: Int = 0, val path: String = "/", - /** - * Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the - * config. Frames larger than this are rejected at the WebSocket - * layer, which is the only layer that sees the raw bytes. `null` - * uses Ktor's default (~1 MiB). - */ - val maxFrameBytes: Long? = null, /** * Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default) * disables the admin endpoint entirely — POSTs return 403. @@ -100,15 +93,6 @@ class KtorRelay( * `Host` and bind their signature to any URL. */ val publicUrl: String? = null, - /** - * Maximum body size accepted on the NIP-86 POST endpoint, in - * bytes. Bounded *before* auth verification because we read the - * body to compute its sha256 for NIP-98's payload binding — - * unbounded reads would let an unauthenticated attacker stream - * gigabytes and OOM the relay. 1 MiB easily fits any plausible - * RPC payload. - */ - val maxAdminBodyBytes: Int = 1 shl 20, /** * Ktor CIO acceptor-thread count. `null` keeps Ktor's default. * Lift on machines with many cores when targeting 10k+ @@ -135,7 +119,13 @@ class KtorRelay( server = nip86Server, verifier = Nip98AuthVerifier(), allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() }, - maxBodyBytes = maxAdminBodyBytes, + // 1 MiB. Bounded *before* NIP-98 auth verification — we + // have to read the body to compute its sha256 for the + // payload binding, so an unbounded read would let an + // unauthenticated attacker stream gigabytes and OOM the + // relay. NIP-86 RPC payloads are a few hundred bytes; + // 1 MiB is ~1000× any plausible request. + maxBodyBytes = 1 shl 20, signedUrlFor = { call -> publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path) }, @@ -192,9 +182,7 @@ class KtorRelay( rootConfig = serverConfig { module { - install(WebSockets) { - maxFrameBytes?.let { maxFrameSize = it } - } + install(WebSockets) routing { // NIP-11: GET on the relay URL with Accept: // application/nostr+json returns the relay info doc. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 48b85079ea..297bece0a8 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -49,8 +49,8 @@ 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]` plugs into - * the relay's policy stack, and `[authorization]` seeds the runtime + * `[options]` toggles AUTH/verify/future-skew, and `[authorization]` + * seeds the runtime * [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore] on * first boot (see [com.vitorpamplona.geode.config.RuntimeConfig]). * @@ -140,18 +140,12 @@ fun main(args: Array) { parallelVerify = parallelVerify, negentropySettings = negentropySettings, ) - // Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes - // is treated as the same cap (Ktor's WebSockets plugin only exposes - // a single per-frame limit; multi-frame messages remain unbounded). - val frameLimit = - (config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong() val server = KtorRelay( relay, host = host, port = port, path = path, - maxFrameBytes = frameLimit, adminPubkeys = config.admin.pubkeys.toSet(), publicUrl = config.admin.public_url, connectionGroupSize = config.network.connection_group_size, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index b782ed7d39..9e3abee849 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -44,7 +44,6 @@ data class StaticConfig( val network: NetworkSection = NetworkSection(), val database: DatabaseSection = DatabaseSection(), val options: OptionsSection = OptionsSection(), - val limits: LimitsSection = LimitsSection(), val authorization: AuthorizationSection = AuthorizationSection(), val admin: AdminSection = AdminSection(), val negentropy: NegentropySection = NegentropySection(), @@ -160,11 +159,6 @@ data class StaticConfig( val parallel_verify: Boolean = true, ) - data class LimitsSection( - val max_ws_message_bytes: Int? = null, - val max_ws_frame_bytes: Int? = null, - ) - /** * NIP-77 negentropy tuning. Defaults track strfry * (`hoytech/strfry`) so a Geode relay accepts the same workload @@ -173,9 +167,10 @@ data class StaticConfig( * * - [frame_size_limit] mirrors strfry's hard-coded * `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`. - * Hex-encoded that's ~1 MB on the wire per NEG-MSG; ensure - * `[limits].max_ws_frame_bytes` (when set) is at least double - * this or NEG-MSGs get truncated by the WS layer. + * Hex-encoded that's ~1 MB on the wire per NEG-MSG. Ktor's + * WebSocket layer does not impose a default frame cap, so this + * payload is delivered intact unless a reverse proxy is + * misconfigured. * - [max_sync_events] mirrors strfry's * `relay__negentropy__maxSyncEvents`. NEG-OPEN whose snapshot * exceeds this returns diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index a674b9d30c..68b2b697ce 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -70,9 +70,6 @@ class StaticConfigTest { require_auth = true reject_future_seconds = 1800 - [limits] - max_ws_frame_bytes = 1048576 - [authorization] pubkey_blacklist = ["aaaa", "bbbb"] kind_blacklist = [4, 1059] @@ -95,8 +92,6 @@ class StaticConfigTest { assertEquals(true, c.options.require_auth) assertEquals(1800, c.options.reject_future_seconds) - assertEquals(1_048_576, c.limits.max_ws_frame_bytes) - assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist) assertEquals(listOf(4, 1059), c.authorization.kind_blacklist) }