3d7a2761fc
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
194 lines
6.6 KiB
Go
194 lines
6.6 KiB
Go
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
|
|
}
|