Files
felhom.eu/hub/internal/store/host_recovery.go
T
admin 1956e5d390 hub v0.84.0 — break-glass console credential on the host page
The credential existed and was not reachable when it was wanted. Every box has
had a strong random root@pam password since TASK G1, vaulted in the hub at day 0
and used for real during the sshd incident — but the only way to read it back was
a hand-written curl carrying the global operator key, a secret kept out-of-band.
In practice the PVE web console on a demo box felt locked.

The host page grows a Console access card: presence + username + set_at by
default, Reveal fetches the plaintext on demand for 60 s with a Copy button.
Masking clears the JS variable, and also fires on a second click and on
visibilitychange. A host with nothing vaulted says so, and says why.

The secret is NEVER rendered into the page, and that constraint shapes the
change. The render path uses a new store.GetHostRecoveryMeta whose struct and
SELECT both omit the secret column, so it is structurally incapable of carrying
one. The plaintext crosses the wire only in the response to POST
/hosts/{id}/reveal-recovery-credential (Cache-Control: no-store, CSRF-gated at
the ServeHTTP level; POST precisely so that gate applies and so no secret is
retrievable by URL alone). Deliberately NOT the customer page's data-secret
widget, which embeds the plaintext on every load.

A delivered reveal writes one recovery_credential_revealed event on the host's
customer timeline (info, source hub, Hungarian) via SaveEvent alone — no
dispatcher, nobody emailed, the log_tail_requested shape. Two reveals write two
events: the register records accesses, not states. A 404 is not an access. An
unbound host reveals fine and writes no event; the [INFO] hub line, carrying the
username and a length only, is then the record.

The global-key API path is untouched by design — it is the route for when the
hub UI itself is broken, and coupling it to the session layer would delete the
independence that makes it a fallback.

Recorded as a real trade: the hub session password alone now unlocks console root
fleet-wide, where retrieval previously also needed the global key. Accepted for a
single-operator, HU-geo-fenced hub that already stores these passwords in
plaintext at rest (CONTEXT.md ruling S-4). The plaintext-at-rest half is filed as
R-133 — every hub DB backup is a fleet-wide console-credential dump.

Tests 550 -> 559; four red-proofs (page leak, audit event, CSRF gate, route
order) each run, observed failing, and reverted. The route-order proof is a seam
test driving ServeHTTP: a handler-level test cannot see that defect, because the
handler is correct and simply never runs.
2026-07-31 08:19:36 +02:00

139 lines
4.7 KiB
Go

package store
import (
"database/sql"
"encoding/json"
"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). The secret is stored as-is at rest; the hub NEVER logs it and only ever returns
// it over the operator-authenticated retrieval path.
func (s *Store) SaveHostRecoveryCredential(hostID, username, secret string) error {
_, 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, secret)
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
}
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()
}