Summary
A peer ban does not survive a node restart, so the connection gater silently stops enforcing it while the banned peer's biscuit is still valid.
The gater (internal/node/gate.go) is what stops a node dialling or accepting a banned peer. InterceptPeerDial and InterceptSecured each consult two sources:
if g.node.revokedPeers != nil && g.node.revokedPeers.Contains(p.String()) { return false }
if g.node.Store.IsBanned(p) { return false }
Both are empty after a restart:
revokedPeers is an lru.Cache bounded at RevocationCacheSize (10,000) and held only in memory, so it dies with the process.
Store.IsBanned reads the banned_peers bucket — and nothing in the node ever writes it. The bucket is created (store.go:82) and read (store.go:319), but the writer was never added, so in production IsBanned always returns false.
Nothing re-tells a restarted node either. MeshEvent_BANNED is published exactly once, at the moment of the ban (server.go:1699 and server.go:1976), and gossip has no replay. There is no startup reconciliation. The control plane does record the ban durably via SetNodeBanned, so the truth exists — it is just never propagated again.
Net effect: an operator revokes a compromised peer. Every node learns it over gossip and enforces it. Any node that then restarts — deploy, crash, upgrade, autoscale — resumes dialling and accepting that peer for the remainder of its already-issued biscuit.
This is distinct from the /refresh enforcement documented in control-plane-configuration.md. That stops the banned node renewing its own token; it does nothing to make other nodes refuse a peer whose current biscuit is still valid. Peer-side refusal is the gater's job, and the gater's state is non-durable.
Why this reads as an oversight rather than a design choice
The sibling mesh event already has exactly this durability:
handleKeyRotationEvent → addTrustedKey → persistTrustedKeys → Store.SaveTrustedKeys, whose own comment states the principle:
"persists the full set of control plane public keys the node currently trusts, so keys learned from rotation events or /keys survive restarts"
handleBannedEvent updates only the in-memory cache. Same event source, same durability requirement, different treatment — and the persistent bucket plus the IsBanned reader wired into both gater methods show the intent was there.
Existing coverage cannot see it: TestConnectionGater's banned-peer case writes the bucket directly with store.db.Update(...), because until now there was no API to write it. The test proves the gater reads the bucket; nothing proves anything fills it.
Reproduction
Deliver a BANNED event, confirm the gater denies the peer, then close and reopen the store with a fresh cache (a restart) and ask again:
--- FAIL: TestBannedEventSurvivesRestart
gate_test.go:351: after a restart the gater still dials a banned peer: the ban did not survive
gate_test.go:354: after a restart the gater still accepts a banned peer: the ban did not survive
Proposed fix, and what it does not cover
The narrow part is a missing writer, and I've opened a PR for it: persist the ban in handleBannedEvent so the bucket the gater already reads is populated. That closes the restart case.
Two related gaps need a design decision rather than a missing writer, which is why I'm raising them here instead of folding them into that PR:
-
A node that was offline when the event was published never learns the ban at all. Persisting on receipt does not help a node that never received it. Options: reconcile against the control plane at startup, or have nodes fetch a ban list alongside /keys. Both add an API surface and a trust/staleness question, so they seem like yours to choose.
-
internal/router has the same lifetime problem. router.go:612 stores bans in an in-memory sync.Map (r.bannedPeers.Store(...)) with no persistence, so a router restart forgets them the same way. Whether routers should persist bans or always reconcile centrally probably depends on the answer to (1).
Happy to implement either direction once you've said which shape you want.
Environment
main at 935be05.
Summary
A peer ban does not survive a node restart, so the connection gater silently stops enforcing it while the banned peer's biscuit is still valid.
The gater (
internal/node/gate.go) is what stops a node dialling or accepting a banned peer.InterceptPeerDialandInterceptSecuredeach consult two sources:Both are empty after a restart:
revokedPeersis anlru.Cachebounded atRevocationCacheSize(10,000) and held only in memory, so it dies with the process.Store.IsBannedreads thebanned_peersbucket — and nothing in the node ever writes it. The bucket is created (store.go:82) and read (store.go:319), but the writer was never added, so in productionIsBannedalways returnsfalse.Nothing re-tells a restarted node either.
MeshEvent_BANNEDis published exactly once, at the moment of the ban (server.go:1699andserver.go:1976), and gossip has no replay. There is no startup reconciliation. The control plane does record the ban durably viaSetNodeBanned, so the truth exists — it is just never propagated again.Net effect: an operator revokes a compromised peer. Every node learns it over gossip and enforces it. Any node that then restarts — deploy, crash, upgrade, autoscale — resumes dialling and accepting that peer for the remainder of its already-issued biscuit.
This is distinct from the
/refreshenforcement documented incontrol-plane-configuration.md. That stops the banned node renewing its own token; it does nothing to make other nodes refuse a peer whose current biscuit is still valid. Peer-side refusal is the gater's job, and the gater's state is non-durable.Why this reads as an oversight rather than a design choice
The sibling mesh event already has exactly this durability:
handleKeyRotationEvent→addTrustedKey→persistTrustedKeys→Store.SaveTrustedKeys, whose own comment states the principle:handleBannedEventupdates only the in-memory cache. Same event source, same durability requirement, different treatment — and the persistent bucket plus theIsBannedreader wired into both gater methods show the intent was there.Existing coverage cannot see it:
TestConnectionGater's banned-peer case writes the bucket directly withstore.db.Update(...), because until now there was no API to write it. The test proves the gater reads the bucket; nothing proves anything fills it.Reproduction
Deliver a
BANNEDevent, confirm the gater denies the peer, then close and reopen the store with a fresh cache (a restart) and ask again:Proposed fix, and what it does not cover
The narrow part is a missing writer, and I've opened a PR for it: persist the ban in
handleBannedEventso the bucket the gater already reads is populated. That closes the restart case.Two related gaps need a design decision rather than a missing writer, which is why I'm raising them here instead of folding them into that PR:
A node that was offline when the event was published never learns the ban at all. Persisting on receipt does not help a node that never received it. Options: reconcile against the control plane at startup, or have nodes fetch a ban list alongside
/keys. Both add an API surface and a trust/staleness question, so they seem like yours to choose.internal/routerhas the same lifetime problem.router.go:612stores bans in an in-memorysync.Map(r.bannedPeers.Store(...)) with no persistence, so a router restart forgets them the same way. Whether routers should persist bans or always reconcile centrally probably depends on the answer to (1).Happy to implement either direction once you've said which shape you want.
Environment
mainat935be05.