Files
felhom.eu/hub/internal/api/host_recovery_test.go
T
admin 05d81810d4 feat(hub,install): break-glass recovery vault + mgmt_plane surfacing (TASK G1)
Hub half of the management-plane break-glass (prereq for felhom-sshd/H1; agent
half = felhom-agent v0.71.0). Closes SPIKE-felhom-sshd §8/#9.

- store.host_recovery + methods: per-host root@pam console password, at-rest,
  operator-retrievable (the PVE-web-console fallback when sshd + auto-heal both fail).
- API: PUT /hosts/{id}/recovery-credential (self-scoped, day-0 vaults) + GET
  /admin/hosts/{id}/recovery-credential (global key only). Secret never logged
  (red-proofed).
- monitor/host_mgmtplane: parses the agent mgmt_plane stanza, raises
  mgmt_plane_healed WARNING on a new privsep_healed_at (recurring clobber surfaces
  before lockout; complements host_staleness).
- host-install: step_break_glass generates a strong root@pam password (openssl
  rand, never logged/filed — stdin to chpasswd + curl), vaults via host key;
  idempotent unless --rotate-recovery. Installs the G1 host artifacts (tmpfiles +
  agent-independent watchdog timer), RuntimeDirectory-guarded; uninstall removes them.

Hub v0.34.0. Non-hollow tests + red-proofs; full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 19:03:18 +02:00

75 lines
2.9 KiB
Go

package api
import (
"bytes"
"log"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
_ "modernc.org/sqlite"
)
func TestRecoveryCredential_VaultSelfScopedAndOperatorRetrieval(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c1", APIKey: "HKEY2"})
body := `{"username":"root@pam","password":"Str0ng-Break-Glass"}`
// self-scoped write: h1's key vaults h1 → 200
if rr := do(h, "PUT", "/hosts/h1/recovery-credential", "HKEY", body); rr.Code != 200 {
t.Fatalf("self vault = %d: %s", rr.Code, rr.Body.String())
}
// a host key CANNOT vault ANOTHER host → 403
if rr := do(h, "PUT", "/hosts/h1/recovery-credential", "HKEY2", body); rr.Code != 403 {
t.Fatalf("cross-host vault must be 403, got %d", rr.Code)
}
// unauthenticated → 401
if rr := do(h, "PUT", "/hosts/h1/recovery-credential", "", body); rr.Code != 401 {
t.Fatalf("unauth vault must be 401, got %d", rr.Code)
}
// operator retrieval requires the GLOBAL key; a host key is refused (401 — can't read its own back)
if rr := do(h, "GET", "/admin/hosts/h1/recovery-credential", "HKEY", ""); rr.Code != 401 {
t.Fatalf("host key reading recovery credential must be 401, got %d", rr.Code)
}
rr := do(h, "GET", "/admin/hosts/h1/recovery-credential", globalKey, "")
if rr.Code != 200 {
t.Fatalf("operator retrieval = %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "Str0ng-Break-Glass") || !strings.Contains(rr.Body.String(), "root@pam") {
t.Fatalf("retrieval must return the vaulted credential, got %s", rr.Body.String())
}
// a host with no credential → 404
if rr := do(h, "GET", "/admin/hosts/h2/recovery-credential", globalKey, ""); rr.Code != 404 {
t.Fatalf("no-credential host must be 404, got %d", rr.Code)
}
}
// SECRET DISCIPLINE (red-proof for trap 3): the password must NEVER appear in the hub log — on the
// vault write NOR the operator retrieval. Companion: if a handler logged the password, this fails.
func TestRecoveryCredential_PasswordNeverLogged(t *testing.T) {
var buf bytes.Buffer
path := filepath.Join(t.TempDir(), "test.db")
st, err := store.New(path, log.New(&buf, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
defer st.Close()
h := New(st, globalKey, "", "", nil, log.New(&buf, "", 0))
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
const secret = "SuperSecret-DoNotLog-42"
if rr := do(h, "PUT", "/hosts/h1/recovery-credential", "HKEY", `{"username":"root@pam","password":"`+secret+`"}`); rr.Code != 200 {
t.Fatalf("vault = %d", rr.Code)
}
if rr := do(h, "GET", "/admin/hosts/h1/recovery-credential", globalKey, ""); rr.Code != 200 {
t.Fatalf("retrieve = %d", rr.Code)
}
if strings.Contains(buf.String(), secret) {
t.Fatalf("the recovery password LEAKED into the hub log:\n%s", buf.String())
}
}