Files
felhom.eu/hub/internal/store/host_recovery_test.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

121 lines
4.2 KiB
Go

package store
import "testing"
func TestHostRecoveryCredential_RoundTripUpsertAndAbsent(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil {
t.Fatalf("UpsertHost: %v", err)
}
// absent → (nil, nil) + Has=false
got, err := s.GetHostRecoveryCredential("h1")
if err != nil || got != nil {
t.Fatalf("absent cred: got %+v / %v (want nil,nil)", got, err)
}
has, _ := s.HasHostRecoveryCredential("h1")
if has {
t.Fatal("HasHostRecoveryCredential must be false before any vault")
}
// vault → round-trips
if err := s.SaveHostRecoveryCredential("h1", "root@pam", "s3cret-Aa1"); err != nil {
t.Fatalf("SaveHostRecoveryCredential: %v", err)
}
got, err = s.GetHostRecoveryCredential("h1")
if err != nil || got == nil {
t.Fatalf("GetHostRecoveryCredential: %+v / %v", got, err)
}
if got.Username != "root@pam" || got.Secret != "s3cret-Aa1" {
t.Fatalf("round-trip mismatch: %+v", got)
}
if has, _ := s.HasHostRecoveryCredential("h1"); !has {
t.Fatal("HasHostRecoveryCredential must be true after vault")
}
// upsert (rotate) → overwrites last-write-wins
if err := s.SaveHostRecoveryCredential("h1", "root@pam", "rotated-Bb2"); err != nil {
t.Fatalf("re-vault: %v", err)
}
got, _ = s.GetHostRecoveryCredential("h1")
if got.Secret != "rotated-Bb2" {
t.Fatalf("rotate did not overwrite: %+v", got)
}
}
// GetHostRecoveryMeta is the render path's accessor (hub v0.84.0). It returns username + set_at
// and, by CONSTRUCTION, cannot return the secret: neither HostRecoveryMeta nor the SELECT names the
// `secret` column, so there is no runtime assertion to write for that half — adding a Secret field
// would not fail this test, it would fail to compile at every call site that never asked for one.
// The runtime half asserted here is the metadata round-trip and the absent case.
func TestGetHostRecoveryMeta_MetadataOnly(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil {
t.Fatalf("UpsertHost: %v", err)
}
// absent → (nil, nil), never an error the page has to special-case
m, err := s.GetHostRecoveryMeta("h1")
if err != nil || m != nil {
t.Fatalf("absent meta: got %+v / %v (want nil,nil)", m, err)
}
if err := s.SaveHostRecoveryCredential("h1", "root@pam", "s3cret-Aa1"); err != nil {
t.Fatalf("SaveHostRecoveryCredential: %v", err)
}
m, err = s.GetHostRecoveryMeta("h1")
if err != nil || m == nil {
t.Fatalf("GetHostRecoveryMeta: %+v / %v", m, err)
}
if m.HostID != "h1" || m.Username != "root@pam" {
t.Fatalf("meta mismatch: %+v", m)
}
if m.SetAt.IsZero() {
t.Fatal("SetAt did not parse — the card cannot render staleness without it")
}
// an unknown host is the absent case too, not an error
if m, err := s.GetHostRecoveryMeta("nope"); err != nil || m != nil {
t.Fatalf("unknown host: got %+v / %v (want nil,nil)", m, err)
}
}
func TestGetHostMgmtPlaneStates_ParsesHealMarker(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil {
t.Fatalf("UpsertHost: %v", err)
}
// a report WITH a heal marker
report := `{"host_id":"h1","mgmt_plane":{"privsep_dir_ok":true,"sshd_reachable":true,"healed_recently":true,"privsep_healed_at":"2026-07-05T16:42:17Z"}}`
if err := s.SaveHostReport("h1", "c1", []byte(report), HostReportDenorm{}); err != nil {
t.Fatalf("SaveHostReport: %v", err)
}
rows, err := s.GetHostMgmtPlaneStates()
if err != nil {
t.Fatalf("GetHostMgmtPlaneStates: %v", err)
}
var found bool
for _, r := range rows {
if r.HostID == "h1" {
found = true
if !r.PrivsepDirOK || r.PrivsepHealedAt != "2026-07-05T16:42:17Z" {
t.Fatalf("parsed row wrong: %+v", r)
}
}
}
if !found {
t.Fatal("h1 not in mgmt-plane states")
}
// a report WITHOUT the stanza (old agent) → zero values, no crash
if err := s.SaveHostReport("h1", "c1", []byte(`{"host_id":"h1"}`), HostReportDenorm{}); err != nil {
t.Fatalf("SaveHostReport2: %v", err)
}
rows, _ = s.GetHostMgmtPlaneStates()
for _, r := range rows {
if r.HostID == "h1" && r.PrivsepHealedAt != "" {
t.Fatalf("old-agent report should yield empty healed_at, got %+v", r)
}
}
}