Files
felhom.eu/hub/internal/web/templates/host_detail_body.html
T
admin 1956e5d390 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.
2026-07-31 08:19:36 +02:00

442 lines
25 KiB
HTML

{{/* host_detail_body — the shared per-host detail sections, rendered by BOTH the
standalone /hosts/{id} page (host_detail.html) and the customer page's Host tab
(customer_unified.html, one instance per host). Data = the map built by
hostDetailData (web/hosts.go); never carries api_key/escrow/PBS secret values. */}}
{{define "host_detail_body"}}
<!-- Identity + Status -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">{{.HostID}}</h2>
<span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span>
</div>
<div class="info-grid" style="margin-top: 1rem;">
<div class="info-item">
<span class="label">Host ID</span>
<span class="value" style="font-family: var(--font-mono)">{{.HostID}}</span>
</div>
<div class="info-item">
<span class="label">Customer</span>
<span class="value"><a href="/customers/{{.CustomerID}}">{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</a></span>
</div>
<div class="info-item">
<span class="label">Agent Version</span>
<span class="value">{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</span>
</div>
{{if .WrapperDrift}}
<div class="info-item">
<span class="label">PBS wrapper</span>
{{if eq .WrapperDrift "ok"}}
<span class="value">matches vouched <code>{{slice .ReportedWrapperSHA 0 12}}…</code></span>
{{else}}
<span class="value" style="color: var(--yellow)">DRIFT — installed <code>{{slice .ReportedWrapperSHA 0 12}}…</code>, vouched <code>{{slice .VouchedWrapperSHA 0 12}}…</code><br>
<span style="font-size:.85em">/usr/local/sbin/felhom-pbs-apply differs from the manifest. It is delivered unversioned from <code>main</code> (R-50b), so this host may be running privileged code from a different commit.</span></span>
{{end}}
</div>
{{end}}
<div class="info-item">
<span class="label">Enrolled</span>
<span class="value">{{timeAgo .CreatedAt}}</span>
</div>
<div class="info-item">
<span class="label">Last Report</span>
<span class="value">{{if .HasReport}}{{timeAgoPtr .LastReportAt}}{{else}}waiting for first report{{end}}</span>
</div>
<div class="info-item">
<span class="label">Desired Generation</span>
<span class="value">{{.DesiredGeneration}}</span>
</div>
{{if .RecoveryMode}}
<div class="info-item">
<span class="label">Recovery Mode</span>
<span class="value" style="color: var(--yellow)">ACTIVE (until {{timeAgoPtr .RecoveryUntil}})</span>
</div>
{{end}}
</div>
</section>
{{if .HasReport}}
<!-- Vitals -->
<section class="card">
<h2>Vitals</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">CPU</span>
<span class="value">{{formatFloat .Vitals.CPUPercent}}%</span>
</div>
<div class="info-item">
<span class="label">Memory</span>
<span class="value">{{formatFloat .Vitals.MemoryPercent}}%</span>
</div>
<div class="info-item">
<span class="label">Disk (root fs)</span>
<span class="value">{{formatFloat .Vitals.DiskPercent}}%</span>
</div>
<div class="info-item">
<span class="label">Cloudflared</span>
<span class="value">{{if .Vitals.CloudflaredStatus}}{{.Vitals.CloudflaredStatus}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Guests</span>
<span class="value">{{.GuestRunning}}/{{.GuestTotal}} running</span>
</div>
</div>
</section>
{{else}}
<section class="card">
<div class="empty-state">
<p>Waiting for first report.</p>
<p class="hint">Vitals, guests and storage appear once this host's agent sends a host-report.</p>
</div>
</section>
{{end}}
<!-- Guests -->
<section class="card" style="padding: 0; overflow: hidden;">
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Guests</h2>
{{if .Guests}}
<table class="data-table">
<thead>
<tr>
<th>VMID</th>
<th>Name</th>
<th>Status</th>
<th>Controller</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{{range .Guests}}
<tr>
<td>{{.VMID}}</td>
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td>
<td>{{if eq .Status "running"}}<span style="color: var(--green)">{{.Status}}</span>{{else if eq .Status "stopped"}}<span style="color: var(--red)">{{.Status}}</span>{{else}}{{.Status}}{{end}}</td>
<td>{{if .ControllerVersion}}<code>{{.ControllerVersion}}</code>{{else}}—{{end}}</td>
<td>{{timeAgoPtr .LastSeenAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No guests reported on this host.</p>
</div>
{{end}}
</section>
<!-- Storage Targets -->
<section class="card" style="padding: 0; overflow: hidden;">
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Storage Targets</h2>
{{if .StorageTargets}}
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Type</th>
<th>State</th>
<th>Fill</th>
<th>Thin Pool</th>
<th>SMART</th>
<th>Temp</th>
<th>Wear</th>
</tr>
</thead>
<tbody>
{{range .StorageTargets}}
<tr>
<td>{{.Name}}</td>
<td>{{if .Role}}{{.Role}}{{else}}—{{end}}</td>
<td>{{if .Type}}{{.Type}}{{else}}—{{end}}</td>
<td>{{if .State}}{{.State}}{{else}}—{{end}}</td>
<td>{{formatFloat .FillPct}}%</td>
<td>{{if .HasThin}}{{formatFloat .ThinDataPct}}%{{else}}—{{end}}</td>
<td>{{if .SmartHealth}}{{if eq .SmartHealth "PASSED"}}<span style="color: var(--green)">{{.SmartHealth}}</span>{{else if eq .SmartHealth "FAILED"}}<span style="color: var(--red)">{{.SmartHealth}}</span>{{else}}{{.SmartHealth}}{{end}}{{else}}—{{end}}</td>
<td>{{if .TempC}}{{.TempC}}°C{{else}}—{{end}}</td>
<td>{{if .WearPct}}{{.WearPct}}%{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No storage targets reported.</p>
</div>
{{end}}
</section>
<!-- Capabilities (v0.51.0): the agent's privileged-capability self-check, rendered.
inactive = disabled by configuration (neutral, DR tier off) — NOT degraded. -->
<section class="card" style="padding: 0; overflow: hidden;">
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Capabilities</h2>
{{if .NeedsDRMigration}}
<p class="hint" style="padding: 0 1.25rem; color: var(--text-muted); font-size: 0.85rem;">
This box predates the uniform DR plumbing (host-install &lt; v1.15.0) — the pbsdr
wrapper is missing. Migration one-liner set (as root on the host): fetch
<code>configs/felhom-pbs-apply</code> from the agent repo to
<code>/usr/local/sbin/felhom-pbs-apply</code> (0755), <code>apt-get install -y age</code>,
set <code>wg_tunnel.enabled: true</code> in agent.json, restart felhom-agent —
see documentation/runbooks/day0-install.md (retrofit).
</p>
{{end}}
{{if .Capabilities}}
<table class="data-table">
<thead>
<tr>
<th>Capability</th>
<th>Status</th>
<th>Feature / reason</th>
</tr>
</thead>
<tbody>
{{range .Capabilities}}
<tr>
<td>{{.Name}}{{if .Critical}} <span class="badge badge-neutral">critical</span>{{end}}</td>
<td><span class="badge {{.Class}}">{{.Status}}</span></td>
<td>{{.Feature}}{{if .Reason}} — {{.Reason}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No capability report yet (waiting for the first host report).</p>
</div>
{{end}}
</section>
<!-- Diagnostics: component log bundles (v0.46.0) -->
<section class="card">
<h2>Diagnostics — Log Bundles</h2>
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
Pull-based: the box ships its debug ring on its own next cycle — controller &le; one report interval (~15 min),
agent &asymp; one heartbeat. The pull is recorded in the box's own log (customer-visible). Bundles expire after 72 h.
</p>
<div style="display: flex; gap: 0.5rem; margin: 0.75rem 0;">
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="controller">
<button type="submit" class="btn btn-sm">Request controller logs</button>
</form>
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="agent">
<button type="submit" class="btn btn-sm">Request agent logs</button>
</form>
</div>
{{if .LogBundles}}
<table class="data-table">
<thead>
<tr>
<th>Component</th>
<th>State</th>
<th>Collected</th>
<th>Received</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .LogBundles}}
<tr>
<td>{{.Component}}</td>
<td>
{{if eq .State "pending"}}<span class="badge badge-neutral" title="Waiting for the box's next cycle (requested {{timeAgo .RequestedAt}})">pending</span>
{{else if eq .State "blocked"}}<span class="badge badge-error" title="{{.BlockedNote}}">blocked: possible secret</span>
{{else}}<span class="badge badge-ok">available</span>{{end}}
</td>
<td>{{if .CollectedAt.IsZero}}—{{else}}{{timeAgo .CollectedAt}}{{end}}</td>
<td>{{if .ReceivedAt.IsZero}}—{{else}}{{timeAgo .ReceivedAt}}{{end}}</td>
<td>{{if .SizeBytes}}{{.SizeBytes}} B{{else}}—{{end}}</td>
<td>
{{if eq .State "available"}}
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}" class="btn btn-sm">View</a>
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}?download=1" class="btn btn-sm">Download</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No log bundles. Use the request buttons above — the box delivers on its next cycle.</p>
</div>
{{end}}
</section>
<!-- DR / Backup -->
<section class="card">
<h2>DR / Backup</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">DR Recipe</span>
<span class="value">{{if .DRPresent}}<span style="color: var(--green)">present</span>{{else}}<span class="text-muted">none</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Key Escrow</span>
<span class="value">{{if .EscrowPresent}}<span style="color: var(--green)">present</span>{{else}}<span class="text-muted">none</span>{{end}}{{if gt .SupersededEscrowCount 0}} <span class="text-muted">· {{.SupersededEscrowCount}} superseded escrow blob(s) retained</span>{{end}}</span>
</div>
</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}}">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</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://&lt;host-ip&gt;: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 &sect;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.
Impact + type-to-confirm dialog per the global-floor confirm pattern. -->
<section class="card" style="border-color: var(--crit);">
<h2>Danger zone</h2>
<p class="text-muted" style="font-size: 0.85rem;">
Removing this host deletes its reports, guests, log bundles and WireGuard peer, and
permanently invalidates its API key — a still-running agent would receive 401s.
Re-enrollment requires the Day-0 passphrase flow.
</p>
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteConfirm('{{.HostID}}')">Remove host&hellip;</button>
<div id="host-delete-confirm-{{.HostID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); background: var(--crit-dim); border-radius: var(--radius); max-width: 44em;">
<p id="host-delete-impact-{{.HostID}}" style="margin: 0 0 0.5rem; font-size: 0.9em;">&hellip;</p>
<label id="host-delete-escrow-row-{{.HostID}}" style="display: none; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="host-delete-escrow-{{.HostID}}">
Move key escrow to retained custody (required when escrow present) + remove DR bundle for this host
</label>
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: var(--text-2);">Type the host id to confirm:</p>
<form method="POST" action="/hosts/{{.HostID}}/delete" id="host-delete-form-{{.HostID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="confirm_host_id" id="host-delete-confirm-hidden-{{.HostID}}" value="">
<input type="hidden" name="delete_escrow" id="host-delete-escrow-hidden-{{.HostID}}" value="">
<input type="text" id="host-delete-confirm-input-{{.HostID}}" placeholder="retype the host id&hellip;" style="padding: 0.3em 0.5em; width: 16em;">
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteSubmit('{{.HostID}}')">Confirm &amp; remove</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('host-delete-confirm-{{.HostID}}').style.display='none';">Cancel</button>
</form>
<p id="host-delete-err-{{.HostID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
<script>
function hostDeleteConfirm(hostID) {
var box = document.getElementById('host-delete-confirm-' + hostID);
var impact = document.getElementById('host-delete-impact-' + hostID);
document.getElementById('host-delete-confirm-input-' + hostID).value = '';
document.getElementById('host-delete-err-' + hostID).textContent = '';
box.style.display = 'block';
impact.textContent = 'Checking impact…';
fetch('/hosts/' + encodeURIComponent(hostID) + '/delete-impact')
.then(function(r){ return r.json(); })
.then(function(d){
var parts = ['Deleting ' + hostID + ' removes ' + d.guests + ' guest row(s), ' +
d.reports + ' host report(s), ' + d.log_bundles + ' agent log bundle(s)'];
if (d.wg_peer_bound) parts.push('the bound WireGuard peer');
if (d.pbs_secret_present) parts.push('the staged PBS secret');
if (d.recovery_present) parts.push('the break-glass recovery credential');
impact.textContent = parts.join(', ') + '. Host status: ' + d.status + '.' +
(d.deletable ? '' : ' Host is ONLINE — deletion will be refused.');
document.getElementById('host-delete-escrow-row-' + hostID).style.display =
d.escrow_present ? 'block' : 'none';
})
.catch(function(){ impact.textContent = 'Could not compute the impact — the server will still enforce every gate.'; });
}
function hostDeleteSubmit(hostID) {
var typed = document.getElementById('host-delete-confirm-input-' + hostID).value.trim();
var err = document.getElementById('host-delete-err-' + hostID);
if (typed !== hostID) { err.textContent = 'Confirmation does not match the host id.'; return; }
var escrowRow = document.getElementById('host-delete-escrow-row-' + hostID);
var escrowCb = document.getElementById('host-delete-escrow-' + hostID);
if (escrowRow.style.display !== 'none' && !escrowCb.checked) {
err.textContent = 'This host has a key escrow — tick the acknowledgement to delete it too.';
return;
}
document.getElementById('host-delete-confirm-hidden-' + hostID).value = typed;
document.getElementById('host-delete-escrow-hidden-' + hostID).value = escrowCb.checked ? '1' : '';
document.getElementById('host-delete-form-' + hostID).submit();
}
</script>
{{end}}
{{end}}