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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user