05d81810d4
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
75 lines
2.9 KiB
Go
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())
|
|
}
|
|
}
|