@@ -73,6 +73,7 @@ clockSkewTolerance : Int<Ms>
73clockSkewTolerance = 5000 -- 5 seconds tolerance for clock skew between federated servers
73clockSkewTolerance = 5000 -- 5 seconds tolerance for clock skew between federated servers
74maxForwardsEntries = 10000
74maxForwardsEntries = 10000
75maxForwardsPerKey = 50 -- max pending forwards per recipient pubkey (prevents one key from starving others)
75maxForwardsPerKey = 50 -- max pending forwards per recipient pubkey (prevents one key from starving others)
76+maxForwardsPerSenderPerKey = 10 -- max pending forwards per sender-recipient pair (prevents one sender from monopolizing a recipient's queue)
76maxForwardsPerServer = 2000 -- max pending forwards per destination server (prevents one unreachable peer from starving others)
77maxForwardsPerServer = 2000 -- max pending forwards per destination server (prevents one unreachable peer from starving others)
77maxServerNameLen = 128
78maxServerNameLen = 128
78maxMessageIdLen = 128
79maxMessageIdLen = 128
@@ -212,6 +213,12 @@ maxGossipEventsPerServer = 500 -- max total gossip events per server per window
212gossipEventRateWindowMs : Int<Ms>
213gossipEventRateWindowMs : Int<Ms>
213gossipEventRateWindowMs = 60000 -- 1 minute
214gossipEventRateWindowMs = 60000 -- 1 minute
214maxGossipEventRateEntries = 100
215maxGossipEventRateEntries = 100
216+-- Gossip batch backoff: when a single gossip request carries more than this many fresh events,
217+-- delay re-broadcast by a random interval up to gossipBatchBackoffMs. Prevents amplification
218+-- storms when peers recover from downtime and send bursts of presence updates.
219+gossipBatchBackoffThreshold = 50
220+gossipBatchBackoffMs : Int<Ms>
221+gossipBatchBackoffMs = 5000 -- max random delay before re-gossiping large batches
215-- Global federation connection cap (bounds total concurrent outbound connections across all paths)
222-- Global federation connection cap (bounds total concurrent outbound connections across all paths)
216maxGlobalFederationConcurrency = 30
223maxGlobalFederationConcurrency = 30
217-- Peer blocklist: max blocked servers tracked at runtime (managed via /maintenance/block, /maintenance/unblock)
224-- Peer blocklist: max blocked servers tracked at runtime (managed via /maintenance/block, /maintenance/unblock)
@@ -256,6 +263,11 @@ peerKeyTtlMs : Int<Ms>
256peerKeyTtlMs = 86400000 -- 24 hours; peer keys change rarely (server restart or rotation)
263peerKeyTtlMs = 86400000 -- 24 hours; peer keys change rarely (server restart or rotation)
257peerKeyRefetchCooldownMs : Int<Ms>
264peerKeyRefetchCooldownMs : Int<Ms>
258peerKeyRefetchCooldownMs = 60000 -- 1 minute; minimum interval between re-fetch attempts per server
265peerKeyRefetchCooldownMs = 60000 -- 1 minute; minimum interval between re-fetch attempts per server
266+-- Require ClientIp header on auth requests. When True, challenge requests with
267+-- empty clientIp are rejected. Prevents unbound sessions if the reverse proxy is
268+-- temporarily misconfigured (no ClientIp header forwarding). Sessions created without
269+-- IP binding permanently accept requests from any IP.
270+requireClientIp = True {}
259-- IP-based rate limiting (reverse proxy sets ClientIp header)
271-- IP-based rate limiting (reverse proxy sets ClientIp header)
260maxIpRatePerIp = 10 -- max auth requests per IP per window
272maxIpRatePerIp = 10 -- max auth requests per IP per window
261ipRateWindowMs : Int<Ms>
273ipRateWindowMs : Int<Ms>
@@ -827,6 +839,12 @@ maxPeerLatencyEntries = 100
827*nonceBucket14 : [ForwardNonce]
839*nonceBucket14 : [ForwardNonce]
828*nonceBucket15 : [ForwardNonce]
840*nonceBucket15 : [ForwardNonce]
842+-- Nonce eviction replay protection: tracks the seenAt of the oldest retained nonce
843+-- after LRU eviction. Forwards with timestamps older than this cutoff cannot be
844+-- verified for dedup (their nonces may have been evicted) and are rejected.
845+-- 0 = no eviction has occurred (all nonces retained), accept all timestamps.
846+*nonceEvictionCutoff : Timestamp
830-- Federation verify per-IP rate limiting (tighter than general IP rate)
848-- Federation verify per-IP rate limiting (tighter than general IP rate)
831*verifyIpRates : [IpRateEntry]
849*verifyIpRates : [IpRateEntry]
@@ -977,7 +995,7 @@ route AuthApi where
977route ClientApi where
995route ClientApi where
978 -- Long-poll for new messages; cursor tracks last-seen receivedAt timestamp
996 -- Long-poll for new messages; cursor tracks last-seen receivedAt timestamp
979 POST {cursor: Timestamp, generation: Int, watchKeys: [PubkeyHex]} /poll headers {authorization: Text, clientIp: Text}
997 POST {cursor: Timestamp, generation: Int, watchKeys: [PubkeyHex]} /poll headers {authorization: Text, clientIp: Text}
980− -> {events: [PollEvent], cursor: Timestamp, authorized: Bool, rateLimited: Bool, draining: Bool, generation: Int, presenceUpdates: [{pubkey: PubkeyHex, online: Bool}], heartbeat: Bool, retryAfterMs: Int, cursorReset: Bool, hasMore: Bool, cursorStale: Bool, messageExpirations: [{messageId: MessageIdHex, toKey: PubkeyHex, expiredAt: Timestamp}], messageDeliveries: [{messageId: MessageIdHex, toKey: PubkeyHex, deliveredAt: Timestamp}], droppedDeliveryReceipts: Int, droppedExpirationNotifs: Int}
998+ -> {events: [PollEvent], cursor: Timestamp, authorized: Bool, rateLimited: Bool, draining: Bool, generation: Int, presenceUpdates: [{pubkey: PubkeyHex, online: Bool}], heartbeat: Bool, retryAfterMs: Int, cursorReset: Bool, hasMore: Bool, cursorStale: Bool, messageExpirations: [{messageId: MessageIdHex, toKey: PubkeyHex, expiredAt: Timestamp}], messageDeliveries: [{messageId: MessageIdHex, toKey: PubkeyHex, deliveredAt: Timestamp}], droppedDeliveryReceipts: Int, droppedExpirationNotifs: Int, loadPct: Int}
982 /messages
1000 /messages
983 -- Send an E2E encrypted message to a recipient pubkey.
1001 -- Send an E2E encrypted message to a recipient pubkey.
@@ -986,7 +1004,7 @@ route ClientApi where
986 -- priority: optional urgency level (0 = normal, higher = more urgent, clamped to [0, maxMessagePriority]).
1004 -- priority: optional urgency level (0 = normal, higher = more urgent, clamped to [0, maxMessagePriority]).
987 -- High-priority messages are forwarded first during federation retries.
1005 -- High-priority messages are forwarded first during federation retries.
988 POST {to: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, messageId: OptionalMessageId, expiresInMs: Int, priority: Int} / headers {authorization: Text, clientIp: Text}
1006 POST {to: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, messageId: OptionalMessageId, expiresInMs: Int, priority: Int} / headers {authorization: Text, clientIp: Text}
989− -> {status: MessageStatus, messageId: OptionalMessageId, retryAfterMs: Int, error: Text}
1007+ -> {status: MessageStatus, messageId: OptionalMessageId, retryAfterMs: Int, error: Text, loadPct: Int}
990 = SendMessage
1008 = SendMessage
991 -- Check delivery status of a sent message (caller must know recipient to prove ownership)
1009 -- Check delivery status of a sent message (caller must know recipient to prove ownership)
992 POST {to: PubkeyHex, messageId: MessageIdHex} /status headers {authorization: Text, clientIp: Text}
1010 POST {to: PubkeyHex, messageId: MessageIdHex} /status headers {authorization: Text, clientIp: Text}
@@ -2743,6 +2761,14 @@ updateForwardNonce = \nb messageId toKey t -> do
2743 then case minBy evictedNonces (\n -> n.seenAt) of Nothing {} -> 0; Just {value: n} -> t - n.seenAt
2761 then case minBy evictedNonces (\n -> n.seenAt) of Nothing {} -> 0; Just {value: n} -> t - n.seenAt
2745 writeNonceBucket nb evictedNonces
2763 writeNonceBucket nb evictedNonces
2764+ -- Update nonce eviction cutoff: the seenAt of the oldest retained nonce.
2765+ -- Forwards with timestamps older than this cannot be verified for dedup.
2766+ when (lruEvicted > 0) (do
2767+ let oldestRetainedSeenAt = case minBy evictedNonces (\n -> n.seenAt) of Nothing {} -> t; Just {value: n} -> n.seenAt
2768+ cutoff <- *nonceEvictionCutoff
2769+ -- Only advance the cutoff (never regress): a bucket with older retained nonces
2770+ -- shouldn't lower the cutoff set by a bucket with newer evictions.
2771+ when (oldestRetainedSeenAt > cutoff) (set *nonceEvictionCutoff = oldestRetainedSeenAt))
2746 yield {lruEvicted: lruEvicted, oldestRetainedAge: oldestRetainedAge}
2772 yield {lruEvicted: lruEvicted, oldestRetainedAge: oldestRetainedAge}
2748-- Log nonce LRU eviction if any occurred. Call outside atomic blocks.
2774-- Log nonce LRU eviction if any occurred. Call outside atomic blocks.
@@ -4564,13 +4590,15 @@ forwardQueuedMessages = \targets t -> do
4564 relevantMessages <- readSelectedBuckets neededBuckets
4590 relevantMessages <- readSelectedBuckets neededBuckets
4565 let candidates = forwardCandidates remoteTargets relevantMessages forwards t
4591 let candidates = forwardCandidates remoteTargets relevantMessages forwards t
4566 totalFwdCount <- *totalForwardCount
4592 totalFwdCount <- *totalForwardCount
4567− -- Per-key and per-server caps: filter candidates to respect both limits
4593+ -- Per-key, per-server, and per-sender-per-key caps: filter candidates to respect all limits.
4594+ -- Per-sender cap prevents a single sender from monopolizing a recipient's forward queue.
4568 let perKeyCapped = fold (\acc c ->
4595 let perKeyCapped = fold (\acc c ->
4569 let existingForKey = count (do f <- forwards; where f.toKey == c.toKey; yield f)
4596 let existingForKey = count (do f <- forwards; where f.toKey == c.toKey; yield f)
4570 + count (do a <- acc; where a.toKey == c.toKey; yield a)
4597 + count (do a <- acc; where a.toKey == c.toKey; yield a)
4571 in let existingForServer = count (do f <- forwards; where f.toServer == c.toServer; yield f)
4598 in let existingForServer = count (do f <- forwards; where f.toServer == c.toServer; yield f)
4572 + count (do a <- acc; where a.toServer == c.toServer; yield a)
4599 + count (do a <- acc; where a.toServer == c.toServer; yield a)
4600+ in let existingForSenderKey = if c.senderKey != "" then count (do f <- forwards; where f.senderKey == c.senderKey && f.toKey == c.toKey; yield f) + count (do a <- acc; where a.senderKey == c.senderKey && a.toKey == c.toKey; yield a) else 0
4573− in if existingForKey >= maxForwardsPerKey || existingForServer >= maxForwardsPerServer then acc else union acc [c]) [] candidates
4601+ in if existingForKey >= maxForwardsPerKey || existingForServer >= maxForwardsPerServer || (c.senderKey != "" && existingForSenderKey >= maxForwardsPerSenderPerKey) then acc else union acc [c]) [] candidates
4574 let capped = takeRelation (maxForwardsEntries - totalFwdCount) perKeyCapped
4602 let capped = takeRelation (maxForwardsEntries - totalFwdCount) perKeyCapped
4575 -- Write back only the affected forward buckets (not all 16)
4603 -- Write back only the affected forward buckets (not all 16)
4576 let combined = union forwards capped
4604 let combined = union forwards capped
@@ -5426,6 +5454,13 @@ remoteOnlyTargets = \targets localOnline ->
5426-- Rate-limited per key; globally capped. Challenge expires in 1 minute.
5454-- Rate-limited per key; globally capped. Challenge expires in 1 minute.
5427handleChallenge = \pubkey clientIp respond -> do
5455handleChallenge = \pubkey clientIp respond -> do
5428 t <- now
5456 t <- now
5457+ -- Reject challenges without ClientIp when requireClientIp is enabled.
5458+ -- Prevents creating sessions with no IP binding (permanently accept from any IP).
5459+ if requireClientIp && clientIp == ""
5461+ logEvent "warn" "missing_client_ip" ("endpoint=challenge key=" ++ pubkey)
5462+ yield (respond {challenge: "", ok: False {}, expiresAt: 0, retryAfterMs: 0, error: "client_ip_required"})
5429 preAuth <- checkPreAuth clientIp t
5464 preAuth <- checkPreAuth clientIp t
5430 if preAuth != ""
5465 if preAuth != ""
@@ -5613,10 +5648,10 @@ handlePoll = \cursor generation watchKeys authorization clientIp respond -> do
5613 -- Cursor staleness: cursor is older than message retention, so messages were missed.
5648 -- Cursor staleness: cursor is older than message retention, so messages were missed.
5614 -- Different from cursorReset (server restart): staleness means client was offline too long.
5649 -- Different from cursorReset (server restart): staleness means client was offline too long.
5615 let cursorStale = effectiveCursor > 0 && t - effectiveCursor > unackedMessageMaxAge
5650 let cursorStale = effectiveCursor > 0 && t - effectiveCursor > unackedMessageMaxAge
5616− if dp >= 2 then yield (respond {events: [], cursor: 0, authorized: True {}, rateLimited: False {}, draining: True {}, generation: serverGen, presenceUpdates: [], heartbeat: False {}, retryAfterMs: 0, cursorReset: cursorWasReset, hasMore: False {}, cursorStale: False {}, messageExpirations: [], messageDeliveries: [], droppedDeliveryReceipts: 0, droppedExpirationNotifs: 0})
5651+ if dp >= 2 then yield (respond {events: [], cursor: 0, authorized: True {}, rateLimited: False {}, draining: True {}, generation: serverGen, presenceUpdates: [], heartbeat: False {}, retryAfterMs: 0, cursorReset: cursorWasReset, hasMore: False {}, cursorStale: False {}, messageExpirations: [], messageDeliveries: [], droppedDeliveryReceipts: 0, droppedExpirationNotifs: 0, loadPct: 0})
5618 globalRateLimited <- checkGlobalRate t
5653 globalRateLimited <- checkGlobalRate t
5619− if globalRateLimited then yield (respond {events: [], cursor: 0, authorized: False {}, rateLimited: True {}, draining: False {}, generation: serverGen, presenceUpdates: [], heartbeat: False {}, retryAfterMs: globalRateWindowMs, cursorReset: cursorWasReset, hasMore: False {}, cursorStale: False {}, messageExpirations: [], messageDeliveries: [], droppedDeliveryReceipts: 0, droppedExpirationNotifs: 0})
5654+ if globalRateLimited then yield (respond {events: [], cursor: 0, authorized: False {}, rateLimited: True {}, draining: False {}, generation: serverGen, presenceUpdates: [], heartbeat: False {}, retryAfterMs: globalRateWindowMs, cursorReset: cursorWasReset, hasMore: False {}, cursorStale: False {}, messageExpirations: [], messageDeliveries: [], droppedDeliveryReceipts: 0, droppedExpirationNotifs: 0, loadPct: 0})
5621 -- Capture presenceVersion before long-poll wait to skip unchanged lookups
5656 -- Capture presenceVersion before long-poll wait to skip unchanged lookups
5622 startPresenceVersion <- *presenceVersion
5657 startPresenceVersion <- *presenceVersion
@@ -5678,7 +5713,10 @@ handlePoll = \cursor generation watchKeys authorization clientIp respond -> do
5678 let deResult = drainDropCounter den result.pubkey
5713 let deResult = drainDropCounter den result.pubkey
5679 when (deResult.count > 0) (set *droppedExpirationNotifs = deResult.remaining)
5714 when (deResult.count > 0) (set *droppedExpirationNotifs = deResult.remaining)
5680 yield {dr: drResult.count, de: deResult.count}
5715 yield {dr: drResult.count, de: deResult.count}
5716+ -- Expose server load to clients: 0 = healthy (<=80% capacity), up to 90 = near-full.
5717+ -- Clients can voluntarily reduce poll frequency or batch sends when loadPct > 0.
5718+ loadPct <- do scale <- *cachedAdaptiveScale; yield (if scale >= 100 then 0 else 100 - scale)
5681− yield (respond {events: result.events, cursor: result.cursor, authorized: result.authorized, rateLimited: result.pollSlotFull, draining: False {}, generation: serverGen, presenceUpdates: presenceUpdates, heartbeat: isHeartbeat, retryAfterMs: pollRetryMs, cursorReset: cursorWasReset, hasMore: result.hasMore, cursorStale: cursorStale, messageExpirations: expirations, messageDeliveries: deliveries, droppedDeliveryReceipts: drops.dr, droppedExpirationNotifs: drops.de})
5719+ yield (respond {events: result.events, cursor: result.cursor, authorized: result.authorized, rateLimited: result.pollSlotFull, draining: False {}, generation: serverGen, presenceUpdates: presenceUpdates, heartbeat: isHeartbeat, retryAfterMs: pollRetryMs, cursorReset: cursorWasReset, hasMore: result.hasMore, cursorStale: cursorStale, messageExpirations: expirations, messageDeliveries: deliveries, droppedDeliveryReceipts: drops.dr, droppedExpirationNotifs: drops.de, loadPct: loadPct})
5683-- Core send logic: dedup, rate limiting, store message, create forwards.
5721-- Core send logic: dedup, rate limiting, store message, create forwards.
5684-- Called inside an atomic block by both handleSendMessage and sendBatchInner.
5722-- Called inside an atomic block by both handleSendMessage and sendBatchInner.
@@ -5754,7 +5792,12 @@ sendMessageCore = \senderKey to encryptedBlob clampedTs effectiveMsgId expiresIn
5754 -- Per-key cap: prevents one key from consuming disproportionate forward slots
5792 -- Per-key cap: prevents one key from consuming disproportionate forward slots
5755 let keyFwdCount = count (do f <- fwdBkt; where f.toKey == to; yield f)
5793 let keyFwdCount = count (do f <- fwdBkt; where f.toKey == to; yield f)
5756 let keyCap = maxForwardsPerKey - keyFwdCount
5794 let keyCap = maxForwardsPerKey - keyFwdCount
5757− let cappedFwds = takeRelation (min globalCap keyCap) newFwds
5795+ -- Per-sender-per-recipient cap: prevents a single sender from monopolizing
5796+ -- a recipient's forward queue, ensuring at least (maxForwardsPerKey /
5797+ -- maxForwardsPerSenderPerKey) distinct senders can queue forwards.
5798+ let senderKeyFwdCount = if senderKey != "" then count (do f <- fwdBkt; where f.senderKey == senderKey && f.toKey == to; yield f) else 0
5799+ let senderKeyCap = if senderKey != "" && senderKeyFwdCount >= maxForwardsPerSenderPerKey then 0 else min globalCap keyCap
5800+ let cappedFwds = takeRelation senderKeyCap newFwds
5758 writeFwdBucket fbi (union fwdBkt cappedFwds)
5801 writeFwdBucket fbi (union fwdBkt cappedFwds)
5759 set *totalForwardCount = totalFwdCount + count cappedFwds
5802 set *totalForwardCount = totalFwdCount + count cappedFwds
5760 -- Return whether any forwards were actually enqueued (vs dropped by capacity cap)
5803 -- Return whether any forwards were actually enqueued (vs dropped by capacity cap)
@@ -5891,14 +5934,14 @@ handleSendMessage = \to encryptedBlob timestamp messageId expiresInMs priority a
5891 let clampedPriority = clamp 0 maxMessagePriority priority
5934 let clampedPriority = clamp 0 maxMessagePriority priority
5892 t <- now
5935 t <- now
5893 dp <- *drainPhase
5936 dp <- *drainPhase
5894− if dp >= 2 then yield (respond {status: MsDraining {}, messageId: "", retryAfterMs: 0, error: "draining"})
5937+ if dp >= 2 then yield (respond {status: MsDraining {}, messageId: "", retryAfterMs: 0, error: "draining", loadPct: 0})
5896 globalRateLimited <- checkGlobalRate t
5939 globalRateLimited <- checkGlobalRate t
5897− if globalRateLimited then yield (respond {status: MsRateLimited {}, messageId: "", retryAfterMs: 0, error: "rate_limited:" ++ show globalRateWindowMs ++ "ms"})
5940+ if globalRateLimited then yield (respond {status: MsRateLimited {}, messageId: "", retryAfterMs: 0, error: "rate_limited:" ++ show globalRateWindowMs ++ "ms", loadPct: 0})
5899 effectiveMsgId <- if messageId != "" then yield messageId else genToken
5942 effectiveMsgId <- if messageId != "" then yield messageId else genToken
5900 if t - timestamp > messageMaxAge
5943 if t - timestamp > messageMaxAge
5901− then yield (respond {status: MsRejected {}, messageId: "", retryAfterMs: 0, error: "stale_timestamp"})
5944+ then yield (respond {status: MsRejected {}, messageId: "", retryAfterMs: 0, error: "stale_timestamp", loadPct: 0})
5903 when (timestamp > t + clockSkewTolerance)
5946 when (timestamp > t + clockSkewTolerance)
5904 (logWarn ("sendMessage: future timestamp detected (+" ++ show (timestamp - t) ++ "ms ahead), clamping to now"))
5947 (logWarn ("sendMessage: future timestamp detected (+" ++ show (timestamp - t) ++ "ms ahead), clamping to now"))
@@ -5914,12 +5957,13 @@ handleSendMessage = \to encryptedBlob timestamp messageId expiresInMs priority a
5914 MsRejected {} -> when (result.retryAfterMs == 0) (logWarn ("sendMessage: rejected for " ++ effectiveMsgId ++ " (global capacity full)"))
5957 MsRejected {} -> when (result.retryAfterMs == 0) (logWarn ("sendMessage: rejected for " ++ effectiveMsgId ++ " (global capacity full)"))
5915 _ -> yield {}
5958 _ -> yield {}
5916 forkSendForwards effectiveMsgId to encryptedBlob clampedTs result.remoteServers
5959 forkSendForwards effectiveMsgId to encryptedBlob clampedTs result.remoteServers
5960+ loadPct <- do scale <- *cachedAdaptiveScale; yield (if scale >= 100 then 0 else 100 - scale)
5917 yield (case result.status of
5961 yield (case result.status of
5918− MsDuplicate {} -> respond {status: MsDuplicate {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
5962+ MsDuplicate {} -> respond {status: MsDuplicate {}, messageId: effectiveMsgId, retryAfterMs: 0, error: "", loadPct: loadPct}
5919− MsDelivered {} -> respond {status: MsDelivered {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
5963+ MsDelivered {} -> respond {status: MsDelivered {}, messageId: effectiveMsgId, retryAfterMs: 0, error: "", loadPct: loadPct}
5920− MsFederated {} -> respond {status: MsFederated {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
5964+ MsFederated {} -> respond {status: MsFederated {}, messageId: effectiveMsgId, retryAfterMs: 0, error: "", loadPct: loadPct}
5921− MsQueued {} -> respond {status: MsQueued {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
5965+ MsQueued {} -> respond {status: MsQueued {}, messageId: effectiveMsgId, retryAfterMs: 0, error: "", loadPct: loadPct}
5922− _ -> respond {status: result.status, messageId: "", retryAfterMs: result.retryAfterMs, error: "rejected"})
5966+ _ -> respond {status: result.status, messageId: "", retryAfterMs: result.retryAfterMs, error: "rejected", loadPct: loadPct})
5924-- GET /lookup/:pubkey: Look up the best known server for a pubkey.
5968-- GET /lookup/:pubkey: Look up the best known server for a pubkey.
5925-- Returns local server if key is online locally, otherwise the remote with latest expiry.
5969-- Returns local server if key is online locally, otherwise the remote with latest expiry.
@@ -6267,6 +6311,14 @@ handleRecvGossip = \events hopsRemaining fromServer requestTimestamp correlation
6267 let newlyOnlineRemote = newlyOnlineRemoteKeys filteredOnline gossipResult.gm.appliedKeys
6311 let newlyOnlineRemote = newlyOnlineRemoteKeys filteredOnline gossipResult.gm.appliedKeys
6268 fork (do
6312 fork (do
6269 when (cappedHops > 1 && federationEnabled && count freshEvents > 0) (do
6313 when (cappedHops > 1 && federationEnabled && count freshEvents > 0) (do
6314+ -- Gossip batch backoff: when a large batch of fresh events arrives (e.g., peer
6315+ -- recovering from downtime), delay re-broadcast by a random interval to prevent
6316+ -- amplification storms. Multiple peers recovering simultaneously would otherwise
6317+ -- generate O(peers^2 * events) gossip traffic.
6318+ when (count freshEvents > gossipBatchBackoffThreshold) (do
6319+ backoff <- randomInt gossipBatchBackoffMs
6320+ logEvent "info" "gossip_batch_backoff" ("events=" ++ show (count freshEvents) ++ " from=" ++ fromServer ++ " backoffMs=" ++ show backoff)
6270 let targets = gossipTargets gossipResult.gm.gossipPeerServers fromServer
6322 let targets = gossipTargets gossipResult.gm.gossipPeerServers fromServer
6271 -- Split first-party (from the sender) vs third-party (relayed) events.
6323 -- Split first-party (from the sender) vs third-party (relayed) events.
6272 -- Third-party events get fewer hops to limit amplification from inflated hopsRemaining.
6324 -- Third-party events get fewer hops to limit amplification from inflated hopsRemaining.
@@ -6330,7 +6382,7 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
6330 presenceBkt <- readPresenceBucket bkt
6382 presenceBkt <- readPresenceBucket bkt
6331 let hasPresence = any presenceBkt (\p -> p.pubkey == toKey && p.expiresAt > t)
6383 let hasPresence = any presenceBkt (\p -> p.pubkey == toKey && p.expiresAt > t)
6332 if not hasPresence
6384 if not hasPresence
6333− then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: True {}}
6385+ then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: True {}, nonceCutoffRejected: False {}}
6335 bucket <- readMessageBucket bkt
6387 bucket <- readMessageBucket bkt
6336 ackedBkt <- readAckedBucket bkt
6388 ackedBkt <- readAckedBucket bkt
@@ -6342,28 +6394,35 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
6342 -- messages (originServer="") whose nonces were recorded by sendMessageCore.
6394 -- messages (originServer="") whose nonces were recorded by sendMessageCore.
6343 let isDup = checkMessageDup bucket messageId toKey || hasMessage messageId toKey ackedBkt || hasMessage messageId toKey delivBkt || any nonceBkt (\n -> n.messageId == messageId && n.toKey == toKey)
6395 let isDup = checkMessageDup bucket messageId toKey || hasMessage messageId toKey ackedBkt || hasMessage messageId toKey delivBkt || any nonceBkt (\n -> n.messageId == messageId && n.toKey == toKey)
6344 if isDup
6396 if isDup
6345− then yield {forward: ForwardDuplicate {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}}
6397+ then yield {forward: ForwardDuplicate {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}, nonceCutoffRejected: False {}}
6399+ -- Nonce eviction replay protection: if LRU eviction has occurred, we cannot
6400+ -- verify dedup for messages whose nonces may have been evicted. Reject forwards
6401+ -- with timestamps older than the eviction cutoff as a safety measure.
6402+ nonceCutoff <- *nonceEvictionCutoff
6403+ if nonceCutoff > 0 && timestamp > 0 && timestamp < nonceCutoff
6404+ then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}, nonceCutoffRejected: True {}}
6347 totalMsgCount <- *totalMessageCount
6406 totalMsgCount <- *totalMessageCount
6348 if totalMsgCount >= maxMessages
6407 if totalMsgCount >= maxMessages
6349− then yield {forward: ForwardRejected {}, globalCapReached: True {}, recvRateLimited: False {}, noPresence: False {}}
6408+ then yield {forward: ForwardRejected {}, globalCapReached: True {}, recvRateLimited: False {}, noPresence: False {}, nonceCutoffRejected: False {}}
6351 recipientCounts <- readRcBucket bkt
6410 recipientCounts <- readRcBucket bkt
6352 let recipientCount = getRecipientCount toKey recipientCounts
6411 let recipientCount = getRecipientCount toKey recipientCounts
6353 if recipientCount >= maxMessagesPerRecipient
6412 if recipientCount >= maxMessagesPerRecipient
6354− then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}}
6413+ then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}, nonceCutoffRejected: False {}}
6356 recvRates <- *recvRates
6415 recvRates <- *recvRates
6357 recvRate <- effectiveRecvRate
6416 recvRate <- effectiveRecvRate
6358 let recvResult = checkPubkeyRate recvRates toKey recvRate recvRateWindowMs maxRecvRateEntries t
6417 let recvResult = checkPubkeyRate recvRates toKey recvRate recvRateWindowMs maxRecvRateEntries t
6359 if recvResult.limited
6418 if recvResult.limited
6360− then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: True {}, noPresence: False {}}
6419+ then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: True {}, noPresence: False {}, nonceCutoffRejected: False {}}
6362 set *recvRates = recvResult.entries
6421 set *recvRates = recvResult.entries
6363 federationPairRates <- *federationPairRates
6422 federationPairRates <- *federationPairRates
6364 let fedPairResult = checkServerRecipientRate federationPairRates fromServer toKey maxFederationPairRatePerPair federationPairRateWindowMs maxFederationPairRateEntries t
6423 let fedPairResult = checkServerRecipientRate federationPairRates fromServer toKey maxFederationPairRatePerPair federationPairRateWindowMs maxFederationPairRateEntries t
6365 if fedPairResult.limited
6424 if fedPairResult.limited
6366− then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}}
6425+ then yield {forward: ForwardRejected {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}, nonceCutoffRejected: False {}}
6368 set *federationPairRates = fedPairResult.entries
6427 set *federationPairRates = fedPairResult.entries
6369 seqCounter <- *seqCounter
6428 seqCounter <- *seqCounter
@@ -6381,21 +6440,23 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
6381 signalMessageForKey toKey seqTs
6440 signalMessageForKey toKey seqTs
6382 -- Record forward nonce for replay protection beyond message/ack dedup lifetime.
6441 -- Record forward nonce for replay protection beyond message/ack dedup lifetime.
6383 recordForwardNonce messageId toKey t
6442 recordForwardNonce messageId toKey t
6384− yield {forward: ForwardStored {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}}
6443+ yield {forward: ForwardStored {}, globalCapReached: False {}, recvRateLimited: False {}, noPresence: False {}, nonceCutoffRejected: False {}}
6385 if result.noPresence then do
6444 if result.noPresence then do
6386 logWarn ("forward: rejected for " ++ toKey ++ " — no active presence on this server (from " ++ fromServer ++ ")")
6445 logWarn ("forward: rejected for " ++ toKey ++ " — no active presence on this server (from " ++ fromServer ++ ")")
6387 yield (respond {ok: False {}, error: "no_presence"})
6446 yield (respond {ok: False {}, error: "no_presence"})
6389 case result.forward of
6448 case result.forward of
6390 ForwardRejected {} ->
6449 ForwardRejected {} ->
6450+ if result.nonceCutoffRejected
6451+ then logEvent "warn" "forward_nonce_cutoff" ("messageId=" ++ messageId ++ " key=" ++ toKey ++ " from=" ++ fromServer ++ " — timestamp older than nonce eviction cutoff, possible replay")
6391− if result.globalCapReached
6452+ else if result.globalCapReached
6392 then logWarn ("forward: global message cap reached (" ++ show maxMessages ++ "), rejecting for " ++ toKey)
6453 then logWarn ("forward: global message cap reached (" ++ show maxMessages ++ "), rejecting for " ++ toKey)
6393 else if result.recvRateLimited
6454 else if result.recvRateLimited
6394 then logEvent "warn" "rate_limited_recv" ("key=" ++ toKey ++ " limit=" ++ show maxRecvRatePerKey)
6455 then logEvent "warn" "rate_limited_recv" ("key=" ++ toKey ++ " limit=" ++ show maxRecvRatePerKey)
6395 else logWarn ("forward: rejected for " ++ toKey ++ " (mailbox full, max " ++ show maxMessagesPerRecipient ++ ")")
6456 else logWarn ("forward: rejected for " ++ toKey ++ " (mailbox full, max " ++ show maxMessagesPerRecipient ++ ")")
6396 _ -> yield {}
6457 _ -> yield {}
6397 yield (case result.forward of
6458 yield (case result.forward of
6398− ForwardRejected {} -> respond {ok: False {}, error: "mailbox_full"}
6459+ ForwardRejected {} -> respond {ok: False {}, error: if result.nonceCutoffRejected then "nonce_cutoff" else "mailbox_full"}
6399 _ -> respond {ok: True {}, error: ""}))
6460 _ -> respond {ok: True {}, error: ""}))
6401-- Generate sender delivery receipts for messages confirmed delivered via federation ack.
6462-- Generate sender delivery receipts for messages confirmed delivered via federation ack.
@@ -6426,13 +6487,17 @@ fedDeliveryReceipts = \bucketIds forwards t -> case headOf bucketIds of
6426 let combined = union sd deduped
6487 let combined = union sd deduped
6427 let overCap = count combined > maxSenderDeliveries
6488 let overCap = count combined > maxSenderDeliveries
6428 if overCap then do
6489 if overCap then do
6490+ -- Batch eviction: evict 2% of capacity instead of 1 entry at a time.
6491+ -- Amortizes the O(n) scan cost across multiple inserts, reducing overhead
6492+ -- under sustained capacity pressure from O(n) per insert to O(n) per 2% fills.
6493+ let evictCount = max 1 (count combined - maxSenderDeliveries + maxSenderDeliveries / 50)
6429− let evicted = extractOldest combined (\d -> d.deliveredAt)
6494+ let eviction = evictSmallest combined evictCount (\d -> d.deliveredAt)
6430− set *senderDeliveries = evicted.items
6495+ set *senderDeliveries = eviction.kept
6431− case evicted.candidate of
6496+ -- Track per-sender drop counts for all evicted entries
6432− Nothing {} -> yield {}
6497+ let dropCounts = fold (\acc e -> incrementDropCounter acc e.senderKey 1 t) [] eviction.evicted
6433− Just {value: ev} -> do
6498+ when (count dropCounts > 0) (do
6434− ddr <- *droppedDeliveryReceipts
6499+ ddr <- *droppedDeliveryReceipts
6435− set *droppedDeliveryReceipts = incrementDropCounter ddr ev.senderKey 1 t
6500+ set *droppedDeliveryReceipts = fold (\acc dc -> incrementDropCounter acc dc.senderKey dc.count t) ddr dropCounts)
6436 else set *senderDeliveries = combined)
6501 else set *senderDeliveries = combined)
6437 fedDeliveryReceipts rest forwards t
6502 fedDeliveryReceipts rest forwards t
@@ -6729,6 +6794,11 @@ forwardBucketGroupInner = \entries fromServer b t acc presenceBkt bucket ackedBk
6729 if isDup
6794 if isDup
6730 then forwardBucketGroupInner rest fromServer b t (union acc [{messageId: entry.messageId, ok: True {}}]) presenceBkt bucket ackedBkt delivBkt nonceBkt rc msgStored
6795 then forwardBucketGroupInner rest fromServer b t (union acc [{messageId: entry.messageId, ok: True {}}]) presenceBkt bucket ackedBkt delivBkt nonceBkt rc msgStored
6797+ -- Nonce eviction replay protection (matches handleForwardMessage)
6798+ nonceCutoff <- *nonceEvictionCutoff
6799+ if nonceCutoff > 0 && entry.timestamp > 0 && entry.timestamp < nonceCutoff
6800+ then forwardBucketGroupInner rest fromServer b t (union acc [{messageId: entry.messageId, ok: False {}}]) presenceBkt bucket ackedBkt delivBkt nonceBkt rc msgStored
6732 totalMsgCount <- *totalMessageCount
6802 totalMsgCount <- *totalMessageCount
6733 let recipientCount = getRecipientCount entry.toKey rc
6803 let recipientCount = getRecipientCount entry.toKey rc
6734 if totalMsgCount + msgStored >= maxMessages || recipientCount >= maxMessagesPerRecipient
6804 if totalMsgCount + msgStored >= maxMessages || recipientCount >= maxMessagesPerRecipient
@@ -7098,37 +7168,57 @@ computeAndCacheHealth = \t -> do
7098 set *cachedHealthDegradedPeers = result.degradedPeers
7168 set *cachedHealthDegradedPeers = result.degradedPeers
7099 yield result
7169 yield result
7171+-- Return cached health state. Used for unauthenticated callers and rate-limited requests.
7172+-- Provides constant-time responses regardless of server state complexity.
7173+readCachedHealth = \respond -> do
7174+ cached <- atomic do
7175+ cOk <- *cachedHealthOk
7176+ cP <- *cachedHealthPrune
7177+ cH <- *cachedHealthHeartbeat
7178+ cC <- *cachedHealthCapacity
7179+ cF <- *cachedHealthFederation
7180+ cW <- *cachedHealthWatchdog
7181+ cN <- *cachedHealthNonce
7182+ cFQ <- *cachedHealthForwardQueue
7183+ cCW <- *cachedHealthCapacityWarning
7184+ cDP <- *cachedHealthDegradedPeers
7186+ yield {ok: cOk && dp == 0, version: serverBuildVersion, pruneHealthy: cP, heartbeatHealthy: cH, capacityHealthy: cC, capacityWarning: cCW, federationHealthy: cF, watchdogHealthy: cW, nonceHealthy: cN, forwardQueueHealthy: cFQ, draining: dp > 0, degradedPeers: cDP}
7187+ yield (respond cached)
7101handleHealth = \authorization respond -> do
7189handleHealth = \authorization respond -> do
7102 t <- now
7190 t <- now
7103 -- Health checks exempt from global rate limiting (monitoring traffic shouldn't starve real requests)
7191 -- Health checks exempt from global rate limiting (monitoring traffic shouldn't starve real requests)
7104 -- but have their own dedicated rate limiter to prevent DDoS on /health from saturating reads.
7192 -- but have their own dedicated rate limiter to prevent DDoS on /health from saturating reads.
7105 healthLimited <- checkHealthRate t
7193 healthLimited <- checkHealthRate t
7106− if healthLimited then do
7194+ if healthLimited then readCachedHealth respond
7107− -- Return cached last-known health (prevents DDoS on /health from masking real failures).
7108− -- Cache is kept fresh by computeAndCacheHealth called from both here and background prune.
7109− cached <- atomic do
7110− cOk <- *cachedHealthOk
7111− cP <- *cachedHealthPrune
7112− cH <- *cachedHealthHeartbeat
7113− cC <- *cachedHealthCapacity
7114− cF <- *cachedHealthFederation
7115− cW <- *cachedHealthWatchdog
7116− cN <- *cachedHealthNonce
7117− cFQ <- *cachedHealthForwardQueue
7118− cCW <- *cachedHealthCapacityWarning
7119− cDP <- *cachedHealthDegradedPeers
7121− yield {ok: cOk && dp == 0, version: serverBuildVersion, pruneHealthy: cP, heartbeatHealthy: cH, capacityHealthy: cC, capacityWarning: cCW, federationHealthy: cF, watchdogHealthy: cW, nonceHealthy: cN, forwardQueueHealthy: cFQ, draining: dp > 0, degradedPeers: cDP}
7122− yield (respond cached)
7124 isAuthed <- verifyMaintenanceAuth authorization
7196 isAuthed <- verifyMaintenanceAuth authorization
7197+ -- Unauthenticated callers always get cached results (constant-time, no live computation).
7198+ -- This prevents timing side-channels: an attacker cannot infer server load by measuring
7199+ -- response time differences between cached (fast) and computed (slow) health checks.
7200+ -- Cache is kept fresh by computeAndCacheHealth called from background prune (~30s).
7201+ -- Only authenticated callers trigger live computation + cache refresh.
7204+ cached <- atomic do
7205+ cOk <- *cachedHealthOk
7207+ yield {ok: cOk && dp == 0, draining: dp > 0}
7208+ yield (respond {ok: cached.ok, version: serverBuildVersion, pruneHealthy: True {},
7209+ heartbeatHealthy: True {},
7210+ capacityHealthy: True {},
7211+ capacityWarning: False {},
7212+ federationHealthy: True {},
7213+ watchdogHealthy: True {},
7214+ nonceHealthy: True {},
7215+ forwardQueueHealthy: True {},
7216+ draining: cached.draining,
7125 result <- computeAndCacheHealth t
7219 result <- computeAndCacheHealth t
7126 let allOk = not result.isDraining && result.pruneOk && result.heartbeatOk && result.capacityOk && result.federationOk && result.watchdogOk && result.nonceOk && result.forwardQueueOk
7220 let allOk = not result.isDraining && result.pruneOk && result.heartbeatOk && result.capacityOk && result.federationOk && result.watchdogOk && result.nonceOk && result.forwardQueueOk
7127− -- Unauthenticated: return real ok boolean so load balancers can route around unhealthy nodes,
7128− -- but hide subsystem breakdown to prevent targeted load-correlation attacks.
7129− -- Draining status is always visible (load balancers need it for routing decisions).
7131− then yield (respond {ok: allOk, version: serverBuildVersion, pruneHealthy: result.pruneOk,
7221+ yield (respond {ok: allOk, version: serverBuildVersion, pruneHealthy: result.pruneOk,
7132 heartbeatHealthy: result.heartbeatOk,
7222 heartbeatHealthy: result.heartbeatOk,
7133 capacityHealthy: result.capacityOk,
7223 capacityHealthy: result.capacityOk,
7134 capacityWarning: result.capacityWarning,
7224 capacityWarning: result.capacityWarning,
@@ -7138,16 +7228,6 @@ handleHealth = \authorization respond -> do
7138 forwardQueueHealthy: result.forwardQueueOk,
7228 forwardQueueHealthy: result.forwardQueueOk,
7139 draining: result.isDraining,
7229 draining: result.isDraining,
7140 degradedPeers: result.degradedPeers})
7230 degradedPeers: result.degradedPeers})
7141− else yield (respond {ok: allOk, version: serverBuildVersion, pruneHealthy: True {},
7142− heartbeatHealthy: True {},
7143− capacityHealthy: True {},
7144− capacityWarning: False {},
7145− federationHealthy: True {},
7146− watchdogHealthy: True {},
7147− nonceHealthy: True {},
7148− forwardQueueHealthy: True {},
7149− draining: result.isDraining,
7152zeroStats = {ok: False {}, version: serverBuildVersion, capacity: {sessions: 0, sessionsMax: 0, sessionsPct: 0, messages: 0, messagesMax: 0, messagesPct: 0, totalMessageCount: 0, presence: 0, presenceMax: 0, presencePct: 0, forwards: 0, forwardsMax: 0, forwardsPct: 0, challenges: 0, challengesMax: 0, ackedIds: 0, ackedIdsMax: 0, ackOverflow: 0, ackOverflowMax: 0, deliveredIds: 0, deliveredIdsMax: 0, forwardNonces: 0, forwardNoncesMax: 0, activePolls: 0, activePollsMax: 0, recipientCounts: 0, messageBucketMax: 0, messageBucketMin: 0}, federation: {enabled: False {}, inFlight: 0, hungSlots: 0, avgLatencyMs: 0, maxLatencyMs: 0, callCount: 0, slowVerifies: 0, verifyFailures: 0, forwardFailures: 0, oldestForwardAge: 0, oldestAckAge: 0, recentGossip: 0, blockedPeers: 0, drainCompletedAt: 0, forwardServers: 0, maxForwardQueueDepth: 0, topFailingPeers: [], pendingCancels: 0, gossipSuccess: 0, gossipFail: 0}, rates: {send: 0, challenge: 0, federation: 0, federationPair: 0, global: 0, ip: 0, lookup: 0, recv: 0, pair: 0, gossipEvent: 0, verifyIp: 0, uniqueKeyIp: 0, pendingAckCleanups: 0, senderExpirations: 0, senderDeliveries: 0, rejectedGlobal: 0, rejectedIp: 0, rejectedAuth: 0, rejectedPerKey: 0, rejectedFederation: 0}, timing: {pruneCount: 0, pruneRunning: 0, heartbeatCount: 0, uptime: 0, lastPruneAt: 0, lastHeartbeatAt: 0, draining: False {}, drainPhase: 0, totalEntries: 0, activeRequests: 0, peakActiveRequests: 0, pollAvgMs: 0, pollMaxMs: 0, pollCount: 0, sendAvgMs: 0, sendMaxMs: 0, sendCount: 0}}
7232zeroStats = {ok: False {}, version: serverBuildVersion, capacity: {sessions: 0, sessionsMax: 0, sessionsPct: 0, messages: 0, messagesMax: 0, messagesPct: 0, totalMessageCount: 0, presence: 0, presenceMax: 0, presencePct: 0, forwards: 0, forwardsMax: 0, forwardsPct: 0, challenges: 0, challengesMax: 0, ackedIds: 0, ackedIdsMax: 0, ackOverflow: 0, ackOverflowMax: 0, deliveredIds: 0, deliveredIdsMax: 0, forwardNonces: 0, forwardNoncesMax: 0, activePolls: 0, activePollsMax: 0, recipientCounts: 0, messageBucketMax: 0, messageBucketMin: 0}, federation: {enabled: False {}, inFlight: 0, hungSlots: 0, avgLatencyMs: 0, maxLatencyMs: 0, callCount: 0, slowVerifies: 0, verifyFailures: 0, forwardFailures: 0, oldestForwardAge: 0, oldestAckAge: 0, recentGossip: 0, blockedPeers: 0, drainCompletedAt: 0, forwardServers: 0, maxForwardQueueDepth: 0, topFailingPeers: [], pendingCancels: 0, gossipSuccess: 0, gossipFail: 0}, rates: {send: 0, challenge: 0, federation: 0, federationPair: 0, global: 0, ip: 0, lookup: 0, recv: 0, pair: 0, gossipEvent: 0, verifyIp: 0, uniqueKeyIp: 0, pendingAckCleanups: 0, senderExpirations: 0, senderDeliveries: 0, rejectedGlobal: 0, rejectedIp: 0, rejectedAuth: 0, rejectedPerKey: 0, rejectedFederation: 0}, timing: {pruneCount: 0, pruneRunning: 0, heartbeatCount: 0, uptime: 0, lastPruneAt: 0, lastHeartbeatAt: 0, draining: False {}, drainPhase: 0, totalEntries: 0, activeRequests: 0, peakActiveRequests: 0, pollAvgMs: 0, pollMaxMs: 0, pollCount: 0, sendAvgMs: 0, sendMaxMs: 0, sendCount: 0}}
@@ -8727,6 +8807,7 @@ main = do
8727 && clockSkewTolerance < challengeExpiry
8807 && clockSkewTolerance < challengeExpiry
8728 && maxMessagesPerRecipient <= maxMessages
8808 && maxMessagesPerRecipient <= maxMessages
8729 && maxForwardsPerKey <= maxForwardsEntries
8809 && maxForwardsPerKey <= maxForwardsEntries
8810+ && maxForwardsPerSenderPerKey <= maxForwardsPerKey
8730 && maxForwardsPerServer <= maxForwardsEntries
8811 && maxForwardsPerServer <= maxForwardsEntries
8731 && maxSessionsPerKey <= maxSessions
8812 && maxSessionsPerKey <= maxSessions
8732 && maxChallengesPerKey <= maxChallenges
8813 && maxChallengesPerKey <= maxChallenges
@@ -8744,7 +8825,7 @@ main = do
8744 if not configOk
8825 if not configOk
8745 then logError "FATAL: invalid configuration — capacity limits and intervals must be > 0, server not started"
8826 then logError "FATAL: invalid configuration — capacity limits and intervals must be > 0, server not started"
8746 else if not configRelationsOk
8827 else if not configRelationsOk
8747− then logError "FATAL: invalid configuration — relationship invariants violated (check: unackedMessageMaxAge <= messageMaxAge, challengeExpiry < sessionExpiry, sessionExpiry <= maxSessionLifetime, retryBackoffBase <= retryBackoffMax, backgroundPruneInterval < healthPruneMaxAge, pollTimeoutMs < sessionExpiry, gossipDedupWindow < presenceRefreshInterval, federationFetchTimeoutMs <= backgroundPruneInterval, maxAckOverflow <= maxAckedIds, gossipOriginHops <= maxGossipHops, maxThirdPartyGossipHops <= maxGossipHops, verificationTokenExpiry <= federationFetchTimeoutMs, forwardNonceRetention >= messageMaxAge, maxGlobalFederationConcurrency >= maxFederationConcurrency, maxGossipEventsPerServer > maxGossipEvents, per-key/per-server limits <= global limits), server not started"
8828+ then logError "FATAL: invalid configuration — relationship invariants violated (check: unackedMessageMaxAge <= messageMaxAge, challengeExpiry < sessionExpiry, sessionExpiry <= maxSessionLifetime, retryBackoffBase <= retryBackoffMax, backgroundPruneInterval < healthPruneMaxAge, pollTimeoutMs < sessionExpiry, gossipDedupWindow < presenceRefreshInterval, federationFetchTimeoutMs <= backgroundPruneInterval, maxAckOverflow <= maxAckedIds, gossipOriginHops <= maxGossipHops, maxThirdPartyGossipHops <= maxGossipHops, verificationTokenExpiry <= federationFetchTimeoutMs, forwardNonceRetention >= messageMaxAge, maxGlobalFederationConcurrency >= maxFederationConcurrency, maxGossipEventsPerServer > maxGossipEvents, maxForwardsPerSenderPerKey <= maxForwardsPerKey, per-key/per-server limits <= global limits), server not started"
8749 t <- now
8830 t <- now
8750 atomic do
8831 atomic do
@@ -8767,7 +8848,9 @@ main = do
8767 (logWarn ("serverHost is " ++ serverHost ++ " — peers cannot reach this server for federation"))
8848 (logWarn ("serverHost is " ++ serverHost ++ " — peers cannot reach this server for federation"))
8768 when (federationEnabled && isBadServerName serverHostN)
8849 when (federationEnabled && isBadServerName serverHostN)
8769 (logError ("serverHost '" ++ serverHost ++ "' would be rejected by isBadServerName — federation peers cannot forward messages here. Set serverHost to a public DNS name."))
8850 (logError ("serverHost '" ++ serverHost ++ "' would be rejected by isBadServerName — federation peers cannot forward messages here. Set serverHost to a public DNS name."))
8852+ then logInfo "IP binding: required — challenge requests without ClientIp header are rejected"
8770− logInfo "IP binding is active — sessions created with a ClientIp header will reject requests without one"
8853+ else logInfo "IP binding: optional — sessions created with a ClientIp header will reject requests without one"
8771 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."
8854 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."
8772 when federationEnabled
8855 when federationEnabled
8773 (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.")
8856 (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.")
@@ -8786,7 +8869,7 @@ main = do
8786 logInfo (" max messages: " ++ show maxMessages ++ " (per recipient: " ++ show maxMessagesPerRecipient ++ ")")
8869 logInfo (" max messages: " ++ show maxMessages ++ " (per recipient: " ++ show maxMessagesPerRecipient ++ ")")
8787 logInfo (" max sessions: " ++ show maxSessions ++ " (per key: " ++ show maxSessionsPerKey ++ ")")
8870 logInfo (" max sessions: " ++ show maxSessions ++ " (per key: " ++ show maxSessionsPerKey ++ ")")
8788 logInfo (" max presence: " ++ show maxPresenceEntries)
8871 logInfo (" max presence: " ++ show maxPresenceEntries)
8789− logInfo (" max forwards: " ++ show maxForwardsEntries ++ " (per key: " ++ show maxForwardsPerKey ++ ", per server: " ++ show maxForwardsPerServer ++ ")")
8872+ logInfo (" max forwards: " ++ show maxForwardsEntries ++ " (per key: " ++ show maxForwardsPerKey ++ ", per sender-key: " ++ show maxForwardsPerSenderPerKey ++ ", per server: " ++ show maxForwardsPerServer ++ ")")
8790 logInfo (" session expiry: " ++ show sessionExpiry ++ "ms (max lifetime: " ++ show maxSessionLifetime ++ "ms)")
8873 logInfo (" session expiry: " ++ show sessionExpiry ++ "ms (max lifetime: " ++ show maxSessionLifetime ++ "ms)")
8791 logInfo (" message max age: " ++ show messageMaxAge ++ "ms")
8874 logInfo (" message max age: " ++ show messageMaxAge ++ "ms")
8792 logInfo (" max active requests: " ++ if maxActiveRequests > 0 then show maxActiveRequests else "unlimited")
8875 logInfo (" max active requests: " ++ if maxActiveRequests > 0 then show maxActiveRequests else "unlimited")