fix(felhomsshd): don't empty the belt/authkeys on an unfetched block (operator lockout)

Mirrors wgtunnel fetched=false-is-never-a-teardown: until the desired-state is
delivered, @operator_ips + felhom-op authorized_keys are left untouched (a nil
block on agent restart would otherwise empty @operator_ips and lock the operator
out until the next fetch). Belt.Sync split into SyncPort (always) + SyncOperator
(fetched only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 22:51:30 +02:00
parent b99d02b7a9
commit 2bf9354c0e
3 changed files with 43 additions and 25 deletions
+23 -9
View File
@@ -19,8 +19,9 @@ type Loop struct {
interval time.Duration
logger *slog.Logger
mu sync.Mutex
block *hub.WireWireguard
mu sync.Mutex
fetched bool // a desired-state document has been delivered (false = never a teardown signal)
block *hub.WireWireguard
nudge chan struct{}
}
@@ -42,6 +43,7 @@ func (l *Loop) OnDesiredState(_ context.Context, resp *hub.DesiredStateResponse)
return
}
l.mu.Lock()
l.fetched = true
l.block = resp.DesiredState.Wireguard
l.mu.Unlock()
select {
@@ -50,10 +52,10 @@ func (l *Loop) OnDesiredState(_ context.Context, resp *hub.DesiredStateResponse)
}
}
func (l *Loop) snapshot() *hub.WireWireguard {
func (l *Loop) snapshot() (bool, *hub.WireWireguard) {
l.mu.Lock()
defer l.mu.Unlock()
return l.block
return l.fetched, l.block
}
// Run reconciles immediately, then on every tick or desired-state nudge, until ctx is cancelled.
@@ -72,15 +74,26 @@ func (l *Loop) Run(ctx context.Context) error {
}
}
// reconcile runs one full pass: instance apply → belt sync → health/heal.
// reconcile runs one full pass: instance apply → belt sync → health/heal. The `fetched` gate mirrors
// wgtunnel: until a desired-state document arrives, the operator inputs (authorized_keys, @operator_ips)
// are LEFT UNTOUCHED — an unfetched block must never empty the belt (operator lockout) or wipe the key.
func (l *Loop) reconcile(ctx context.Context) {
block := l.snapshot()
port, err := l.mgr.Apply(ctx, block)
fetched, block := l.snapshot()
// Pass the block to Apply only when fetched, so authorized_keys is applied only from real desired
// state (a nil block on Apply skips the authorized_keys write, leaving the existing file).
applyBlock := block
if !fetched {
applyBlock = nil
}
port, err := l.mgr.Apply(ctx, applyBlock)
if err != nil {
return // Apply logged; a claim/exhaustion or install error — retry next tick
}
if l.belt != nil {
l.belt.Sync(ctx, port, oobPeerIP(block))
l.belt.SyncPort(ctx, port) // always — the port is the agent's own claim
if fetched {
l.belt.SyncOperator(ctx, oobPeerIP(block)) // only from real desired state
}
}
l.mgr.HealAndCheck(ctx, port)
}
@@ -95,5 +108,6 @@ func oobPeerIP(block *hub.WireWireguard) string {
// OOBStatus implements the hub collector's reporter seam (Part 4).
func (l *Loop) OOBStatus(ctx context.Context) *hub.OOBStatus {
return l.mgr.Status(ctx, l.snapshot())
_, block := l.snapshot()
return l.mgr.Status(ctx, block)
}