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.
This commit is contained in:
@@ -1,3 +1,58 @@
|
||||
## v0.84.0 — Break-glass console credential on the host page (2026-07-31)
|
||||
|
||||
**The credential existed and was not reachable when it was wanted.** Every Felhom-installed box has
|
||||
had a strong random `root@pam` console password since TASK G1 — set on the box by
|
||||
`felhom-host-install.sh` step 4b, vaulted in the hub at day 0, live for three hosts today, and used
|
||||
for real during the sshd incident. The only way to read it back was a hand-written `curl` against
|
||||
`/api/v1/admin/hosts/<id>/recovery-credential` carrying the **global operator key** — a different
|
||||
secret from the hub login password, kept out-of-band. In practice the PVE web console on a demo box
|
||||
felt locked.
|
||||
|
||||
**The host page grows a `Console access` card.** By default it states only that a credential is
|
||||
vaulted, for which user, and when it was last set. A **Reveal** button fetches the plaintext on
|
||||
demand and shows it for 60 s with a Copy button; masking clears the JS variable, and the mask also
|
||||
fires on a second click and on `visibilitychange → hidden`. A host with nothing vaulted says so, and
|
||||
says why (byo host, or step 4b never ran), with no Reveal control at all.
|
||||
|
||||
**The secret is never rendered into the page — that constraint shapes the whole change.** The render
|
||||
path calls a new `store.GetHostRecoveryMeta`, whose struct and whose `SELECT` both omit the `secret`
|
||||
column, so it is *structurally* incapable of carrying it; `hostDetailData` gains exactly three keys
|
||||
(`RecoveryVaulted`, `RecoveryUsername`, `RecoverySetAt`). 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 a secret is never retrievable by
|
||||
URL alone (prefetch, history, referrer). The load-bearing test asserts the canary appears **nowhere**
|
||||
in the rendered response — attribute, comment, inline script or JSON blob.
|
||||
|
||||
**Deliberately NOT the `customer_unified.html` `data-secret` widget**, which embeds the plaintext in
|
||||
the page HTML on every load: acceptable for one customer's retrieval passphrase, not for console root
|
||||
on every box in the fleet (it survives in the bfcache, in "save page as", and in any DOM-capturing
|
||||
screenshot). That widget is untouched and recorded as an observation.
|
||||
|
||||
**Transparency, matching the log-pull precedent.** A delivered reveal writes one
|
||||
`recovery_credential_revealed` event (info, source `hub`, Hungarian) on the host's customer timeline
|
||||
— `SaveEvent` alone, no dispatcher call, so nobody is emailed. Two reveals write two events: the
|
||||
register records **accesses**, not states. A 404 is not an access and writes nothing. An unbound host
|
||||
reveals fine and writes no event (no customer to tell); the `[INFO]` hub line is then the only
|
||||
record, and it carries the username and a length — never the password.
|
||||
|
||||
**The global-key API path is untouched, by design.** `handleAdminGetRecoveryCredential` is the
|
||||
break-glass route for when the hub *UI* is the thing that is broken; coupling it to the session layer
|
||||
would remove exactly the independence that makes it a fallback.
|
||||
|
||||
**Recorded as a real trade, not a free one:** the hub session password alone now unlocks console root
|
||||
on every managed box, where retrieval previously also required the global API key. Accepted for a
|
||||
single-operator, HU-geo-fenced hub that already stores these passwords in plaintext at rest — and the
|
||||
plaintext-at-rest half is now filed as **R-133** (envelope-encrypt `host_recovery.secret` under a KEK
|
||||
held outside the DB, so a hub DB backup stops being a fleet-wide console-credential dump).
|
||||
|
||||
Files: `hub/internal/store/host_recovery.go` (+`GetHostRecoveryMeta`, `HostRecoveryMeta`),
|
||||
`hub/internal/web/hosts.go` (+`handleHostRevealRecoveryCredential`, `hostDetailData`),
|
||||
`hub/internal/web/server.go` (route, **above** the bare `/hosts/` catch-all),
|
||||
`hub/internal/web/templates/host_detail_body.html` (card + fetch-on-demand script).
|
||||
Tests 550 → 559; four red-proofs (A page-leak, B audit event, D CSRF gate, E route order) each run,
|
||||
observed failing, and reverted. The E proof is a seam test driving `ServeHTTP`: a handler-level test
|
||||
cannot see that defect, because the handler is correct and simply never runs.
|
||||
|
||||
## v0.83.0 — R-109 + R-122: the recipe assembly stops dropping sections (2026-07-30)
|
||||
|
||||
Pairs with **agent v0.118.0** (R-106 + R-109). The agent half is useless without this one.
|
||||
|
||||
@@ -47,6 +47,34 @@ func (s *Store) GetHostRecoveryCredential(hostID string) (*HostRecoveryCredentia
|
||||
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) {
|
||||
|
||||
@@ -43,6 +43,43 @@ func TestHostRecoveryCredential_RoundTripUpsertAndAbsent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -438,6 +438,14 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
|
||||
drBundle, _ := s.store.GetHostDRBundle(host.HostID)
|
||||
escrow, _ := s.store.GetHostEscrow(host.HostID)
|
||||
|
||||
// v0.84.0 Console access — presence + username + set_at ONLY. GetHostRecoveryMeta cannot carry
|
||||
// the secret (its query does not select the column); the plaintext reaches the operator solely
|
||||
// through POST /hosts/{id}/reveal-recovery-credential.
|
||||
recoveryMeta, err := s.store.GetHostRecoveryMeta(host.HostID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] host recovery meta %s: %v", host.HostID, err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"WrapperDrift": wrapperDrift,
|
||||
"ReportedWrapperSHA": reportedWrapperSHA,
|
||||
@@ -467,6 +475,20 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
|
||||
// v0.60.0 Part B: retained superseded escrow blobs (data-first — old passphrases stay
|
||||
// R-recoverable). Operator-only surface.
|
||||
"SupersededEscrowCount": func() int { n, _ := s.store.CountSupersededEscrow(host.HostID); return n }(),
|
||||
// v0.84.0 break-glass Console access card. NEVER add a key holding the secret.
|
||||
"RecoveryVaulted": recoveryMeta != nil,
|
||||
"RecoveryUsername": func() string {
|
||||
if recoveryMeta != nil {
|
||||
return recoveryMeta.Username
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
"RecoverySetAt": func() time.Time {
|
||||
if recoveryMeta != nil {
|
||||
return recoveryMeta.SetAt
|
||||
}
|
||||
return time.Time{}
|
||||
}(),
|
||||
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
|
||||
"LogBundles": s.hostLogBundleRows(host),
|
||||
"CSRFToken": s.getCSRFToken(r),
|
||||
@@ -511,6 +533,61 @@ func (s *Server) handleHostDeleteImpact(w http.ResponseWriter, r *http.Request,
|
||||
})
|
||||
}
|
||||
|
||||
// handleHostRevealRecoveryCredential — POST /hosts/{id}/reveal-recovery-credential (v0.84.0).
|
||||
// The operator-SESSION counterpart to the global-key API path (api/handler.go
|
||||
// handleAdminGetRecoveryCredential), which stays untouched and remains the break-glass route for
|
||||
// when this UI is itself unavailable — coupling it to the session layer would remove exactly the
|
||||
// independence that makes it a fallback.
|
||||
//
|
||||
// POST, not GET, deliberately: it is the only way the ServeHTTP-level CSRF check applies, and a
|
||||
// secret must not be retrievable by URL alone (prefetch, history, referrer).
|
||||
//
|
||||
// SECRET DISCIPLINE: the plaintext goes into the JSON response body and nowhere else — never the
|
||||
// hub log, never the event message or details_json.
|
||||
func (s *Server) handleHostRevealRecoveryCredential(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
host, err := s.store.GetHost(hostID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] reveal recovery credential %s: %v", hostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if host == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cred, err := s.store.GetHostRecoveryCredential(hostID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] reveal recovery credential %s: %v", hostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cred == nil {
|
||||
// A 404 is not an access — nothing was delivered, so nothing is recorded on the timeline.
|
||||
s.logger.Printf("[INFO] reveal recovery credential %s: no credential vaulted", hostID)
|
||||
http.Error(w, "No recovery credential vaulted for this host", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Transparency by default, exactly as handleRequestLogTail does it: SaveEvent alone writes the
|
||||
// customer-visible timeline row WITHOUT emailing anyone (no dispatcher call here, by design).
|
||||
// An unbound host has no customer to tell — the [INFO] line below is then the only record.
|
||||
if host.CustomerID != "" {
|
||||
if _, err := s.store.SaveEvent(host.CustomerID, "recovery_credential_revealed", "info",
|
||||
"Az üzemeltető lekérte a géped konzolos hozzáférési jelszavát (távoli hibaelhárítás).", "", "hub"); err != nil {
|
||||
s.logger.Printf("[WARN] SaveEvent recovery_credential_revealed %s/%s: %v", host.CustomerID, hostID, err)
|
||||
}
|
||||
}
|
||||
s.logger.Printf("[INFO] operator revealed break-glass console credential for host %s (user=%s, secret %d chars)",
|
||||
hostID, cred.Username, len(cred.Secret))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"host_id": cred.HostID,
|
||||
"username": cred.Username,
|
||||
"password": cred.Secret,
|
||||
"set_at": cred.SetAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// handleHostDelete — POST /hosts/{id}/delete (v0.47.0 stale host removal). Gates, in order:
|
||||
// - unknown host → 404
|
||||
// - ONLINE host → 409 unconditionally (host reports authenticate via GetHostByAPIKey;
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package web
|
||||
|
||||
// Scenarios A–G (hub v0.84.0) — the break-glass Console access card + the operator-session reveal
|
||||
// endpoint.
|
||||
//
|
||||
// SEAM DISCIPLINE: every test here drives RequireAuth(ServeHTTP), never a handler function
|
||||
// directly. Scenario E (route ordering) is INVISIBLE to a handler-level test — the handler is
|
||||
// correct and simply never runs — and that is the shape of the inert-seam defects on record.
|
||||
//
|
||||
// The load-bearing assertion is negative in the way that matters: the plaintext must not appear
|
||||
// ANYWHERE in a rendered host page. revealCanary is deliberately distinctive so an accidental leak
|
||||
// is greppable across the tree.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
const revealCanary = "REVEAL-CANARY-9f2b7c"
|
||||
|
||||
// newRevealServer builds a hub with a live operator password (so RequireAuth + the ServeHTTP CSRF
|
||||
// check are both armed) and a CAPTURED logger, so the "the secret never reaches the log" assertion
|
||||
// has something to read.
|
||||
func newRevealServer(t *testing.T) (*Server, *store.Store, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
var logBuf bytes.Buffer
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := New(st, "", "", "test", 30*time.Minute, log.New(&logBuf, "", 0))
|
||||
h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.configPasswordHash = string(h)
|
||||
return s, st, &logBuf
|
||||
}
|
||||
|
||||
// newRevealSession mints a live operator session and returns (cookie, csrfToken).
|
||||
func newRevealSession(t *testing.T, s *Server) (*http.Cookie, string) {
|
||||
t.Helper()
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions["sess-token-reveal"] = &hubSession{
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
csrfToken: "csrf-token-reveal",
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
return &http.Cookie{Name: "hub_session", Value: "sess-token-reveal"}, "csrf-token-reveal"
|
||||
}
|
||||
|
||||
// seedRevealHost creates a host (optionally bound to a customer) with a vaulted credential.
|
||||
func seedRevealHost(t *testing.T, st *store.Store, hostID, customerID, secret string) {
|
||||
t.Helper()
|
||||
if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: "k-" + hostID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secret != "" {
|
||||
if err := st.SaveHostRecoveryCredential(hostID, "root@pam", secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveReveal drives the REAL stack: RequireAuth → ServeHTTP → routing → handler.
|
||||
func serveReveal(t *testing.T, s *Server, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func countEvents(t *testing.T, st *store.Store, customerID, eventType string) int {
|
||||
t.Helper()
|
||||
evs, err := st.GetRecentEvents(customerID, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range evs {
|
||||
if e.EventType == eventType {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- Scenario A: the rendered host page carries presence + username + set_at, NEVER the secret ---
|
||||
// RED-PROOF A: add `data-secret="{{.RecoverySecret}}"` to the card and a "RecoverySecret" key
|
||||
// holding cred.Secret to hostDetailData → the canary assertion below goes RED.
|
||||
func TestReveal_A_PageNeverCarriesTheSecret(t *testing.T) {
|
||||
s, st, _ := newRevealServer(t)
|
||||
cookie, _ := newRevealSession(t, s)
|
||||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-8363b5", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := serveReveal(t, s, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("host page = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
|
||||
// THE load-bearing assertion — the whole response, attributes, comments, scripts and all.
|
||||
if strings.Contains(body, revealCanary) {
|
||||
t.Fatal("SECRET LEAK: the vaulted console password appears in the rendered host page")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Console access",
|
||||
"root@pam",
|
||||
"/hosts/demo-felhom-8363b5/reveal-recovery-credential",
|
||||
"Reveal",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("host page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario B: reveal delivers the secret, records exactly one event, and never logs it ---
|
||||
// RED-PROOF B: delete the SaveEvent call in handleHostRevealRecoveryCredential → the event
|
||||
// assertion goes RED.
|
||||
func TestReveal_B_RevealDeliversAndAudits(t *testing.T) {
|
||||
s, st, logBuf := newRevealServer(t)
|
||||
cookie, csrf := newRevealSession(t, s)
|
||||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
rr := serveReveal(t, s, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("reveal = %d, want 200 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var got struct {
|
||||
HostID string `json:"host_id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
SetAt string `json:"set_at"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v (body %q)", err, rr.Body.String())
|
||||
}
|
||||
if got.Password != revealCanary {
|
||||
t.Errorf("password = %q, want the vaulted secret", got.Password)
|
||||
}
|
||||
if got.Username != "root@pam" || got.HostID != "demo-felhom-8363b5" {
|
||||
t.Errorf("unexpected payload: %+v", got)
|
||||
}
|
||||
if got.SetAt == "" {
|
||||
t.Error("set_at missing — the operator cannot judge staleness without it")
|
||||
}
|
||||
|
||||
// Exactly ONE timeline row, on the bound customer, info severity, source "hub", Hungarian.
|
||||
evs, err := st.GetRecentEvents("demo-felhom", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range evs {
|
||||
if e.EventType != "recovery_credential_revealed" {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if e.Severity != "info" {
|
||||
t.Errorf("severity = %q, want info", e.Severity)
|
||||
}
|
||||
if e.Source != "hub" {
|
||||
t.Errorf("source = %q, want hub", e.Source)
|
||||
}
|
||||
if !strings.Contains(e.Message, "konzolos hozzáférési jelszavát") {
|
||||
t.Errorf("message is not the Hungarian customer-facing line: %q", e.Message)
|
||||
}
|
||||
if strings.Contains(e.Message, revealCanary) || strings.Contains(e.DetailsJSON, revealCanary) {
|
||||
t.Fatal("SECRET LEAK: the password is in the event row")
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("recovery_credential_revealed rows = %d, want exactly 1", n)
|
||||
}
|
||||
|
||||
// The hub log records the access — username + length only.
|
||||
if strings.Contains(logBuf.String(), revealCanary) {
|
||||
t.Fatal("SECRET LEAK: the password reached the hub log")
|
||||
}
|
||||
if !strings.Contains(logBuf.String(), "operator revealed break-glass console credential") {
|
||||
t.Errorf("the access is not recorded in the hub log: %q", logBuf.String())
|
||||
}
|
||||
|
||||
// No dispatcher call exists on this path — nothing was emailed.
|
||||
notifs, err := st.GetRecentNotifications("demo-felhom", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(notifs) != 0 {
|
||||
t.Errorf("reveal produced %d notification_log row(s); it must email nobody", len(notifs))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario C: nothing vaulted → the explanatory card, no control, 404 on POST, zero events ---
|
||||
func TestReveal_C_NotVaulted(t *testing.T) {
|
||||
s, st, _ := newRevealServer(t)
|
||||
cookie, csrf := newRevealSession(t, s)
|
||||
seedRevealHost(t, st, "sess-f-2670b5", "sess-f", "") // no credential row
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/hosts/sess-f-2670b5", nil)
|
||||
req.AddCookie(cookie)
|
||||
body := serveReveal(t, s, req).Body.String()
|
||||
if !strings.Contains(body, "not vaulted") {
|
||||
t.Error("the not-vaulted state does not render its badge")
|
||||
}
|
||||
if !strings.Contains(body, "byo host") {
|
||||
t.Error("the not-vaulted state does not explain WHY (byo / step 4b)")
|
||||
}
|
||||
if strings.Contains(body, "/hosts/sess-f-2670b5/reveal-recovery-credential") {
|
||||
t.Error("a Reveal control is offered for a host with nothing vaulted")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/hosts/sess-f-2670b5/reveal-recovery-credential", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
rr := serveReveal(t, s, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("reveal on an unvaulted host = %d, want 404", rr.Code)
|
||||
}
|
||||
// A 404 is not an access: only a DELIVERED secret is recorded.
|
||||
if n := countEvents(t, st, "sess-f", "recovery_credential_revealed"); n != 0 {
|
||||
t.Errorf("a 404 wrote %d event row(s); it must write none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario D: the CSRF gate (security) ---
|
||||
// RED-PROOF D: the companion below sends the token and asserts 200, proving this test
|
||||
// discriminates on CSRF rather than passing because the request was malformed some other way.
|
||||
func TestReveal_D_CSRFRequired(t *testing.T) {
|
||||
s, st, _ := newRevealServer(t)
|
||||
cookie, csrf := newRevealSession(t, s)
|
||||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||||
req.AddCookie(cookie) // session, but NO CSRF token
|
||||
rr := serveReveal(t, s, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("reveal without CSRF = %d, want 403", rr.Code)
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), revealCanary) {
|
||||
t.Fatal("SECRET LEAK: the 403 body carries the password")
|
||||
}
|
||||
if n := countEvents(t, st, "demo-felhom", "recovery_credential_revealed"); n != 0 {
|
||||
t.Errorf("a CSRF refusal wrote %d event row(s); it must write none", n)
|
||||
}
|
||||
|
||||
// The discriminator: the SAME request WITH the token succeeds.
|
||||
req = httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
if rr := serveReveal(t, s, req); rr.Code != http.StatusOK {
|
||||
t.Fatalf("the same request WITH a CSRF token = %d, want 200 — the 403 above proves nothing", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario E: the method gate — and, through it, the ROUTE ORDER (seam test) ---
|
||||
// RED-PROOF E: move the new case BELOW `case strings.HasPrefix(path, "/hosts/")` → the GET falls
|
||||
// through to the catch-all, renders the host detail page 200, and this test goes RED.
|
||||
func TestReveal_E_MethodGateAndRouteOrder(t *testing.T) {
|
||||
s, st, _ := newRevealServer(t)
|
||||
cookie, _ := newRevealSession(t, s)
|
||||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := serveReveal(t, s, req)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET on the reveal route = %d, want 405", rr.Code)
|
||||
}
|
||||
// The route-order half: a fall-through to /hosts/ would render the host page instead.
|
||||
if strings.Contains(rr.Body.String(), "Console access") {
|
||||
t.Fatal("the reveal route fell through to the /hosts/ catch-all — the case is registered AFTER it")
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), revealCanary) {
|
||||
t.Fatal("SECRET LEAK: the 405 body carries the password")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario F: unknown host ---
|
||||
func TestReveal_F_UnknownHost(t *testing.T) {
|
||||
s, _, _ := newRevealServer(t)
|
||||
cookie, csrf := newRevealSession(t, s)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/does-not-exist/reveal-recovery-credential", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
if rr := serveReveal(t, s, req); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("reveal on an unknown host = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario G: unauthenticated ---
|
||||
func TestReveal_G_Unauthenticated(t *testing.T) {
|
||||
s, st, _ := newRevealServer(t)
|
||||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest") // API-like → 401 rather than a login redirect
|
||||
rr := serveReveal(t, s, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated reveal = %d, want 401", rr.Code)
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), revealCanary) {
|
||||
t.Fatal("SECRET LEAK: the 401 body carries the password")
|
||||
}
|
||||
if n := countEvents(t, st, "demo-felhom", "recovery_credential_revealed"); n != 0 {
|
||||
t.Errorf("an unauthenticated call wrote %d event row(s); it must write none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edge case (§8): an UNBOUND host reveals fine and writes no event (SaveEvent needs a customer) ---
|
||||
func TestReveal_UnboundHostRevealsWithoutAnEvent(t *testing.T) {
|
||||
s, st, logBuf := newRevealServer(t)
|
||||
cookie, csrf := newRevealSession(t, s)
|
||||
seedRevealHost(t, st, "unbound-01", "", revealCanary)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/unbound-01/reveal-recovery-credential", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
rr := serveReveal(t, s, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("reveal on an unbound host = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), revealCanary) {
|
||||
t.Error("the unbound host's secret was not delivered")
|
||||
}
|
||||
// No placeholder customer id is invented — the hub log is the only record.
|
||||
if n := countEvents(t, st, "", "recovery_credential_revealed"); n != 0 {
|
||||
t.Errorf("an unbound host wrote %d event row(s) against an empty customer id", n)
|
||||
}
|
||||
if !strings.Contains(logBuf.String(), "unbound-01") {
|
||||
t.Error("the unbound host's reveal is recorded nowhere at all")
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
// v0.84.0 break-glass console credential — suffix route BEFORE the bare /hosts/ catch-all
|
||||
// (registered after it, the POST would 404 and the GET would silently render the host page).
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/reveal-recovery-credential"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/reveal-recovery-credential")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleHostRevealRecoveryCredential(w, r, hostID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
|
||||
if r.Method == http.MethodPost {
|
||||
|
||||
@@ -279,6 +279,96 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Console access (v0.84.0): the break-glass root@pam credential. The page carries
|
||||
presence + username + set_at ONLY — the plaintext NEVER enters this document and is
|
||||
fetched on demand from POST /hosts/{id}/reveal-recovery-credential. Deliberately NOT
|
||||
the customer_unified data-secret widget, which embeds the plaintext on every load. -->
|
||||
<section class="card">
|
||||
<h2>Console access</h2>
|
||||
{{if .RecoveryVaulted}}
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">User</span>
|
||||
<span class="value"><code>{{.RecoveryUsername}}</code></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Password set</span>
|
||||
<span class="value">{{timeAgo .RecoverySetAt}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-box">
|
||||
<code id="console-pw-{{.HostID}}">••••••••••••••••</code>
|
||||
<button type="button" class="copy-btn" id="console-reveal-{{.HostID}}" data-reveal-url="/hosts/{{.HostID}}/reveal-recovery-credential" onclick="revealConsolePassword('{{.HostID}}')">Reveal</button>
|
||||
<button type="button" class="copy-btn" id="console-copy-{{.HostID}}" onclick="copyConsolePassword('{{.HostID}}')" disabled>Copy</button>
|
||||
</div>
|
||||
<p class="hint" id="console-hint-{{.HostID}}" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
|
||||
Break-glass credential for the PVE web console at https://<host-ip>:8006 (realm: Linux PAM standard authentication). Revealing it is recorded on the customer's event timeline. Last vaulted value — if root@pam was changed on the box without re-vaulting, this is stale.
|
||||
</p>
|
||||
{{else}}
|
||||
<p><span class="badge badge-neutral">not vaulted</span></p>
|
||||
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
|
||||
No console credential is vaulted for this host. Expected for a byo host — the owner manages root@pam. Otherwise the installer's step 4b did not run; re-run felhom-host-install.sh, or set and vault one per the break-glass runbook §5.
|
||||
</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{if .RecoveryVaulted}}
|
||||
<script>
|
||||
// Fetch-on-demand: the password exists in this document ONLY between a Reveal and the next
|
||||
// mask, and only in a local variable — never in localStorage, a data- attribute or the URL.
|
||||
// `|| {}` because the customer page renders this sub-template once per host: a plain
|
||||
// re-assignment would drop a sibling card's mask timer on the floor.
|
||||
var consolePwState = typeof consolePwState !== 'undefined' ? consolePwState : {};
|
||||
var consolePwMask = '••••••••••••••••';
|
||||
function maskConsolePassword(hostID) {
|
||||
var st = consolePwState[hostID];
|
||||
if (st && st.timer) { clearTimeout(st.timer); }
|
||||
consolePwState[hostID] = null; // CLEARS the retained plaintext
|
||||
var code = document.getElementById('console-pw-' + hostID);
|
||||
if (code) { code.textContent = consolePwMask; }
|
||||
var reveal = document.getElementById('console-reveal-' + hostID);
|
||||
if (reveal) { reveal.textContent = 'Reveal'; }
|
||||
var copy = document.getElementById('console-copy-' + hostID);
|
||||
if (copy) { copy.disabled = true; }
|
||||
}
|
||||
function revealConsolePassword(hostID) {
|
||||
if (consolePwState[hostID]) { maskConsolePassword(hostID); return; } // second click hides
|
||||
var hint = document.getElementById('console-hint-' + hostID);
|
||||
var code = document.getElementById('console-pw-' + hostID);
|
||||
var btn = document.getElementById('console-reveal-' + hostID);
|
||||
code.textContent = 'Revealing…';
|
||||
// The endpoint comes from the button's data-reveal-url — the SAME string a render test
|
||||
// asserts, so the assertion cannot pass while the fetch targets somewhere else.
|
||||
fetch(btn.getAttribute('data-reveal-url'), {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': '{{.CSRFToken}}'}
|
||||
}).then(function(r){
|
||||
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
||||
return r.json();
|
||||
}).then(function(d){
|
||||
code.textContent = d.password;
|
||||
consolePwState[hostID] = {pw: d.password, timer: setTimeout(function(){ maskConsolePassword(hostID); }, 60000)};
|
||||
document.getElementById('console-reveal-' + hostID).textContent = 'Hide';
|
||||
document.getElementById('console-copy-' + hostID).disabled = false;
|
||||
}).catch(function(e){
|
||||
code.textContent = consolePwMask;
|
||||
hint.textContent = 'Could not reveal the credential (' + e.message + '). The global-key curl path in the break-glass runbook §3.1 still works.';
|
||||
});
|
||||
}
|
||||
function copyConsolePassword(hostID) {
|
||||
var st = consolePwState[hostID];
|
||||
if (!st) { return; }
|
||||
var pw = st.pw;
|
||||
if (navigator.clipboard) { navigator.clipboard.writeText(pw); }
|
||||
maskConsolePassword(hostID); // re-mask after use
|
||||
}
|
||||
document.addEventListener('visibilitychange', function(){
|
||||
if (document.visibilityState === 'hidden') {
|
||||
for (var id in consolePwState) { if (consolePwState[id]) { maskConsolePassword(id); } }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{if .Deletable}}
|
||||
<!-- Danger zone (v0.47.0): rendered ONLY for non-online hosts — deleting a live
|
||||
host would brick its heartbeat channel, so the affordance never exists for one.
|
||||
|
||||
Reference in New Issue
Block a user