Skip to content

daemon: stop the KX rate limiter wedging key exchange permanently - #436

Merged
TeoSlayer merged 1 commit into
mainfrom
fix/kx-ratelimit-permanent-wedge
Jul 29, 2026
Merged

daemon: stop the KX rate limiter wedging key exchange permanently#436
TeoSlayer merged 1 commit into
mainfrom
fix/kx-ratelimit-permanent-wedge

Conversation

@TeoSlayer

Copy link
Copy Markdown
Collaborator

Remote, permanent denial of key exchange

allowKxFromSource tracked one bucket per source IP in kxRateLim and never removed any. There is no delete(tm.kxRateLim, ...) anywhere in the package — the only deletes in tunnel.go are for relayKxLim, lastPendDropLog, lastRekeyReq, pending and peers.

So once maxPerSrcKxEntries (4096) distinct source IPs had ever sent a PILA/PILK frame:

if len(tm.kxRateLim) >= maxPerSrcKxEntries {
    return false
}

…every subsequent new source IP was refused for the life of the process. Key exchange with any new peer was permanently dead, with no recovery short of a restart.

A peer spraying spoofed source IPs fills all 4096 slots in a single burst. That turns a rate limiter into a remote, permanent DoS — and it fails closed, so the daemon looks healthy while quietly refusing every new peer.

The fix was already thirty lines away

The sibling relay limiter calls pruneRelayKxLocked before rejecting. This mirrors it exactly with pruneKxRateLimLocked, reusing relayKxPruneAge (10s).

Pruning costs no rate-limiting accuracy. A bucket idle for 10s has long since refilled — perSourceKxLimit tokens accrue every second — so dropping it is equivalent to keeping it: the next frame from that IP recreates it with a full budget.

Tests

  • a full table of stale entries must still admit a new peer — fails against the unfixed code
  • a full table of active entries must still refuse, proving the prune is a reclaim and not a hole in the cap
  • the per-source budget is unchanged
  • the pre-existing TestKxRateLimiterMaxEntries still passes

Full pkg/daemon suite green (21.6s).

Provenance

Found during a codebase-wide sweep for safety mechanisms that silently fail to engage — the same class as the dead idle-timeout that OOM-killed the service fleet, and as connAdapter.SetReadDeadline returning nil without storing anything.

🤖 Generated with Claude Code

allowKxFromSource tracked one bucket per source IP in kxRateLim and never
removed any. There was no delete(tm.kxRateLim, ...) anywhere in the package —
the only deletes in tunnel.go are for relayKxLim, lastPendDropLog,
lastRekeyReq, pending and peers.

So once maxPerSrcKxEntries (4096) distinct source IPs had EVER sent a
PILA/PILK frame, this branch:

    if len(tm.kxRateLim) >= maxPerSrcKxEntries {
        return false
    }

refused every subsequent new source IP for the life of the process. Key
exchange with any new peer was permanently dead, with no recovery short of a
restart. A peer spraying spoofed source IPs fills all 4096 slots in a single
burst, which turns a rate limiter into a remote, permanent denial of service.

The correct pattern was already thirty lines away: the sibling relay limiter
calls pruneRelayKxLocked before rejecting. This mirrors it exactly with
pruneKxRateLimLocked, reusing relayKxPruneAge (10s).

Pruning costs no rate-limiting accuracy. A bucket idle for 10s has long since
refilled — perSourceKxLimit tokens accrue every second — so dropping it is
equivalent to keeping it: the next frame from that IP recreates it with a
full budget. The cap still holds when the table is genuinely active.

Tests: a full table of stale entries must still admit a new peer (fails
against the unfixed code); a full table of ACTIVE entries must still refuse,
so the prune is a reclaim and not a hole in the cap; and the per-source
budget is unchanged. The pre-existing TestKxRateLimiterMaxEntries still
passes.

Found during a codebase-wide sweep for safety mechanisms that silently fail
to engage — the same class as the dead idle-timeout that OOM-killed the
service fleet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TeoSlayer
TeoSlayer merged commit 9a75dd3 into main Jul 29, 2026
14 checks passed
@TeoSlayer
TeoSlayer deleted the fix/kx-ratelimit-permanent-wedge branch July 29, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants