386f51edc6
The controller waits ~15 minutes with app mails after a crash boot of the host; it learns of the crash boot from this route. Reads /var/lib/felhom-crash-guard/state.json (read-only, no Proxmox call) and passes present/last_boot_at/last_boot_unclean/tripped through; a missing, unreadable or garbled file answers 200 present:false. Guest-token authed like every sibling route. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
103 lines
4.2 KiB
Go
103 lines
4.2 KiB
Go
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)
|
|
}
|