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:
@@ -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