Files
felhom-agent/internal/guestnet/watchdog.go
T
admin c0966d753d feat(v0.92.0): guest-network watchdog (R-54) — supervise the guest's DHCP client
Closes the OPEN RISK in INCIDENT-guest-dhclient-killed-2026-07-20 §5. The guest's dhclient
is started once by ifupdown at boot and nothing supervises it; when it died on 2026-07-20
the guest ran another ~80 minutes on its unexpired lease, then lost its address and default
route and took the tunnel, hub reports, catalog sync and the controller->agent channel with
it (1h15m outage, healthy-looking for the first 80 minutes).

So liveness of the DHCP client is itself a probe: a DHCP guest is unhealthy the moment
`pgrep -x dhclient` comes back empty, while the lease is still live. Waiting for the address
to vanish is waiting out the silent window.

internal/guestnet: four fixed-shape pct exec probes (address, default route, interfaces
mode, dhclient liveness — parsers pinned to output captured live from 9201), the incident's
heal invocation verbatim, and dampers throughout: two consecutive bad probes, >=10 min
between heals, <=3/hour, observe-only while guest or agent uptime < 3 min. Refuses to act on
a static guest, an unknown mode, an unprobeable guest, or an unproven guest list (the source
is the pool-verified ListLXC ∩ felhom pool, never a bare ListLXC). A failed probe reads as
unknown, never as a dead client. Healthy cycles log a Debug line so "no alarms" and "never
probed" stay distinguishable. Not in the errc fan-out — a guest watchdog must never be able
to kill the agent.

guest_net is the repo's first default-ON gate (opt-out is `{"disable": true}`): it looks only
inward at guests we already own, and the failure exists on every box today.

Report block ships as GuestNetStatus, not the spec's WireGuestNet: Wire* is the DOWN
direction in this repo, report stanzas are *Status.

Red-proofs: classify reverted to IP-presence-only -> the July-20 fixture reports "healthy"
with zero heals; un-wiring the reporter and the goroutine fails the AST wiring test.

Also: `var version` was stale at 0.89.0 (ldflags hid it; `go run` did not).
2026-07-21 12:29:37 +02:00

378 lines
12 KiB
Go

