@@ -29,10 +29,16 @@ federationScheme = "https://" -- scheme for federation HTTP calls
2929-- slow peer from consuming all concurrency slots, though truly hung connections still hold
3030-- one slot until the forked task terminates.
3131-- Message and data size limits
32−-- TODO: If Knot adds HTTP-layer configuration, enforce a request body size limit
33−-- (e.g., 256KB) to prevent large-payload DoS. Currently field-level length checks
34−-- (maxBlobLen, maxPubkeyLen, etc.) validate after parsing, so a multi-GB JSON body
35−-- would be fully parsed before rejection. A framework-level limit would reject early.
32+-- IMPORTANT: Knot has no HTTP-layer request body size limit. Field-level length checks
33+-- (maxBlobLen, maxPubkeyLen, etc.) validate after JSON parsing, so a multi-GB body is
34+-- fully parsed before rejection. Deploy behind a reverse proxy with a body size limit:
35+-- Caddy: request_body { max_size 256KB }
36+-- nginx: client_max_body_size 256k;
37+-- HAProxy: tune.maxrewrite 256k
38+-- Without this, the server is vulnerable to large-payload DoS attacks. The maxBlobLen
39+-- limit (64KB hex = 32KB binary) bounds legitimate message payloads; snapshots and
40+-- batch requests may be larger but are authenticated via maintenance secret.
41+-- TODO: If Knot adds HTTP-layer configuration, enforce a request body size limit natively.
3642messageMaxAge : Int<Ms>
3743messageMaxAge = 2592000000 -- 30 days
3844maxPresenceTtl : Int<S>
@@ -102,6 +108,7 @@ federationPairRateWindowMs : Int<Ms>
102108federationPairRateWindowMs = 60000 -- 1 minute
103109maxFederationPairRateEntries = 5000
104110-- Global request rate limiting
111+maxActiveRequests = 500 -- max concurrent requests before rejecting with overloaded (0 = disable)
105112maxGlobalRequestRate = 1000 -- max total requests per window
106113globalRateWindowMs : Int<Ms>
107114globalRateWindowMs = 1000
@@ -226,6 +233,12 @@ autoSnapshotInterval : Int<Ms>
226233autoSnapshotInterval = 300000 -- 5 minutes; periodic state summary to stdout (0 to disable)
227234-- Full snapshot auto-export interval: emit [SNAPSHOT_DATA] JSON to stdout for sidecar capture.
228235-- 0 to disable. When enabled, a crash loses at most this interval of federation state.
236+-- SECURITY: auto-exported snapshots contain unencrypted federation state (presence, acked
237+-- message IDs, peer failure records, forward nonces). The data does not include message
238+-- content (E2E encrypted blobs are ephemeral) but does reveal metadata: which pubkeys are
239+-- online, which servers they federate with, and delivery timing. Ensure the log pipeline
240+-- is access-controlled and encrypted at rest. Set to 0 in high-security deployments
241+-- and use manual GET /maintenance/snapshot with authenticated access instead.
229242-- Only emits when there's meaningful state to persist (presence/forwards/ackedIds > 0).
230243autoSnapshotExportInterval : Int<Ms>
231244autoSnapshotExportInterval = 3600000 -- 1 hour; full state export to stdout (0 to disable)
@@ -597,6 +610,11 @@ type SenderDelivery = {senderKey: Text, messageId: MessageIdHex, toKey: PubkeyHe
597610-- lastPresenceRefresh), so skipped/delayed prune cycles don't cause bucket gaps.
598611*presenceRefreshGroup : Int
599612
613+-- Round-robin nonce pruning: tracks which bucket group (0-3) to scan next.
614+-- Each prune cycle scans 4 of 16 nonce buckets, cycling through all 16 over 4 cycles.
615+-- Nonces have 60-day retention so 30-second pruning granularity is unnecessary.
616+*noncePruneGroup : Int
617+
600618*challengeRates : [ChallengeRateEntry]
601619
602620-- Separate revoke rate limiter (prevents challenge floods from exhausting revoke budget)
@@ -1176,6 +1194,15 @@ warnOldProtocol = \fromServer pv ->
11761194 when (pv > 0 && pv < federationProtocolVersion)
11771195 (logWarn ("federation: peer " ++ fromServer ++ " using protocol v" ++ show pv ++ " (current: v" ++ show federationProtocolVersion ++ ")"))
11781196
1197+-- Log per-server detail for stale in-flight entries (hung connection debugging).
1198+-- Emits one structured event per server with slot count and duration.
1199+logStaleInFlight = \entries t ->
1200+ case headOf entries of
1201+ Nothing {} -> yield {}
1202+ Just {value: r} -> do
1203+ logEvent "error" "stale_inflight" (r.server ++ " " ++ show r.count ++ " slots hung for " ++ show (t - r.firstStartedAt) ++ "ms")
1204+ logStaleInFlight (do e <- entries; where e.server != r.server; yield e) t
1205+
11791206-- Fast path: check if a string contains only safe JSON characters (hex digits,
11801207-- alphanumerics, common punctuation). If so, no escaping is needed.
11811208-- Returns False on the first unsafe character. Tail-recursive (TCO-safe in Knot).
@@ -1494,8 +1521,16 @@ isBadServerName = \sn ->
14941521 || containsDoubleEncoding sn
14951522 -- Kubernetes / service mesh internal DNS
14961523 || containsSuffix ".svc" sn
1524+ || containsSuffix ".cluster.local" sn
14971525 -- HashiCorp Consul service discovery
14981526 || containsSuffix ".consul" sn
1527+ || containsSuffix ".node.consul" sn
1528+ || containsSuffix ".service.consul" sn
1529+ -- Cloud provider internal DNS (SSRF targets for metadata/internal services)
1530+ || containsSuffix ".compute.internal" sn -- GCP internal DNS
1531+ || containsSuffix ".ec2.internal" sn -- AWS EC2 internal DNS
1532+ || containsSuffix ".amazonaws.com" sn -- AWS service endpoints (S3, SQS, etc.)
1533+ || matchesHostname sn "metadata.google.internal" -- GCP metadata server
14991534 -- NOTE: Unicode/IDN defense is provided by containsBadUrlChar above, which rejects
15001535 -- any character not in [0-9a-z-.:] via isAllowedServerChar. This blocks homoglyph
15011536 -- attacks (e.g., Greek omicron in "lοcalhost") assuming Knot's Text type preserves
@@ -2476,7 +2511,7 @@ writeAllNonces = \nonces -> do
24762511 writeNonceBucket 14 partitioned.b14
24772512 writeNonceBucket 15 partitioned.b15
24782513
2479−-- Prune nonces across all 16 buckets
2514+-- Prune nonces across all 16 buckets (used by flush and restore)
24802515pruneAllNonceBuckets : Int -> Timestamp -> IO ()
24812516pruneAllNonceBuckets = \b t ->
24822517 if b > 15 then yield {}
@@ -2485,6 +2520,25 @@ pruneAllNonceBuckets = \b t ->
24852520 writeNonceBucket b (do n <- bucket; where t - n.seenAt < forwardNonceRetention; yield n)
24862521 pruneAllNonceBuckets (b + 1) t
24872522
2523+-- Round-robin nonce pruning: scan 4 of 16 buckets per call.
2524+-- With 60-day retention and 30-second prune intervals, full coverage every ~2 minutes
2525+-- is sufficient. Reduces prune cycle cost by 75% vs scanning all 16 buckets.
2526+pruneNonceBucketsRoundRobin : Timestamp -> IO ()
2527+pruneNonceBucketsRoundRobin = \t -> do
2528+ group <- *noncePruneGroup
2529+ let startBucket = (group * 4) - ((group * 4) / 16) * 16
2530+ pruneNonceBucketRange startBucket (startBucket + 3) t
2531+ set *noncePruneGroup = if group >= 3 then 0 else group + 1
2532+
2533+pruneNonceBucketRange : Int -> Int -> Timestamp -> IO ()
2534+pruneNonceBucketRange = \b endB t ->
2535+ if b > endB || b > 15 then yield {}
2536+ else do
2537+ bucket <- readNonceBucket b
2538+ let pruned = do n <- bucket; where t - n.seenAt < forwardNonceRetention; yield n
2539+ when (count pruned < count bucket) (writeNonceBucket b pruned)
2540+ pruneNonceBucketRange (b + 1) endB t
2541+
24882542-- ============================================
24892543-- Forward Bucket Helpers
24902544-- ============================================
@@ -3262,10 +3316,16 @@ pruneExpired = \t -> do
32623316 pruneAllAckedBuckets 0 retainCutoff
32633317 -- DeliveredIds: per-bucket pruning (same pattern as acked)
32643318 pruneAllDeliveredBuckets 0 retainCutoff
3265− -- Overflow + failures: smaller collections, grouped together
3319+ -- Overflow + failures: smaller collections, grouped together.
3320+ -- Stale peer cleanup: servers still in knownPeerServers (have active presence) keep
3321+ -- their failure records for the full maxServerDowntime (24h). Servers with no presence
3322+ -- are pruned aggressively after 1h — they have no local keys to forward to and their
3323+ -- failure records just inflate circuit breaker counts.
32663324 atomic do
32673325 forwardFailures <- *forwardFailures
3268− set *forwardFailures = do f <- forwardFailures; where t - f.failedAt < maxServerDowntime; yield f
3326+ peers <- *knownPeerServers
3327+ let isKnown = \server -> any peers (\p -> p == server)
3328+ set *forwardFailures = do f <- forwardFailures; where (if isKnown f.server then t - f.failedAt < maxServerDowntime else t - f.failedAt < sessionExpiry); yield f
32693329 ackOverflow <- *ackOverflow
32703330 set *ackOverflow = do a <- ackOverflow; where a.ackedAt > retainCutoff; yield a
32713331 -- Prune rate limiters (split into smaller atomic blocks to reduce lock contention)
@@ -3319,8 +3379,15 @@ pruneExpired = \t -> do
33193379 inFlight <- *federationInFlight
33203380 let staleThreshold = federationFetchTimeoutMs * 5 / 4
33213381 let staleInFlight = do r <- inFlight; where r.count > 0 && r.firstStartedAt > 0 && t - r.firstStartedAt >= staleThreshold; yield r
3322− when (count staleInFlight > 0)
3323− (logWarn ("pruneExpired: cleaning " ++ show (count staleInFlight) ++ " stale in-flight entries (leaked slots)"))
3382+ when (count staleInFlight > 0) (do
3383+ logWarn ("pruneExpired: cleaning " ++ show (count staleInFlight) ++ " stale in-flight entries (leaked slots)")
3384+ -- Per-server detail for debugging hung connections: log which peers are stuck and for how long
3385+ logStaleInFlight staleInFlight t)
3386+ -- Also warn about connections approaching the stale threshold (early detection)
3387+ let hungThreshold = federationFetchTimeoutMs
3388+ let hungInFlight = do r <- inFlight; where r.count > 0 && r.firstStartedAt > 0 && t - r.firstStartedAt >= hungThreshold && t - r.firstStartedAt < staleThreshold; yield r
3389+ when (count hungInFlight > 0)
3390+ (logEvent "warn" "hung_federation" ("connections approaching stale threshold: " ++ show (count hungInFlight) ++ " slots"))
33243391 set *federationInFlight = do r <- inFlight; where r.count > 0 && (r.firstStartedAt == 0 || t - r.firstStartedAt < staleThreshold); yield r
33253392 -- Prune stale federation replay dedup entries
33263393 recentFedReqs <- *recentFederationRequests
@@ -3328,8 +3395,8 @@ pruneExpired = \t -> do
33283395 -- Prune expired federation verification tokens
33293396 pending <- *pendingVerifications
33303397 set *pendingVerifications = do p <- pending; where t - p.createdAt < verificationTokenExpiry; yield p
3331− -- Prune expired forward nonces per bucket (extended replay protection)
3332− pruneAllNonceBuckets 0 t
3398+ -- Prune expired forward nonces (round-robin: 4 of 16 buckets per cycle)
3399+ pruneNonceBucketsRoundRobin t
33333400 -- Prune gossip event rate entries
33343401 gossipEventRates <- *gossipEventRates
33353402 set *gossipEventRates = do r <- gossipEventRates; where t - r.windowStart < gossipEventRateWindowMs; yield r
@@ -3430,46 +3497,73 @@ mergeGossipPresence = \presence deduped trustedOffline fromServer t ->
34303497 withinCapacity: capResult.withinCapacity}
34313498
34323499
3433−-- Effective rate limit readers: return runtime override if set (> 0), else compile-time default
3500+-- Adaptive rate limiting: scale rate limits down when the server is under heavy load.
3501+-- Returns a scaling factor from 0 to 100 (percentage of full rate to allow).
3502+-- 100% at <=80% utilization, linearly decreasing to 10% at 100% utilization.
3503+-- Uses the highest utilization across messages, sessions, and presence as the
3504+-- bottleneck metric. 10% floor prevents complete lockout (admin can still act).
3505+adaptiveRateScale : Int -> Int -> Int -> Int
3506+adaptiveRateScale = \msgCount sessCount presCount ->
3507+ let msgPct = if maxMessages > 0 then msgCount * 100 / maxMessages else 0 in
3508+ let sessPct = if maxSessions > 0 then sessCount * 100 / maxSessions else 0 in
3509+ let presPct = if maxPresenceEntries > 0 then presCount * 100 / maxPresenceEntries else 0 in
3510+ let peak = max msgPct (max sessPct presPct) in
3511+ if peak <= 80 then 100
3512+ else max 10 (100 - (peak - 80) * 90 / 20)
3513+
3514+-- Apply adaptive scaling to a base rate limit. Reads cached counters (O(1), no bucket scans)
3515+-- and scales the rate proportionally. Returns at least 1 to prevent division-by-zero in callers.
3516+adaptiveRate : Int -> IO Int
3517+adaptiveRate = \baseRate -> do
3518+ tmc <- *totalMessageCount
3519+ sc <- *totalSessionCount
3520+ pc <- *totalPresenceCount
3521+ let scale = adaptiveRateScale tmc sc pc
3522+ yield (max 1 (baseRate * scale / 100))
3523+
3524+-- Effective rate limit readers: return runtime override if set (> 0), else compile-time default.
3525+-- All per-key and global rates are scaled by adaptiveRate under high utilization.
34343526effectiveGlobalRequestRate = do
34353527 o <- *rateOverrideGlobalRequest
3436− yield (if o > 0 then o else maxGlobalRequestRate)
3528+ adaptiveRate (if o > 0 then o else maxGlobalRequestRate)
34373529
34383530effectiveIpRate = do
34393531 o <- *rateOverrideIpRate
3440− yield (if o > 0 then o else maxIpRatePerIp)
3532+ adaptiveRate (if o > 0 then o else maxIpRatePerIp)
34413533
34423534effectiveSendRate = do
34433535 o <- *rateOverrideSendRate
3444− yield (if o > 0 then o else maxSendRatePerKey)
3536+ adaptiveRate (if o > 0 then o else maxSendRatePerKey)
34453537
34463538effectiveFederationRate = do
34473539 o <- *rateOverrideFederationRate
3540+ -- Federation rates are NOT adaptively scaled: throttling federation under load
3541+ -- prevents ack delivery, causing origin servers to retry (amplification loop).
34483542 yield (if o > 0 then o else maxFederationRatePerServer)
34493543
34503544effectiveAuthGlobalRate = do
34513545 o <- *rateOverrideAuthGlobal
3452− yield (if o > 0 then o else maxAuthGlobalRate)
3546+ adaptiveRate (if o > 0 then o else maxAuthGlobalRate)
34533547
34543548effectiveRecvRate = do
34553549 o <- *rateOverrideRecvRate
3456− yield (if o > 0 then o else maxRecvRatePerKey)
3550+ adaptiveRate (if o > 0 then o else maxRecvRatePerKey)
34573551
34583552effectiveChallengeRate = do
34593553 o <- *rateOverrideChallengeRate
3460− yield (if o > 0 then o else maxChallengeRatePerKey)
3554+ adaptiveRate (if o > 0 then o else maxChallengeRatePerKey)
34613555
34623556effectivePairRate = do
34633557 o <- *rateOverridePairRate
3464− yield (if o > 0 then o else maxPairRatePerPair)
3558+ adaptiveRate (if o > 0 then o else maxPairRatePerPair)
34653559
34663560effectiveLookupRate = do
34673561 o <- *rateOverrideLookupRate
3468− yield (if o > 0 then o else maxLookupRatePerKey)
3562+ adaptiveRate (if o > 0 then o else maxLookupRatePerKey)
34693563
34703564effectiveClientWriteRate = do
34713565 o <- *rateOverrideClientWriteRate
3472− yield (if o > 0 then o else maxClientWriteRatePerKey)
3566+ adaptiveRate (if o > 0 then o else maxClientWriteRatePerKey)
34733567
34743568checkFederationRate = \server t -> atomic do
34753569 federationRates <- *federationRates
@@ -4375,11 +4469,12 @@ ackPolledMessages = \pubkey cursor t -> do
43754469
43764470pollNewEvents = \pubkey clampedCursor t -> do
43774471 bucket <- readMessageBucket (pubkeyBucket pubkey)
4378− -- NOTE: sortBy is technically redundant since receivedAt is monotonically increasing
4379− -- (via nextSeqTs) and union appends new messages at the end. Kept as a defensive
4380− -- measure in case bucket ordering assumptions are ever violated (e.g., by a future
4381− -- bulk-import feature or runtime changes to list representation).
4382− let allPollable = sortBy (\m -> m.receivedAt) (pollableMessages bucket pubkey clampedCursor t)
4472+ -- Priority-aware delivery: high-priority messages surface first within the maxPollEvents cap.
4473+ -- Sort key: (-priority * a large constant + receivedAt) ensures priority dominates while
4474+ -- preserving receivedAt order within the same priority level. The constant must exceed the
4475+ -- maximum possible receivedAt spread within one poll batch (bounded by messageMaxAge).
4476+ -- Priority 0 (default) sorts after all higher priorities.
4477+ let allPollable = sortBy (\m -> (0 - m.priority) * 10000000000000 + m.receivedAt) (pollableMessages bucket pubkey clampedCursor t)
43834478 let limitedMsgs = takeRelation maxPollEvents allPollable
43844479 let evts = do
43854480 m <- limitedMsgs
@@ -5895,6 +5990,7 @@ handleDrain = \phase authorization respond -> do
58955990 authorized <- verifyMaintenanceAuth authorization
58965991 if not authorized then yield (respond {ok: False {}, drained: 0, phase: 0})
58975992 else do
5993+ logEvent "audit" "drain" ("phase=" ++ show phase)
58985994 -- Effective phase: 0 or >=2 means full drain (backward compat for clients sending empty body)
58995995 let effectivePhase = if phase == 1 then 1 else 2
59005996 -- Idempotency: if already at or above requested phase, return success.
@@ -5956,6 +6052,7 @@ handleDrain = \phase authorization respond -> do
59566052 println "[SNAPSHOT_END]"
59576053 atomic do set *prunesSinceSnapshot = 0
59586054 logInfo ("drain: auto-snapshot exported (" ++ show (count drainResult.localEntries) ++ " presence, " ++ show (count flushState.forwards) ++ " forwards)")
6055+ logEvent "warn" "snapshot_unencrypted" "drain snapshot exported to stdout — ensure log pipeline is secured"
59596056 -- Fork best-effort flush of pending federation ops, then gossip, then wait for in-flight.
59606057 -- Operator polls /stats for drainCompletedAt > 0 to know when safe to stop.
59616058 atomic do set *drainCompletedAt = 0
@@ -5999,6 +6096,7 @@ handleUndrain = \authorization respond -> do
59996096 authorized <- verifyMaintenanceAuth authorization
60006097 if not authorized then yield (respond {ok: False {}})
60016098 else do
6099+ logEvent "audit" "undrain" ""
60026100 wasDraining <- atomic do
60036101 d <- *drainPhase
60046102 set *drainPhase = 0
@@ -6020,6 +6118,7 @@ handleShutdown = \authorization respond -> do
60206118 authorized <- verifyMaintenanceAuth authorization
60216119 if not authorized then yield (respond {ok: False {}, drained: 0})
60226120 else do
6121+ logEvent "audit" "shutdown" ""
60236122 dp <- *drainPhase
60246123 if dp >= 2 then do
60256124 -- Already draining, just set the shutdown flag
@@ -6048,6 +6147,7 @@ handleReconcile = \authorization respond -> do
60486147 authorized <- verifyMaintenanceAuth authorization
60496148 if not authorized then yield (respond {ok: False {}, messageDrift: 0, presenceDrift: 0, sessionDrift: 0})
60506149 else do
6150+ logEvent "audit" "reconcile" ""
60516151 presDrift <- atomic do
60526152 actual <- countAllPresence
60536153 stored <- *totalPresenceCount
@@ -6405,6 +6505,7 @@ handleRestore = \version checksumIn presenceData ackedIdsData ackOverflowData de
64056505 authorized <- verifyMaintenanceAuth authorization
64066506 if not authorized then yield (respond {ok: False {}, error: "unauthorized", presenceCount: 0, ackedIdsCount: 0, deliveredIdsCount: 0})
64076507 else do
6508+ logEvent "audit" "restore" ("version=" ++ show version ++ " presence=" ++ show (count presenceData) ++ " forwards=" ++ show (count forwardsData))
64086509 -- Minimum version: v6 (first version with checksum integrity verification).
64096510 -- Pre-v6 snapshots (0-5) have no checksum and could be corrupted without detection.
64106511 -- Versions 6-10 are accepted with backward compatibility (missing fields default to []/0).
@@ -6861,6 +6962,7 @@ handleBlockPeer = \server authorization respond -> do
68616962 authorized <- verifyMaintenanceAuth authorization
68626963 if not authorized then yield (respond {ok: False {}, blocked: 0})
68636964 else do
6965+ logEvent "audit" "block_peer" server
68646966 result <- atomic do
68656967 blocked <- *blockedPeers
68666968 if isBlockedPeer server blocked then yield {alreadyBlocked: True {}, total: count blocked}
@@ -6881,6 +6983,7 @@ handleUnblockPeer = \server authorization respond -> do
68816983 authorized <- verifyMaintenanceAuth authorization
68826984 if not authorized then yield (respond {ok: False {}, unblocked: False {}})
68836985 else do
6986+ logEvent "audit" "unblock_peer" server
68846987 removed <- atomic do
68856988 blocked <- *blockedPeers
68866989 let had = isBlockedPeer server blocked
@@ -6904,6 +7007,7 @@ handleAllowPeer = \server authorization respond -> do
69047007 authorized <- verifyMaintenanceAuth authorization
69057008 if not authorized then yield (respond {ok: False {}, allowed: 0})
69067009 else do
7010+ logEvent "audit" "allow_peer" server
69077011 result <- atomic do
69087012 allowed <- *allowedPeers
69097013 if any allowed (\a -> a == server) then yield {alreadyAllowed: True {}, total: count allowed}
@@ -6924,6 +7028,7 @@ handleUnallowPeer = \server authorization respond -> do
69247028 authorized <- verifyMaintenanceAuth authorization
69257029 if not authorized then yield (respond {ok: False {}, removed: False {}})
69267030 else do
7031+ logEvent "audit" "unallow_peer" server
69277032 removed <- atomic do
69287033 allowed <- *allowedPeers
69297034 let had = any allowed (\a -> a == server)
@@ -6985,6 +7090,7 @@ handleAdminRevokeSessions = \pubkey authorization respond -> do
69857090 authorized <- verifyMaintenanceAuth authorization
69867091 if not authorized then yield (respond {ok: False {}, revokedCount: 0})
69877092 else do
7093+ logEvent "audit" "revoke_all_sessions" pubkey
69887094 t <- now
69897095 result <- atomic do
69907096 -- Filter each session bucket in-place (avoids readAllSessions + writeAllSessions repartition)
@@ -7016,6 +7122,7 @@ handleAdminPurgeMessages = \pubkey authorization respond -> do
70167122 authorized <- verifyMaintenanceAuth authorization
70177123 if not authorized then yield (respond {ok: False {}, purgedCount: 0})
70187124 else do
7125+ logEvent "audit" "purge_messages" pubkey
70197126 t <- now
70207127 purgeResult <- atomic do
70217128 let b = pubkeyBucket pubkey
@@ -7063,8 +7170,10 @@ handleAdminPurgeMessages = \pubkey authorization respond -> do
70637170handleAdminRevokeIp = \ip authorization respond -> do
70647171 authorized <- verifyMaintenanceAuth authorization
70657172 if not authorized then yield (respond {ok: False {}, revokedCount: 0, affectedKeys: 0})
7066− else if ip == "" then yield (respond {ok: False {}, revokedCount: 0, affectedKeys: 0})
70677173 else do
7174+ logEvent "audit" "revoke_ip" ip
7175+ if ip == "" then yield (respond {ok: False {}, revokedCount: 0, affectedKeys: 0})
7176+ else do
70687177 t <- now
70697178 result <- revokeSessionsByIp ip 0 t {revokedCount: 0, affectedKeys: [], cleanups: []}
70707179 -- Gossip offline for pubkeys that lost their last session
@@ -7152,6 +7261,7 @@ handleFlushNonces = \authorization respond -> do
71527261 authorized <- verifyMaintenanceAuth authorization
71537262 if not authorized then yield (respond {ok: False {}, flushedCount: 0})
71547263 else do
7264+ logEvent "audit" "flush_nonces" ""
71557265 flushedCount <- atomic do
71567266 total <- countAllNonces 0
71577267 writeAllNonces []
@@ -7165,20 +7275,22 @@ handleFlushNonces = \authorization respond -> do
71657275handleRotateSecret = \newSecret authorization respond -> do
71667276 authorized <- verifyMaintenanceAuth authorization
71677277 if not authorized then yield (respond {ok: False {}, error: "unauthorized"})
7168− else if newSecret != "" && federationEnabled && not (tooLong newSecret 15) then do
7169− logError "rotate-secret: rejected — secret must be at least 16 characters when federation is enabled"
7170− yield (respond {ok: False {}, error: "secret_too_short"})
7171− else if newSecret != "" && not (tooLong newSecret 31) then do
7172− logWarn "rotate-secret: new secret is shorter than 32 characters — 32+ recommended for production"
7173− atomic do set *runtimeMaintenanceSecret = newSecret
7174− logInfo "rotate-secret: maintenance secret rotated (warning: short secret)"
7175− yield (respond {ok: True {}, error: ""})
71767278 else do
7177− atomic do set *runtimeMaintenanceSecret = newSecret
7178− if newSecret == ""
7179− then logInfo "rotate-secret: reverted to compile-time maintenance secret"
7180− else logInfo "rotate-secret: maintenance secret rotated"
7181− yield (respond {ok: True {}, error: ""})
7279+ logEvent "audit" "rotate_secret" (if newSecret == "" then "revert_to_default" else "new_secret")
7280+ if newSecret != "" && federationEnabled && not (tooLong newSecret 15) then do
7281+ logError "rotate-secret: rejected — secret must be at least 16 characters when federation is enabled"
7282+ yield (respond {ok: False {}, error: "secret_too_short"})
7283+ else if newSecret != "" && not (tooLong newSecret 31) then do
7284+ logWarn "rotate-secret: new secret is shorter than 32 characters — 32+ recommended for production"
7285+ atomic do set *runtimeMaintenanceSecret = newSecret
7286+ logInfo "rotate-secret: maintenance secret rotated (warning: short secret)"
7287+ yield (respond {ok: True {}, error: ""})
7288+ else do
7289+ atomic do set *runtimeMaintenanceSecret = newSecret
7290+ if newSecret == ""
7291+ then logInfo "rotate-secret: reverted to compile-time maintenance secret"
7292+ else logInfo "rotate-secret: maintenance secret rotated"
7293+ yield (respond {ok: True {}, error: ""})
71827294
71837295-- GET /maintenance/rate-limits: Return current effective rate limits.
71847296handleGetRateLimits = \authorization respond -> do
@@ -7205,6 +7317,7 @@ handleSetRateLimits = \globalRequest ipRate sendRate federationRate authGlobal r
72057317 authorized <- verifyMaintenanceAuth authorization
72067318 if not authorized then yield (respond {ok: False {}})
72077319 else do
7320+ logEvent "audit" "set_rate_limits" ("global=" ++ show globalRequest ++ " ip=" ++ show ipRate ++ " send=" ++ show sendRate)
72087321 atomic do
72097322 set *rateOverrideGlobalRequest = max 0 globalRequest
72107323 set *rateOverrideIpRate = max 0 ipRate
@@ -7390,13 +7503,86 @@ serveInner = \req -> case req of
73907503 TopRecipients {limit, authorization, respond} -> handleTopRecipients limit authorization respond
73917504 Ready {respond} -> handleReady respond
73927505
7506+-- Backpressure: check if the server is overloaded (too many concurrent requests).
7507+-- Health, Ready, and Maintenance endpoints are exempt so operators can always monitor
7508+-- and control the server even under extreme load.
7509+isOverloadExempt = \req -> case req of
7510+ Health {} -> True {}
7511+ Ready {} -> True {}
7512+ Prune {} -> True {}
7513+ Drain {} -> True {}
7514+ Shutdown {} -> True {}
7515+ Undrain {} -> True {}
7516+ Snapshot {} -> True {}
7517+ Restore {} -> True {}
7518+ Reconcile {} -> True {}
7519+ Stats {} -> True {}
7520+ StatsPeers {} -> True {}
7521+ StatsKey {} -> True {}
7522+ _ -> False {}
7523+
7524+-- Reject overloaded requests with appropriate empty/error responses per route type.
7525+-- Each response matches the handler's return shape with zeroed/empty fields.
7526+serveOverloaded = \req -> case req of
7527+ ReqChallenge {respond} -> respond {challenge: "", ok: False {}, expiresAt: 0, retryAfterMs: 1000, error: "overloaded"}
7528+ VerifyAuth {respond} -> respond {token: "", expiresAt: 0, error: "overloaded"}
7529+ RevokeSession {respond} -> respond {ok: False {}, authorized: False {}}
7530+ Logout {respond} -> respond {ok: False {}, authorized: False {}}
7531+ Poll {respond} -> respond {events: [], cursor: 0, authorized: False {}, rateLimited: True {}, draining: False {}, generation: 0, presenceUpdates: [], heartbeat: False {}, retryAfterMs: 1000, cursorReset: False {}, hasMore: False {}, cursorStale: False {}, messageExpirations: [], messageDeliveries: []}
7532+ SendMessage {respond} -> respond {status: MsRejected {}, messageId: "", retryAfterMs: 1000, error: "overloaded"}
7533+ MessageStatus {respond} -> respond {status: "", authorized: False {}}
7534+ AckMessages {respond} -> respond {ok: False {}, authorized: False {}, ackedCount: 0, error: "overloaded"}
7535+ SendBatch {respond} -> respond {results: [], authorized: False {}, retryAfterMs: 1000}
7536+ ListMessages {respond} -> respond {messages: [], authorized: False {}, count: 0, error: "overloaded"}
7537+ DeleteMessages {respond} -> respond {ok: False {}, authorized: False {}, deletedCount: 0, error: "overloaded"}
7538+ CancelMessage {respond} -> respond {ok: False {}, authorized: False {}, cancelled: False {}, error: "overloaded"}
7539+ CancelBatch {respond} -> respond {results: [], authorized: False {}, error: "overloaded"}
7540+ LookupPresence {respond} -> respond {server: "", authorized: False {}, error: "overloaded"}
7541+ BatchLookupPresence {respond} -> respond {results: [], authorized: False {}, error: "overloaded"}
7542+ RecvGossip {respond} -> respond {ok: False {}, error: "overloaded"}
7543+ ForwardMessage {respond} -> respond {ok: False {}, error: "overloaded"}
7544+ FederationAck {respond} -> respond {ok: False {}, error: "overloaded", ackedCount: 0, results: []}
7545+ FederationCancel {respond} -> respond {ok: False {}, error: "overloaded", cancelled: False {}}
7546+ FederationBatchCancel {respond} -> respond {ok: False {}, error: "overloaded", results: []}
7547+ FederationBatchForward {respond} -> respond {ok: False {}, error: "overloaded", results: []}
7548+ VerifyToken {respond} -> respond {ok: False {}}
7549+ TopRecipients {respond} -> respond {recipients: [], ok: False {}, total: 0}
7550+ BlockPeer {respond} -> respond {ok: False {}, blocked: 0}
7551+ UnblockPeer {respond} -> respond {ok: False {}, unblocked: False {}}
7552+ ListBlocked {respond} -> respond {peers: [], ok: False {}}
7553+ PingPeer {respond} -> respond {ok: False {}, latencyMs: 0, error: "overloaded"}
7554+ AdminListSessions {respond} -> respond {sessions: [], count: 0, ok: False {}}
7555+ AdminRevokeSessions {respond} -> respond {ok: False {}, revokedCount: 0}
7556+ AdminPurgeMessages {respond} -> respond {ok: False {}, purgedCount: 0}
7557+ AdminRevokeIp {respond} -> respond {ok: False {}, revokedCount: 0, affectedKeys: 0}
7558+ AdminListAllSessions {respond} -> respond {sessions: [], ok: False {}, total: 0}
7559+ GetRateLimits {respond} -> respond {ok: False {}, globalRequest: 0, ipRate: 0, sendRate: 0, federationRate: 0, authGlobal: 0, recvRate: 0, challengeRate: 0, pairRate: 0, lookupRate: 0, clientWriteRate: 0}
7560+ SetRateLimits {respond} -> respond {ok: False {}}
7561+ FlushNonces {respond} -> respond {ok: False {}, flushedCount: 0}
7562+ AllowPeer {respond} -> respond {ok: False {}, allowed: 0}
7563+ UnallowPeer {respond} -> respond {ok: False {}, removed: False {}}
7564+ ListAllowed {respond} -> respond {peers: [], ok: False {}}
7565+ RotateSecret {respond} -> respond {ok: False {}, error: "overloaded"}
7566+ -- Exempt routes handled by isOverloadExempt; this branch is unreachable
7567+ _ -> yield {}
7568+
73937569-- Access-logged request handler: wraps serveInner with timing and correlation ID.
73947570-- Logs all non-poll requests with a 6-hex-char request ID for log correlation.
73957571-- Polls are only logged when slow (> slowRequestThreshold) to avoid overwhelming
73967572-- logs with high-frequency long-poll traffic.
7573+-- Backpressure: rejects non-exempt requests when activeRequests >= maxActiveRequests.
73977574serve = \req -> do
73987575 startTime <- now
73997576 reqId <- genTokenN 1
7577+ -- Backpressure check: reject early if overloaded (exempt: health, ready, maintenance)
7578+ overloaded <- if maxActiveRequests <= 0 || isOverloadExempt req then yield (False {})
7579+ else do
7580+ ar <- *activeRequests
7581+ yield (ar >= maxActiveRequests)
7582+ if overloaded then do
7583+ logEvent "warn" "overloaded" (requestPath req)
7584+ serveOverloaded req
7585+ else do
74007586 atomic do
74017587 ar <- *activeRequests
74027588 let newAr = ar + 1
@@ -7659,6 +7845,12 @@ backgroundWatchdog = do
76597845 atomic do
76607846 inFlight <- *federationInFlight
76617847 set *federationActiveConnections = fold (\acc r -> acc + r.count) 0 inFlight)
7848+ -- Log hung federation connections even below the max threshold (early warning)
7849+ inFlight <- *federationInFlight
7850+ let hungSlots = do r <- inFlight; where r.count > 0 && r.firstStartedAt > 0 && t - r.firstStartedAt >= federationFetchTimeoutMs; yield r
7851+ when (count hungSlots > 0) (do
7852+ logEvent "warn" "watchdog_hung_federation" (show (count hungSlots) ++ " servers with hung connections")
7853+ logStaleInFlight hungSlots t)
76627854 backgroundWatchdog
76637855
76647856-- Startup connectivity probe: send an empty gossip to each known peer and log results.
@@ -7753,6 +7945,7 @@ main = do
77537945 when (federationEnabled && isBadServerName serverHostN)
77547946 (logError ("serverHost '" ++ serverHost ++ "' would be rejected by isBadServerName — federation peers cannot forward messages here. Set serverHost to a public DNS name."))
77557947 logInfo "IP binding is active — sessions created with a ClientIp header will reject requests without one"
7948+ logWarn "SECURITY: Knot has no HTTP-layer request body size limit. Deploy behind a reverse proxy with a body limit (e.g., Caddy: request_body { max_size 256KB }) to prevent large-payload DoS."
77567949 when federationEnabled
77577950 (logWarn "SECURITY: DNS rebinding risk — federation URLs resolve DNS at fetch time, not at validation time. A peer hostname passing isBadServerName could rebind to a private IP between validation and fetch. Mitigate by blocking outbound connections to RFC1918/link-local/CGNAT ranges at the firewall level.")
77587951 logInfo ("Skrepka relay server starting on port " ++ show listenPort ++ "...")
@@ -7765,6 +7958,7 @@ main = do
77657958 logInfo (" max forwards: " ++ show maxForwardsEntries ++ " (per key: " ++ show maxForwardsPerKey ++ ")")
77667959 logInfo (" session expiry: " ++ show sessionExpiry ++ "ms (max lifetime: " ++ show maxSessionLifetime ++ "ms)")
77677960 logInfo (" message max age: " ++ show messageMaxAge ++ "ms")
7961+ logInfo (" max active requests: " ++ if maxActiveRequests > 0 then show maxActiveRequests else "unlimited")
77687962 logInfo (" global rate limit: " ++ show maxGlobalRequestRate ++ "/" ++ show globalRateWindowMs ++ "ms")
77697963 logInfo (" auth rate limit: " ++ show maxAuthGlobalRate ++ "/" ++ show authGlobalRateWindowMs ++ "ms")
77707964 logInfo (" pair rate limit: " ++ show maxPairRatePerPair ++ "/" ++ show pairRateWindowMs ++ "ms per sender-recipient")