package store import ( "database/sql" "encoding/json" "fmt" "strings" "time" ) // HostRecoveryCredential is the break-glass PVE console credential for a host (TASK G1). Secret is // the root@pam password — a hub-held secret, operator-retrievable (NOT zero-knowledge like escrow). type HostRecoveryCredential struct { HostID string Username string Secret string SetAt time.Time } // SaveHostRecoveryCredential upserts a host's break-glass credential (last-write-wins: day-0 sets it, // --rotate re-sets). R-133 (hub v0.135.0): the secret is SEALED at rest with the same key and the same // helpers as the off-site sub-account passwords (offsite_seal.go, OFFSITE_SECRET_KEY) — a copy of hub.db // alone no longer holds any box's console password. No key → the save is REFUSED (ErrNoSealKey): a hub that // cannot seal must not fall back to plaintext. The hub NEVER logs the secret and only ever returns it over // the operator-authenticated retrieval paths. Pinned by r133_recovery_seal_test.go. func (s *Store) SaveHostRecoveryCredential(hostID, username, secret string) error { sealed, err := s.sealSecret(secret) if err != nil { return err } _, err = s.db.Exec(` INSERT INTO host_recovery (host_id, username, secret, set_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(host_id) DO UPDATE SET username = excluded.username, secret = excluded.secret, updated_at = datetime('now')`, hostID, username, sealed) return err } // GetHostRecoveryCredential returns a host's break-glass credential, or (nil, nil) if none is vaulted. func (s *Store) GetHostRecoveryCredential(hostID string) (*HostRecoveryCredential, error) { var c HostRecoveryCredential var setAt string err := s.db.QueryRow( `SELECT host_id, username, secret, set_at FROM host_recovery WHERE host_id = ?`, hostID). Scan(&c.HostID, &c.Username, &c.Secret, &setAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } // R-133: open the sealed value. A wrong or missing key, or a row still in plaintext (the start-up // sealing has not run), is an ERROR — never a fallback that hands out what the column holds. plain, err := s.openSecret(c.Secret) if err != nil { return nil, fmt.Errorf("host_recovery %s: %w", hostID, err) } c.Secret = plain c.SetAt = parseSQLiteTime(setAt) return &c, nil } // HostRecoveryMeta is the NON-SECRET shape of a vaulted break-glass credential: what the operator's // host page shows without the plaintext ever entering the rendered document. The secret column is // deliberately absent from both the struct and the query — the render path must be unable to carry it. type HostRecoveryMeta struct { HostID string Username string SetAt time.Time } // GetHostRecoveryMeta returns a host's credential metadata, or (nil, nil) if none is vaulted. // Use this — NOT GetHostRecoveryCredential — on any path that renders a page: the secret can only // leave the hub through the explicit, CSRF-gated, audited reveal endpoint. func (s *Store) GetHostRecoveryMeta(hostID string) (*HostRecoveryMeta, error) { var m HostRecoveryMeta var setAt string err := s.db.QueryRow( `SELECT host_id, username, set_at FROM host_recovery WHERE host_id = ?`, hostID). Scan(&m.HostID, &m.Username, &setAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } m.SetAt = parseSQLiteTime(setAt) return &m, nil } // HasHostRecoveryCredential reports whether a host already has a vaulted credential (day-0 idempotency: // don't regenerate/re-set on a re-run unless --rotate). func (s *Store) HasHostRecoveryCredential(hostID string) (bool, error) { var one int err := s.db.QueryRow(`SELECT 1 FROM host_recovery WHERE host_id = ?`, hostID).Scan(&one) if err == sql.ErrNoRows { return false, nil } if err != nil { return false, err } return true, nil } // HostMgmtPlaneRow is the latest management-plane state per host (TASK G1), parsed from the newest // host_report. PrivsepHealedAt is the watchdog heal-marker timestamp ("" when never healed / old agent). type HostMgmtPlaneRow struct { HostID string CustomerID string PrivsepDirOK bool SshdReachable bool PrivsepHealedAt string } // GetHostMgmtPlaneStates returns the latest mgmt_plane stanza per host (mirrors // GetHostLeafFingerprints). A report without the stanza (old agent, feature off) yields zero values → // no alert. Malformed JSON degrades to zero values, never an error for that host. func (s *Store) GetHostMgmtPlaneStates() ([]HostMgmtPlaneRow, 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 []HostMgmtPlaneRow for rows.Next() { var r HostMgmtPlaneRow var reportJSON string if err := rows.Scan(&r.HostID, &r.CustomerID, &reportJSON); err != nil { return nil, err } var body struct { MgmtPlane *struct { PrivsepDirOK bool `json:"privsep_dir_ok"` SshdReachable bool `json:"sshd_reachable"` PrivsepHealedAt string `json:"privsep_healed_at"` } `json:"mgmt_plane"` } _ = json.Unmarshal([]byte(reportJSON), &body) // malformed/old → nil mgmt_plane → zero values if body.MgmtPlane != nil { r.PrivsepDirOK = body.MgmtPlane.PrivsepDirOK r.SshdReachable = body.MgmtPlane.SshdReachable r.PrivsepHealedAt = body.MgmtPlane.PrivsepHealedAt } out = append(out, r) } return out, rows.Err() } // SealLegacyRecoverySecrets seals, in place, every host_recovery row still holding a plaintext console // password (written before hub v0.135.0, R-133). Idempotent; returns how many rows it sealed. Values are // never logged. Called at start-up right after the key is installed (cmd/hub/main.go), beside // SealLegacyOffsiteSecrets. func (s *Store) SealLegacyRecoverySecrets() (int, error) { if s.sealer == nil { return 0, ErrNoSealKey } rows, err := s.db.Query(`SELECT host_id, secret FROM host_recovery`) if err != nil { return 0, err } type row struct{ id, v string } var todo []row for rows.Next() { var r row if err := rows.Scan(&r.id, &r.v); err != nil { rows.Close() return 0, err } if !strings.HasPrefix(r.v, sealPrefix) { todo = append(todo, r) } } rows.Close() n := 0 for _, r := range todo { sealed, err := s.sealSecret(r.v) if err != nil { return n, err } if _, err := s.db.Exec(`UPDATE host_recovery SET secret = ? WHERE host_id = ? AND secret = ?`, sealed, r.id, r.v); err != nil { return n, err } n++ } return n, nil }