Skip to content

node: pull keys, bans and policy from the control plane in one loop - #453

Merged
aojea merged 2 commits into
google:mainfrom
aojea:node-control-plane-sync
Sep 20, 2026
Merged

aojea merged 2 commits into
google:mainfrom
aojea:node-control-plane-sync

Conversation

@aojea

@aojea aojea commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

What

Fixes the red testnet-health on hub: every fresh join has failed since 2026-09-19 19:45Z, which is what the cold-path probe measures. Also closes the gap that let it happen, by giving nodes the same pull-based safety net routers already have, in one place instead of three — and makes that place the only place node and router read from the control plane.

Why

Reproduced locally with sam-node run --join --control-plane https://hub.sam-mesh.dev:

Enrolling via HTTP at https://hub.sam-mesh.dev/register           <- OK
Failed to connect and auth with router after enrollment:
  fatal authentication error: failed to verify router biscuit:
  no valid key found for verification: biscuit: invalid signature

hub/keys advertises two signing keys, bananas/keys one: hub's control plane rotated its key 24h after the rc.1 rollout (bananas is redeployed several times a day, so its 24h rotation ticker never fires). The routers still present biscuits signed by the retiring key, legitimately inside its 48h grace period, but the enroll response carries only the newest key and Enroll() dials the router with just that one — main.go's post-enrollment /keys catch-up ran too late.