package guestnet
import (
"context"
"log/slog"
"sort"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// GuestSource yields the guests this agent OWNS. Production passes the pool-verified source
// (ListLXC ∩ pool membership, audit A1) — never a bare ListLXC, which under a broad token would let
// the watchdog run dhclient inside a co-tenant's container.
type GuestSource interface {
Guests(ctx context.Context) ([]proxmox.Guest, error)
}
// Defaults. Every one of these is a damper: this watchdog runs a privileged command inside a
// customer's container, so it is designed to under-act.
const (
DefaultInterval = 60 * time.Second
// DefaultMinHealInterval is the per-guest cool-off between heals.
DefaultMinHealInterval = 10 * time.Minute
// DefaultMaxHealsPerHour caps a guest's heals; beyond it the watchdog only reports, because a
// guest needing a fourth heal in an hour has a problem dhclient cannot fix.
DefaultMaxHealsPerHour = 3
// DefaultSettle is the boot-race guard, applied to BOTH the guest's uptime and the agent's own.
// A guest that booted 40 s ago legitimately has no lease yet.
DefaultSettle = 3 * time.Minute
// requiredBadProbes: two CONSECUTIVE bad cycles before any heal. One blip is not a diagnosis.
requiredBadProbes = 2
)
// Watchdog probes each owned, running guest's network every interval and heals a DHCP guest whose
// client has died. It never returns an error: a guest-network fault is a reported fact, not an agent
// failure.
type Watchdog struct {
runner Runner
guests GuestSource
logger *slog.Logger
interval time.Duration
minHealInterval time.Duration
maxHealsPerHour int
settle time.Duration
now func() time.Time
startedAt time.Time
mu sync.Mutex
state map[int]*guestState
}
type guestState struct {
badProbes int
heals []time.Time // heal timestamps, pruned to the last hour
lastHealAt time.Time
lastState State
report GuestReport
}
// New builds a Watchdog with the shipped dampers. interval <= 0 uses DefaultInterval.
func New(runner Runner, guests GuestSource, interval time.Duration, logger *slog.Logger) *Watchdog {
if interval <= 0 {
interval = DefaultInterval
}
if logger == nil {
logger = slog.Default()
}
w := &Watchdog{
runner: runner,
guests: guests,
logger: logger,
interval: interval,
minHealInterval: DefaultMinHealInterval,
maxHealsPerHour: DefaultMaxHealsPerHour,
settle: DefaultSettle,
now: time.Now,
state: map[int]*guestState{},
}
w.startedAt = w.now()
return w
}
// SetDampers overrides the three rate limits from config. Non-positive values keep the default,
// the same "0 = package default" convention the storage watchdog and wg loop use.
func (w *Watchdog) SetDampers(minHealInterval time.Duration, maxHealsPerHour int, settle time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if minHealInterval > 0 {
w.minHealInterval = minHealInterval
}
if maxHealsPerHour > 0 {
w.maxHealsPerHour = maxHealsPerHour
}
if settle > 0 {
w.settle = settle
}
}
// Watch runs until ctx is cancelled. Started with `go wd.Watch(ctx)` — deliberately not part of the
// errc fan-out, because a guest-network watchdog must never be able to bring the agent down.
func (w *Watchdog) Watch(ctx context.Context) {
w.logger.Info("guestnet: watchdog starting",
"interval", w.interval, "min_heal_interval", w.minHealInterval,
"max_heals_per_hour", w.maxHealsPerHour, "settle", w.settle)
t := time.NewTicker(w.interval)
defer t.Stop()
w.Tick(ctx) // immediate baseline
for {
select {
case <-ctx.Done():
w.logger.Info("guestnet: watchdog shutting down", "reason", ctx.Err())
return
case <-t.C:
w.Tick(ctx)
}
}
}
// Tick performs one full sweep. Exported so the wiring test and the live STOP leg can drive exactly
// one cycle instead of waiting on a ticker.
func (w *Watchdog) Tick(ctx context.Context) {
guests, err := w.guests.Guests(ctx)
if err != nil {
// Unknown ownership ⇒ do nothing. Never fall back to an unfiltered guest list.
w.logger.Warn("guestnet: guest list unavailable — skipping sweep (ownership unproven)", "err", err)
return
}
for _, g := range guests {
if ctx.Err() != nil {
return
}
if g.Status != "running" {
w.forget(g.VMID)
continue
}
w.checkGuest(ctx, g)
}
}
// forget drops state for a guest that is no longer running, so a stopped-and-restarted guest starts
// from a clean slate rather than inheriting a stale bad-probe count.
func (w *Watchdog) forget(vmid int) {
w.mu.Lock()
delete(w.state, vmid)
w.mu.Unlock()
}
func (w *Watchdog) checkGuest(ctx context.Context, g proxmox.Guest) {
now := w.now()
p := w.probe(ctx, g.VMID)
state, detail := classify(p)
w.mu.Lock()
st := w.state[g.VMID]
if st == nil {
st = &guestState{}
w.state[g.VMID] = st
}
prev := st.lastState
st.lastState = state
st.pruneHeals(now)
rep := GuestReport{
VMID: g.VMID, Mode: string(p.Mode), IP: p.IP, HasRoute: p.HasRoute,
DHClientAlive: p.DHCPAlive, State: string(state), Message: detail,
HealsLastHour: len(st.heals), CheckedAt: now.UTC().Format(time.RFC3339),
}
if !st.lastHealAt.IsZero() {
rep.LastHealAt = st.lastHealAt.UTC().Format(time.RFC3339)
}
switch state {
case StateHealthy:
st.badProbes = 0
w.mu.Unlock()
// The healthy path MUST be observable. v0.91.2's lesson, learned the hard way one day
// earlier: if a healthy cycle logs nothing, "no alarms" and "never probed" are the same
// line of evidence, and an inert watchdog is indistinguishable from a working one.
w.logger.Debug("guestnet: guest network healthy", "vmid", g.VMID, "mode", string(p.Mode),
"has_route", p.HasRoute, "dhclient_alive", p.DHCPAlive)
if prev != "" && prev != StateHealthy {
w.logger.Info("guestnet: guest network recovered", "vmid", g.VMID, "previous_state", string(prev))
}
w.record(g.VMID, rep)
return
case StateUnknown, StateStaticFault:
st.badProbes = 0 // neither is a dhclient fault; don't accumulate toward a heal
w.mu.Unlock()
if prev != state { // loud once per transition, not once per minute
w.logger.Warn("guestnet: guest network not actionable — reporting only",
"vmid", g.VMID, "state", string(state), "mode", string(p.Mode),
"has_ip", p.IP != "", "has_route", p.HasRoute, "detail", detail)
}
w.record(g.VMID, rep)
return
}
// --- StateUnhealthy: a DHCP guest with something dhclient can fix ---------------------------
st.badProbes++
bad := st.badProbes
lastHeal := st.lastHealAt
healsInHour := len(st.heals)
w.mu.Unlock()
if reason, ok := w.observeOnly(g, now); !ok {
rep.Damped = true
rep.Message = detail + " — observing only: " + reason
w.logger.Info("guestnet: guest network unhealthy but not acting", "vmid", g.VMID,
"reason", reason, "detail", detail)
w.record(g.VMID, rep)
return
}
if bad < requiredBadProbes {
rep.Message = detail + " — awaiting a second consecutive bad probe before healing"
w.logger.Info("guestnet: guest network unhealthy (first bad probe — not acting yet)",
"vmid", g.VMID, "detail", detail, "bad_probes", bad, "required", requiredBadProbes)
w.record(g.VMID, rep)
return
}
if damped, reason := w.damped(lastHeal, healsInHour, now); damped {
rep.Damped = true
rep.Message = detail + " — heal damped: " + reason
w.logger.Warn("guestnet: guest network unhealthy but healing is DAMPED — reporting only",
"vmid", g.VMID, "reason", reason, "heals_last_hour", healsInHour, "detail", detail)
w.record(g.VMID, rep)
return
}
// --- heal ----------------------------------------------------------------------------------
w.logger.Warn("guestnet: guest network unhealthy — healing",
"vmid", g.VMID, "detail", detail, "bad_probes", bad)
healed, healErr := w.heal(ctx, g.VMID)
w.mu.Lock()
st = w.state[g.VMID]
if st != nil {
st.heals = append(st.heals, now)
st.lastHealAt = now
st.badProbes = 0 // the post-heal probe below is the new evidence
healsInHour = len(st.heals)
}
w.mu.Unlock()
after := w.probe(ctx, g.VMID)
afterState, afterDetail := classify(after)
rep = GuestReport{
VMID: g.VMID, Mode: string(after.Mode), IP: after.IP, HasRoute: after.HasRoute,
DHClientAlive: after.DHCPAlive, State: string(afterState), Message: afterDetail,
Healed: true, HealSucceeded: afterState == StateHealthy,
LastHealAt: now.UTC().Format(time.RFC3339), HealsLastHour: healsInHour,
CheckedAt: w.now().UTC().Format(time.RFC3339),
}
if healErr != nil {
rep.Message = "heal command failed: " + healErr.Error() + "; " + afterDetail
}
if afterState == StateHealthy {
w.logger.Info("guestnet: guest network healed", "vmid", g.VMID, "ip", after.IP,
"has_route", after.HasRoute, "dhclient_alive", after.DHCPAlive, "heals_last_hour", healsInHour)
} else {
w.logger.Error("guestnet: heal did not restore the guest network", "vmid", g.VMID,
"state", string(afterState), "detail", afterDetail, "heal_ran", healed, "err", healErr)
}
w.mu.Lock()
if st = w.state[g.VMID]; st != nil {
st.lastState = afterState
}
w.mu.Unlock()
w.record(g.VMID, rep)
}
// heal runs the incident's restored invocation, VERBATIM (INCIDENT-guest-dhclient-killed-2026-07-20
// §5) — the same argv that brought guest 9201 back at 10:04:3x UTC. Fixed shape, no shell, no guest
// data interpolated. Logged at INFO before it runs so the operator sees the exact command.
func (w *Watchdog) heal(ctx context.Context, vmid int) (bool, error) {
args := []string{"exec", itoa(vmid), "--", "dhclient",
"-pf", "/run/dhclient." + eth0 + ".pid",
"-lf", "/var/lib/dhcp/dhclient." + eth0 + ".leases", eth0}
w.logger.Info("guestnet: running heal command", "vmid", vmid, "cmd", "pct "+joinArgs(args))
_, errOut, err := w.runner.Run(ctx, "pct", args...)
if err != nil {
w.logger.Error("guestnet: heal command failed", "vmid", vmid, "stderr", firstLine(string(errOut)), "err", err)
return false, err
}
return true, nil
}
// observeOnly reports whether a boot race means this cycle must look and not touch.
func (w *Watchdog) observeOnly(g proxmox.Guest, now time.Time) (string, bool) {
if now.Sub(w.startedAt) < w.settle {
return "agent started less than " + w.settle.String() + " ago", false
}
if g.Uptime > 0 && time.Duration(g.Uptime)*time.Second < w.settle {
return "guest has been up for less than " + w.settle.String(), false
}
return "", true
}
// damped applies the two rate limits.
func (w *Watchdog) damped(lastHeal time.Time, healsInHour int, now time.Time) (bool, string) {
if !lastHeal.IsZero() && now.Sub(lastHeal) < w.minHealInterval {
return true, "last heal was less than " + w.minHealInterval.String() + " ago"
}
if healsInHour >= w.maxHealsPerHour {
return true, "heal budget for the hour is spent (a guest needing more than this has a fault dhclient cannot fix)"
}
return false, ""
}
func (s *guestState) pruneHeals(now time.Time) {
kept := s.heals[:0]
for _, t := range s.heals {
if now.Sub(t) < time.Hour {
kept = append(kept, t)
}
}
s.heals = kept
}
func (w *Watchdog) record(vmid int, rep GuestReport) {
w.mu.Lock()
defer w.mu.Unlock()
if st := w.state[vmid]; st != nil {
st.report = rep
}
}
func joinArgs(args []string) string {
out := ""
for i, a := range args {
if i > 0 {
out += " "
}
out += a
}
return out
}
// --- the hub report block ------------------------------------------------------------------------
// GuestReport is one guest's last observed network state, mirrored to the hub.
type GuestReport struct {
VMID int `json:"vmid"`
State string `json:"state"` // healthy | unhealthy | static_fault | unknown
Mode string `json:"mode"` // dhcp | static | unknown
IP string `json:"ip,omitempty"`
HasRoute bool `json:"has_route"`
DHClientAlive bool `json:"dhclient_alive"`
CheckedAt string `json:"checked_at,omitempty"`
Healed bool `json:"healed,omitempty"` // a heal ran on THIS cycle
HealSucceeded bool `json:"heal_succeeded,omitempty"` // and the re-probe came back healthy
LastHealAt string `json:"last_heal_at,omitempty"`
HealsLastHour int `json:"heals_last_hour,omitempty"`
Damped bool `json:"damped,omitempty"`
Message string `json:"message,omitempty"`
}
// Snapshot returns the per-guest blocks for the heartbeat, VMID-sorted for a stable wire shape.
func (w *Watchdog) Snapshot() []GuestReport {
w.mu.Lock()
defer w.mu.Unlock()
out := make([]GuestReport, 0, len(w.state))
for _, st := range w.state {
if st.report.VMID != 0 {
out = append(out, st.report)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].VMID < out[j].VMID })
return out
}