feat(relay): drop max_event_bytes + rate-limit configs; verify on by default
Removes config keys + policies that don't earn their complexity:
- [limits].max_event_bytes / MaxEventBytesPolicy — duplicates
[limits].max_ws_frame_bytes which is the right layer (Ktor frame
cap at the wire), and the policy-level check ran AFTER the event
was already parsed and bound for the store.
- [limits].messages_per_sec, [limits].subscriptions_per_min /
RateLimitPolicy — per-session token buckets without the matching
per-IP / global-EPS infrastructure are mostly cosmetic; an
operator who needs real rate limits will use a reverse proxy.
Also flips [options].verify_signatures default from `false` to `true`.
A relay accepting traffic from real clients should verify Schnorr
signatures, and verify-by-default closes the footgun of forgetting
the flag. The CLI gains `--no-verify` for explicit opt-out
(test fixtures, mirror replays); the old `--verify` is dropped (a
no-op now anyway).
Removed:
- quartz-relay/.../policies/MaxEventBytesPolicy.kt
- quartz-relay/.../policies/RateLimitPolicy.kt
- 7 obsolete tests in PoliciesTest + 1 in PoliciesIntegrationTest
- The matching config fields in LimitsSection
- Sample config entries in config.example.toml
- Wiring in Main.composePolicy
Added:
- Test verifySignaturesCanBeExplicitlyDisabled covering the
new explicit-opt-out path.
Total :quartz-relay tests: 59, 0 failures.
This commit is contained in:
@@ -25,10 +25,6 @@ contact = "admin@example.com"
|
||||
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
|
||||
@@ -39,32 +35,24 @@ 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
|
||||
# Verify Schnorr signatures on every EVENT. Default: true. Disable
|
||||
# only for trusted-input scenarios (test fixtures, mirror replays).
|
||||
# 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 events whose `created_at` is more than this many seconds in
|
||||
# the future. Enforced by RejectFutureEventsPolicy.
|
||||
# reject_future_seconds = 1800
|
||||
|
||||
[limits]
|
||||
# Maximum byte size of an EVENT (canonical NIP-01 JSON form).
|
||||
# Enforced by MaxEventBytesPolicy.
|
||||
# max_event_bytes = 131072
|
||||
|
||||
# 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
|
||||
|
||||
# Per-session token-bucket caps. Enforced by RateLimitPolicy.
|
||||
# messages_per_sec = 10
|
||||
# subscriptions_per_min = 60
|
||||
|
||||
# Parsed but NOT YET ENFORCED.
|
||||
# max_subscriptions_per_session = 32
|
||||
# max_filters_per_req = 10
|
||||
|
||||
[authorization]
|
||||
# Allow / deny lists. Allow is a permissive ceiling; deny still
|
||||
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
|
||||
|
||||
@@ -29,9 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.relay.config.RelayConfig
|
||||
import com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.relay.policies.MaxEventBytesPolicy
|
||||
import com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.relay.policies.RateLimitPolicy
|
||||
import com.vitorpamplona.quartz.relay.policies.RejectFutureEventsPolicy
|
||||
import java.io.File
|
||||
|
||||
@@ -48,10 +46,10 @@ import java.io.File
|
||||
* 2. TOML file passed via `--config <path>`
|
||||
* 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]`.
|
||||
* 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]` and
|
||||
* `[authorization]` plug into the relay's policy stack.
|
||||
*
|
||||
* CLI flags:
|
||||
* --config <file> TOML config (see config.example.toml)
|
||||
@@ -61,7 +59,9 @@ import java.io.File
|
||||
* --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)
|
||||
* --no-verify DO NOT verify event signatures (off by default
|
||||
* verify is on; use only for trusted-input
|
||||
* scenarios like fixture replay).
|
||||
*/
|
||||
fun main(args: Array<String>) {
|
||||
val a = parseArgs(args)
|
||||
@@ -79,7 +79,10 @@ fun main(args: Array<String>) {
|
||||
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
|
||||
// Verify is on by default; only disable when the operator explicitly
|
||||
// opts out (CLI `--no-verify` or `[options].verify_signatures = false`
|
||||
// in the config).
|
||||
val verifySigs = !a.flag("--no-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
|
||||
@@ -98,8 +101,6 @@ fun main(args: Array<String>) {
|
||||
composePolicy(config, advertisedUrl, requireAuth, verifySigs)
|
||||
}
|
||||
|
||||
warnUnenforcedSections(config)
|
||||
|
||||
val relay = Relay(advertisedUrl, store, info, policyBuilder)
|
||||
// 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
|
||||
@@ -133,13 +134,9 @@ fun main(args: Array<String>) {
|
||||
* Builds the policy stack for one connection from the config.
|
||||
*
|
||||
* Order matters — cheap rejection paths run before expensive ones:
|
||||
* 1. Rate limit (per-session, fastest reject path)
|
||||
* 2. AUTH (drops everything if not authenticated)
|
||||
* 3. Future-timestamp + size-cap + allow/deny lists
|
||||
* 4. Signature verification (most expensive)
|
||||
*
|
||||
* The relay's `policyBuilder` factory is invoked per connection so
|
||||
* rate-limit token buckets are session-scoped (not global).
|
||||
* 1. AUTH (drops everything if not authenticated)
|
||||
* 2. Future-timestamp + allow/deny lists
|
||||
* 3. Signature verification (most expensive — Schnorr verify)
|
||||
*/
|
||||
private fun composePolicy(
|
||||
config: RelayConfig,
|
||||
@@ -149,11 +146,6 @@ private fun composePolicy(
|
||||
): IRelayPolicy {
|
||||
val pieces = mutableListOf<IRelayPolicy>()
|
||||
|
||||
val l = config.limits
|
||||
if (l.messages_per_sec != null || l.subscriptions_per_min != null) {
|
||||
pieces += RateLimitPolicy(l.messages_per_sec, l.subscriptions_per_min)
|
||||
}
|
||||
|
||||
if (requireAuth) {
|
||||
pieces += FullAuthPolicy(advertisedUrl)
|
||||
}
|
||||
@@ -162,10 +154,6 @@ private fun composePolicy(
|
||||
pieces += RejectFutureEventsPolicy(secs)
|
||||
}
|
||||
|
||||
l.max_event_bytes?.let { bytes ->
|
||||
pieces += MaxEventBytesPolicy(bytes)
|
||||
}
|
||||
|
||||
val auth = config.authorization
|
||||
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
|
||||
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
|
||||
@@ -183,25 +171,6 @@ private fun composePolicy(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a warning for config sections we still don't enforce. As we
|
||||
* add policies the matching branch is removed here.
|
||||
*/
|
||||
private fun warnUnenforcedSections(config: RelayConfig) {
|
||||
val warnings = mutableListOf<String>()
|
||||
val l = config.limits
|
||||
if (l.max_subscriptions_per_session != null) {
|
||||
warnings += "[limits].max_subscriptions_per_session is parsed but NOT YET ENFORCED."
|
||||
}
|
||||
if (l.max_filters_per_req != null) {
|
||||
warnings += "[limits].max_filters_per_req 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 val opts: Map<String, String>,
|
||||
private val flags: Set<String>,
|
||||
|
||||
@@ -34,19 +34,13 @@ import java.io.File
|
||||
*
|
||||
* 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(),
|
||||
) {
|
||||
/**
|
||||
@@ -103,12 +97,6 @@ data class RelayConfig(
|
||||
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(
|
||||
@@ -123,18 +111,19 @@ data class RelayConfig(
|
||||
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,
|
||||
/**
|
||||
* Drop events whose Schnorr signature does not verify. **Defaults
|
||||
* to `true`**: any relay accepting traffic from real clients
|
||||
* should verify signatures, and verifying-by-default closes the
|
||||
* footgun of forgetting the flag. Set explicitly to `false` only
|
||||
* for trusted-input scenarios (test fixtures, mirror replays).
|
||||
*/
|
||||
val verify_signatures: Boolean = true,
|
||||
)
|
||||
|
||||
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(
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* 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.policies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
|
||||
/**
|
||||
* Rejects events whose canonical JSON byte size exceeds [maxBytes].
|
||||
* Mirrors nostr-rs-relay's `[limits].max_event_bytes`.
|
||||
*
|
||||
* Note: this measures the size of the SERVER-side re-serialised event
|
||||
* (via [OptimizedJsonMapper.toJson]), which is byte-equivalent to the
|
||||
* canonical NIP-01 form a well-behaved client would have sent. It does
|
||||
* NOT enforce `[limits].max_ws_message_bytes` — that one belongs at the
|
||||
* WebSocket frame layer (Ktor `WebSockets { maxFrameSize = ... }` for
|
||||
* [com.vitorpamplona.quartz.relay.LocalRelayServer]) because the policy
|
||||
* layer never sees the raw frame. Both limits are enforced together
|
||||
* when the operator sets them in the config.
|
||||
*/
|
||||
class MaxEventBytesPolicy(
|
||||
val maxBytes: Int,
|
||||
) : PassThroughPolicy() {
|
||||
init {
|
||||
require(maxBytes > 0) { "maxBytes must be > 0, got $maxBytes" }
|
||||
}
|
||||
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||
val size = OptimizedJsonMapper.toJson(cmd.event).length
|
||||
return if (size > maxBytes) {
|
||||
PolicyResult.Rejected("invalid: event size $size exceeds limit of $maxBytes bytes")
|
||||
} else {
|
||||
PolicyResult.Accepted(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* 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.policies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
|
||||
/**
|
||||
* Per-session token-bucket rate limiter. Mirrors nostr-rs-relay's
|
||||
* `[limits].messages_per_sec` and `[limits].subscriptions_per_min`.
|
||||
*
|
||||
* - [messagesPerSec] caps EVERY incoming command (EVENT/REQ/COUNT/AUTH)
|
||||
* over a 1-second window. `null` disables.
|
||||
* - [subscriptionsPerMin] caps REQ + COUNT (subscriptions opened) over
|
||||
* a 60-second window. `null` disables.
|
||||
*
|
||||
* Each session gets its own buckets — instances of this policy must be
|
||||
* created per-connection via the relay's `policyBuilder` factory.
|
||||
*
|
||||
* Time source defaults to monotonic [System.nanoTime] so wall-clock
|
||||
* jumps don't reset the buckets. Tests inject a deterministic clock.
|
||||
*/
|
||||
class RateLimitPolicy(
|
||||
val messagesPerSec: Int? = null,
|
||||
val subscriptionsPerMin: Int? = null,
|
||||
private val nowNanos: () -> Long = System::nanoTime,
|
||||
) : PassThroughPolicy() {
|
||||
private val msgBucket =
|
||||
messagesPerSec?.let {
|
||||
require(it > 0) { "messagesPerSec must be > 0" }
|
||||
TokenBucket(capacity = it, refillIntervalNanos = 1_000_000_000L / it, nowNanos)
|
||||
}
|
||||
|
||||
private val subBucket =
|
||||
subscriptionsPerMin?.let {
|
||||
require(it > 0) { "subscriptionsPerMin must be > 0" }
|
||||
TokenBucket(capacity = it, refillIntervalNanos = 60_000_000_000L / it, nowNanos)
|
||||
}
|
||||
|
||||
private fun checkMsgBucket(): String? = if (msgBucket?.tryTake() == false) "blocked: too many messages per second" else null
|
||||
|
||||
private fun checkSubBucket(): String? = if (subBucket?.tryTake() == false) "blocked: too many subscriptions per minute" else null
|
||||
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||
checkMsgBucket()?.let { return PolicyResult.Rejected(it) }
|
||||
return PolicyResult.Accepted(cmd)
|
||||
}
|
||||
|
||||
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> {
|
||||
checkMsgBucket()?.let { return PolicyResult.Rejected(it) }
|
||||
checkSubBucket()?.let { return PolicyResult.Rejected(it) }
|
||||
return PolicyResult.Accepted(cmd)
|
||||
}
|
||||
|
||||
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> {
|
||||
checkMsgBucket()?.let { return PolicyResult.Rejected(it) }
|
||||
checkSubBucket()?.let { return PolicyResult.Rejected(it) }
|
||||
return PolicyResult.Accepted(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token bucket with monotonic refill. Single-threaded by contract —
|
||||
* RelaySession.receive runs serially within a session, so no locking
|
||||
* is needed.
|
||||
*/
|
||||
private class TokenBucket(
|
||||
val capacity: Int,
|
||||
val refillIntervalNanos: Long,
|
||||
val now: () -> Long,
|
||||
) {
|
||||
private var tokens: Long = capacity.toLong()
|
||||
private var lastRefill: Long = now()
|
||||
|
||||
fun tryTake(): Boolean {
|
||||
refill()
|
||||
return if (tokens > 0) {
|
||||
tokens -= 1
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun refill() {
|
||||
val n = now()
|
||||
val elapsed = n - lastRefill
|
||||
if (elapsed <= 0) return
|
||||
val newTokens = elapsed / refillIntervalNanos
|
||||
if (newTokens > 0) {
|
||||
tokens = (tokens + newTokens).coerceAtMost(capacity.toLong())
|
||||
lastRefill += newTokens * refillIntervalNanos
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-9
@@ -35,10 +35,17 @@ class RelayConfigTest {
|
||||
assertEquals("/", c.network.path)
|
||||
assertEquals(true, c.database.in_memory)
|
||||
assertEquals(false, c.options.require_auth)
|
||||
assertEquals(false, c.options.verify_signatures)
|
||||
// Verify is on by default — operators have to opt out explicitly.
|
||||
assertEquals(true, c.options.verify_signatures)
|
||||
assertTrue(c.authorization.pubkey_whitelist.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifySignaturesCanBeExplicitlyDisabled() {
|
||||
val c = RelayConfig.fromToml("[options]\nverify_signatures = false")
|
||||
assertEquals(false, c.options.verify_signatures)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesAllSectionsTogether() {
|
||||
val toml =
|
||||
@@ -53,7 +60,6 @@ class RelayConfigTest {
|
||||
host = "127.0.0.1"
|
||||
port = 9988
|
||||
path = "/relay"
|
||||
remote_ip_header = "X-Forwarded-For"
|
||||
|
||||
[database]
|
||||
in_memory = false
|
||||
@@ -65,9 +71,7 @@ class RelayConfigTest {
|
||||
reject_future_seconds = 1800
|
||||
|
||||
[limits]
|
||||
max_event_bytes = 131072
|
||||
messages_per_sec = 10
|
||||
max_filters_per_req = 12
|
||||
max_ws_frame_bytes = 1048576
|
||||
|
||||
[authorization]
|
||||
pubkey_blacklist = ["aaaa", "bbbb"]
|
||||
@@ -83,7 +87,6 @@ class RelayConfigTest {
|
||||
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)
|
||||
@@ -92,9 +95,7 @@ class RelayConfigTest {
|
||||
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(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)
|
||||
|
||||
-16
@@ -121,20 +121,4 @@ class PoliciesIntegrationTest {
|
||||
hub.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maxEventBytesBlocksOversizeOverWire() =
|
||||
runBlocking {
|
||||
val (client, hub) = hubWith { MaxEventBytesPolicy(maxBytes = 400) }
|
||||
try {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val small = signer.sign(TextNoteEvent.build("hi"))
|
||||
assertEquals(true, client.publishAndConfirm(small, setOf(relayUrl)))
|
||||
val huge = signer.sign(TextNoteEvent.build("x".repeat(2_000)))
|
||||
assertEquals(false, client.publishAndConfirm(huge, setOf(relayUrl)))
|
||||
} finally {
|
||||
client.disconnect()
|
||||
hub.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.relay.policies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
@@ -148,82 +143,6 @@ class PoliciesTest {
|
||||
assertRejected(p.accept(EventCmd(event(createdAt = now + 1))))
|
||||
}
|
||||
|
||||
// -- MaxEventBytesPolicy ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun maxBytesAllowsSmallEvents() {
|
||||
val small = event(content = "a")
|
||||
val limit = OptimizedJsonMapper.toJson(small).length + 100
|
||||
val p = MaxEventBytesPolicy(maxBytes = limit)
|
||||
assertAccepted(p.accept(EventCmd(small)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maxBytesRejectsOversizedEvents() {
|
||||
val big = event(content = "x".repeat(2_000))
|
||||
val p = MaxEventBytesPolicy(maxBytes = 500)
|
||||
assertRejected(p.accept(EventCmd(big)), reasonContains = "exceeds limit")
|
||||
}
|
||||
|
||||
// -- RateLimitPolicy ----------------------------------------------------
|
||||
|
||||
/** Helper that makes a clock we can advance in nanoseconds. */
|
||||
private class FakeClock {
|
||||
var nanos = 0L
|
||||
|
||||
fun read(): Long = nanos
|
||||
|
||||
fun advanceMillis(ms: Long) {
|
||||
nanos += ms * 1_000_000L
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitMessagesPerSecond() {
|
||||
val clock = FakeClock()
|
||||
val p = RateLimitPolicy(messagesPerSec = 3, nowNanos = clock::read)
|
||||
|
||||
// First three pass within the same instant.
|
||||
repeat(3) { assertAccepted(p.accept(EventCmd(event()))) }
|
||||
// Fourth is rate-limited.
|
||||
assertRejected(p.accept(EventCmd(event())), reasonContains = "messages per second")
|
||||
|
||||
// After enough wall-time the bucket refills.
|
||||
clock.advanceMillis(400) // 1s/3 = 333ms per token; 400ms gives at least 1 token
|
||||
assertAccepted(p.accept(EventCmd(event())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitSubscriptionsPerMinute() {
|
||||
val clock = FakeClock()
|
||||
val p = RateLimitPolicy(subscriptionsPerMin = 2, nowNanos = clock::read)
|
||||
val req = ReqCmd("sub-1", listOf(Filter()))
|
||||
|
||||
assertAccepted(p.accept(req))
|
||||
assertAccepted(p.accept(req))
|
||||
assertRejected(p.accept(req), reasonContains = "subscriptions per minute")
|
||||
|
||||
// Refill after 30s for 2/min -> 1 token.
|
||||
clock.advanceMillis(31_000)
|
||||
assertAccepted(p.accept(req))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitCountAlsoCountsAsSubscription() {
|
||||
val clock = FakeClock()
|
||||
val p = RateLimitPolicy(subscriptionsPerMin = 1, nowNanos = clock::read)
|
||||
val cnt = CountCmd("q1", listOf(Filter()))
|
||||
assertAccepted(p.accept(cnt))
|
||||
assertRejected(p.accept(cnt), reasonContains = "subscriptions per minute")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitDisabledWhenBothLimitsAreNull() {
|
||||
val p = RateLimitPolicy()
|
||||
repeat(1000) { assertAccepted(p.accept(EventCmd(event()))) }
|
||||
repeat(1000) { assertAccepted(p.accept(ReqCmd("s", listOf(Filter())))) }
|
||||
}
|
||||
|
||||
// -- Stack composition --------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -250,15 +169,4 @@ class PoliciesTest {
|
||||
reasonContains = "not allowed",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitConstructorRejectsInvalidValues() {
|
||||
var threw = false
|
||||
try {
|
||||
RateLimitPolicy(messagesPerSec = 0)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
threw = true
|
||||
}
|
||||
assertEquals(true, threw)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user