The underlying problem is wider than enrollment: a running node learned a rotated key, a ban or a policy change only from a gossip event that is published once with no replay — and the shipped control plane never publishes them (#317, fixed separately in #454). Routers poll /keys and /info periodically as a safety net; nodes had SyncMeshConfig at start, a separate policy loop, and the event handlers.

How (by commit)

  1. node: pull keys, bans and policy from the control plane in one loop
    • Enrollment fetches the full key set from /keys right after the response and before the router handshake, on both the OIDC and bootstrap paths (adoptEnrolledKeys).
    • One runtime loop (internal/node/controlplane_sync.go): SyncControlPlane pulls /keys, /info and /policies together; each part is attempted even if another fails. /info reconciles the ban set on the node the way the router already does. POLICY_UPDATE just triggers the unified pull; BANNED/KEY_ROTATION still apply immediately.
    • Replaces --policy-sync-interval with --control-plane-sync-interval.
  2. node: sync every 15m by default, spread the fleet's pulls
    • Default 15m (was going to be 5m): the one hard constraint is the control plane's --key-grace-period (1h default) — a successor key can only be adopted while its predecessor still vouches for it, so one attempt per window is a race; four is the trade against load on large meshes. Flag help and docs say to keep it well below the grace period and raise it on large meshes.
    • Periodic pulls jittered by interval/10; event-triggered pulls jittered by interval/10 by default instead of a fixed 10s, so a policy update on a large mesh does not land on the control plane at once.
  3. node, router: one client for the control plane's pull endpoints
    • internal/controlplane/client: the single GET /keys (verified) and GET /info implementation, plus the HTTP client with the per-hop plaintext check. Depends on api/ only, so it pulls in none of the control plane server. Node and router both use it; the router's /keys read gains the 1 MiB body cap.
    • SyncMeshConfig (the store-level copy of the startup pull) is gone: sam-node run and the mobile FFI build the node from the stored config and call SyncControlPlane before Start, which adopts the control plane's current router addresses in memory until the host exists. Options.BannedPeerIDs, which only carried SyncMeshConfig's ban set, goes with it.

Tests

  • TestEnrollTrustsRouterSignedByGraceKey (regression): enroll response carries only the current key, mock router signed by the grace-period key. Fails on the parent commit with the exact hub error.
  • TestSyncControlPlane, TestSyncControlPlaneBeforeStart (reachable and unreachable control plane), TestSyncControlPlaneRefusesUntrustedKeySet, TestSyncTrustedKeys, TestReconcileBannedPeers (incl. non-canonical peer ID encoding), TestControlPlaneSyncLoop, TestGaterEnforcesSeededBans.
  • internal/controlplane/client: /info decode and path, /keys adoption and rejection (stranger-signed, unsigned), status/decode/oversize errors, and the plaintext transport policy read per request.
  • Router: existing TestSyncKeysRequiresTrustedSignature and friends pass through the shared client.

Sending to CI for make test / integration / e2e — this laptop is short on resources for the full suites.

Related

  • control-plane: publish mesh events for real #454 gives the control plane a real publisher for the gossip events; with both merged, events are the fast path and this loop the safety net.
  • hub's VM router 173.255.113.127:4501 is unreachable from the outside (dial timeout); unrelated to this change.

Every fresh join on hub.sam-mesh.dev has failed since 2026-09-19 19:45Z
with "failed to verify router biscuit: no valid key found", which is
what the cold-path probe measures and why testnet-health has been red.

The control plane rotated its signing key 24h after the rc.1 rollout.
The routers still present biscuits signed by the retiring key, which is
legitimately in its 48h grace period, but the enroll response carries
only the newest key and the node dials the router with just that one
before main.go's post-enrollment /keys catch-up runs. bananas never
sees this because it is redeployed several times a day and its
rotation ticker never reaches 24h. Reproduced locally against hub
(hub /keys advertises two keys, bananas one).

The deeper gap is that a running node learned a rotated key, a ban or
a policy change only from a gossip event published once with no replay
(and the shipped control plane never publishes them, google#317). Routers
already poll /keys and /info as a safety net; nodes had three separate
partial paths: SyncMeshConfig at start, a policy loop, and the event
handlers.

Enrollment now fetches the full key set right after the response and
before the router handshake, on both the OIDC and bootstrap paths. At
runtime one loop pulls /keys, /info and /policies together every
--control-plane-sync-interval (5m, matching the router), reconciling
the ban set the same way the router does so a ban or an unban reaches
a running node without an event. POLICY_UPDATE now just brings the
next pull forward. --policy-sync-interval is replaced by the new flag.

Regression test: an enroll response carrying only the current key
against a router still signed by the grace-period key, which fails on
the parent commit with the exact error seen on hub.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request consolidates the synchronization of control plane data (signing keys, ban sets, router addresses, and mesh policy) into a single unified loop running on a 5-minute interval, replacing the separate policy sync loop. It also ensures that a newly enrolled node fetches the full set of trusted keys from the control plane before the router handshake, preventing failures when routers use keys still in their grace period. The feedback recommends adding defensive nil checks for the control plane sync trigger channel to prevent potential hangs if the node is constructed manually without initializing this channel.

Comment on lines +169 to +174
func (n *SamNode) triggerControlPlaneSync() {
select {
case n.controlPlaneSyncTrigger <- struct{}{}:
default:
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential indefinite blocks or hangs when SamNode is constructed manually (e.g., in tests or external integrations) without initializing the trigger channel, it is highly recommended to add a defensive nil check for n.controlPlaneSyncTrigger before attempting to send to it.

Suggested change
func (n *SamNode) triggerControlPlaneSync() {
select {
case n.controlPlaneSyncTrigger <- struct{}{}:
default:
}
}
func (n *SamNode) triggerControlPlaneSync() {
if n.controlPlaneSyncTrigger == nil {
return
}
select {
case n.controlPlaneSyncTrigger <- struct{}{}:
default:
}
}

Comment on lines +182 to +185
}
go func() {
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, we should defensively check if n.controlPlaneSyncTrigger is nil in startControlPlaneSyncLoop to avoid blocking indefinitely on the select case case <-n.controlPlaneSyncTrigger: if the channel was not initialized.

func (n *SamNode) startControlPlaneSyncLoop(ctx context.Context, interval time.Duration) {
	if interval <= 0 || n.Store == nil || n.controlPlaneSyncTrigger == nil {
		return
	}

Five minutes was the router's cadence, not a number chosen for nodes,
and a mesh can be a handful of laptops or a million devices. The one
constraint on the interval is the control plane's --key-grace-period
(1h by default): a successor key can only be adopted while the key it
replaces still vouches for it, and one attempt per window is a race.
Fifteen minutes gives four attempts per default window and a fifth of
the load; an operator who raises the grace period can raise this too,
and the flag now says so.

Every periodic pull is stretched by up to a tenth of the interval, and
the jitter applied to an event-triggered pull now defaults to the same
tenth instead of a fixed ten seconds, so a policy update on a large
mesh is spread over minutes rather than landing on the control plane
at once.
@aojea
aojea merged commit c6e012c into google:main Sep 20, 2026
20 checks passed
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.

1 participant