package store import "encoding/json" // HostOOBRow is the latest operator-access (OOB) state per host (TASK H1), parsed from the newest // host_report. Present is false when the agent sent no oob stanza (pre-H1 / feature off) → never // alerted. // // ⚠ THIS STRUCT USED TO MIRROR FIVE OF THE AGENT'S EIGHT OOB FIELDS, and the three it dropped // included the one that decides the question the OOB checker exists to answer (R-260, G-1). The // agent has emitted `operator_key_configured` on every heartbeat since v0.72.0 — the same version // that introduced the stanza itself — and having no field for it here meant encoding/json discarded // it on arrival, so `oobDegraded` reported a box with felhom-sshd active, reachable, a valid config, // a configured peer and NO OPERATOR KEY INSTALLED as `ok`. The agent knew and said so. type HostOOBRow struct { HostID string CustomerID string Present bool FelhomSshdActive bool FelhomSshdPort int Reachable bool ConfigInvalid bool OperatorPeerConfigured bool // OperatorKeyConfigured — the operator's authorized_key is actually installed on the box. // `operator_peer_configured` above is NOT a substitute: that one only says the peer IP is in // desired-state, i.e. that OOB is MEANT to work. This says the credential that actually grants // entry is there. OperatorKeyConfigured bool // OperatorKeyReported distinguishes "the agent said false" from "the agent never said". // Absence must never read as "the key is installed" — that is this defect returning through the // version door, and this project has watched an absence read as a fact four times. // // It cannot be false while Present is true for any RELEASED agent: the field and the stanza // shipped together in v0.72.0 (2026-07-05), and the vouched floor is far above it. That is a // claim, so it is pinned by TestOOBDecode_StanzaWithoutOperatorKey_IsNotSilentlyOK rather than // left as a comment. OperatorKeyReported bool // WGHandshakeAgeS / HealedAt are carried for the ALERT MESSAGE, not for the predicate — the // operator reads this at 07:00 and needs the context, but a check that starts failing for // reasons nobody asked for is how a check stops being read. nil / "" when not reported. WGHandshakeAgeS *int64 HealedAt string } // decodeOOBInto parses the `oob` stanza of one raw host report into r. // // It is a named function rather than an inline literal SO THAT TESTS CAN DRIVE THE REAL DECODE // BOUNDARY. A test that constructs HostOOBRow by hand cannot see a field that never decodes, which // is the entire class of defect this exists because of (R-260): every hub test asked what the // checker did with a row, none asked whether the row could carry the fact. func decodeOOBInto(r *HostOOBRow, reportJSON string) error { var body struct { OOB *struct { FelhomSshdActive bool `json:"felhom_sshd_active"` FelhomSshdPort int `json:"felhom_sshd_port"` Reachable bool `json:"reachable"` ConfigInvalid bool `json:"config_invalid"` OperatorPeerConfigured bool `json:"operator_peer_configured"` OperatorKeyConfigured *bool `json:"operator_key_configured"` WGHandshakeAgeS *int64 `json:"wg_handshake_age_s"` HealedAt string `json:"healed_at"` } `json:"oob"` } err := json.Unmarshal([]byte(reportJSON), &body) if body.OOB == nil { return err } r.Present = true r.FelhomSshdActive = body.OOB.FelhomSshdActive r.FelhomSshdPort = body.OOB.FelhomSshdPort r.Reachable = body.OOB.Reachable r.ConfigInvalid = body.OOB.ConfigInvalid r.OperatorPeerConfigured = body.OOB.OperatorPeerConfigured // A POINTER, deliberately: nil means the agent never said, which is not the same as saying no. if body.OOB.OperatorKeyConfigured != nil { r.OperatorKeyReported = true r.OperatorKeyConfigured = *body.OOB.OperatorKeyConfigured } r.WGHandshakeAgeS = body.OOB.WGHandshakeAgeS r.HealedAt = body.OOB.HealedAt return err } // GetHostOOBStates returns the latest oob stanza per host (mirrors GetHostMgmtPlaneStates). A report // without the stanza yields Present=false; malformed JSON degrades to zero values, never an error. func (s *Store) GetHostOOBStates() ([]HostOOBRow, error) { rows, err := s.db.Query(` SELECT hr.host_id, hr.customer_id, hr.report_json FROM host_reports hr JOIN (SELECT host_id, MAX(id) AS mx FROM host_reports GROUP BY host_id) latest ON hr.id = latest.mx`) if err != nil { return nil, err } defer rows.Close() var out []HostOOBRow for rows.Next() { var r HostOOBRow var reportJSON string if err := rows.Scan(&r.HostID, &r.CustomerID, &reportJSON); err != nil { return nil, err } if err := decodeOOBInto(&r, reportJSON); err != nil { // malformed JSON degrades to zero values, never an error (documented above) _ = err } out = append(out, r) } return out, rows.Err() }