package localapi import ( "encoding/json" "errors" "io" "io/fs" "net/http" "os" "time" ) // GET /host/crash-guard (R-856, `09` §3 decision 143): what the host's crash guard // (configs/felhom-crash-guard, `11` §5.9) recorded about the most recent HOST boot. The controller // reads it once after it starts: when the host's last boot followed an UNCLEAN stop, its app mails // wait ~15 minutes instead of the normal 90 s boot grace. // // Read-only and Proxmox-free: the agent reads the guard's state file (root-owned, 0644 — the // non-root agent can read it) and passes four fields through. Host-wide, token-authed (any valid // per-guest token sees the host's view, as GET /host/metrics does). // // NEVER an error page. A missing file (no guard installed, or no boot recorded yet), an unreadable // one, or one that does not parse answers 200 with present:false — the controller reads that as // UNKNOWN and keeps its normal boot grace. Pinned by TestR856_CrashGuard*. // defaultCrashGuardStatePath is where configs/felhom-crash-guard writes its state (STATE_DIR there). const defaultCrashGuardStatePath = "/var/lib/felhom-crash-guard/state.json" // crashGuardStateMax bounds the read; the real file is well under 4 KiB. const crashGuardStateMax = 1 << 20 // CrashGuardResponse is the data block of GET /host/crash-guard. Field names are the controller's // agentapi.CrashGuardState (felhom-controller internal/agentapi/crashguard.go) — a wire contract, // pinned by TestR856_CrashGuardWireMatchesControllerClient. type CrashGuardResponse struct { Present bool `json:"present"` LastBootAt string `json:"last_boot_at,omitempty"` // RFC3339 UTC ("2006-01-02T15:04:05Z") LastBootUnclean bool `json:"last_boot_unclean"` Tripped bool `json:"tripped"` } // crashGuardFile is the subset of the guard's state.json the route passes through. Every other key // (armed, boot_id, config, unclean_boots, last_trip, ...) is ignored. type crashGuardFile struct { LastBootAt string `json:"last_boot_at"` LastBootUnclean bool `json:"last_boot_unclean"` Tripped bool `json:"tripped"` } // readCrashGuardState reads and parses the guard's state file. ok=false on ANY failure (missing, // unreadable, oversized, not a JSON object, a field of the wrong type); reason says which, for the log. func readCrashGuardState(path string) (resp CrashGuardResponse, ok bool, reason string) { f, err := os.Open(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return resp, false, "no state file" } return resp, false, "unreadable: " + err.Error() } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, crashGuardStateMax+1)) if err != nil { return resp, false, "read: " + err.Error() } if len(raw) > crashGuardStateMax { return resp, false, "state file too large" } var st crashGuardFile // Unmarshal into a struct fails on a non-object top level (null decodes, so reject it below). if err := json.Unmarshal(raw, &st); err != nil { return resp, false, "unparseable: " + err.Error() } var probe map[string]json.RawMessage if err := json.Unmarshal(raw, &probe); err != nil || probe == nil { return resp, false, "unparseable: not a JSON object" } resp = CrashGuardResponse{Present: true, LastBootUnclean: st.LastBootUnclean, Tripped: st.Tripped} // Normalise to RFC3339 UTC; an unparseable time passes through as-is (the controller reads an // unparseable boot time as "not this start's boot" → its normal grace). if t, perr := time.Parse(time.RFC3339, st.LastBootAt); perr == nil { resp.LastBootAt = t.UTC().Format(time.RFC3339) } else { resp.LastBootAt = st.LastBootAt } return resp, true, "" } func (s *Server) handleCrashGuard(w http.ResponseWriter, r *http.Request, vmid int) { path := s.crashGuardStatePath if path == "" { path = defaultCrashGuardStatePath } resp, ok, reason := readCrashGuardState(path) if !ok { s.logger.Debug("local-api: /host/crash-guard not present", "vmid", vmid, "reason", reason) writeOK(w, CrashGuardResponse{Present: false}) return } s.logger.Debug("local-api: /host/crash-guard served", "vmid", vmid, "last_boot_at", resp.LastBootAt, "last_boot_unclean", resp.LastBootUnclean, "tripped", resp.Tripped) writeOK(w, resp) }