4d6ec7c7bb
gates / gates (push) Successful in 14s
Four paper debts and one fact given a reader. Hub-only — nothing to bake. A4 — the entry about "the tester's machine" named a risk correctly and labelled it in a way that invited deleting it. Established from the hub's own store: `peti-felhom` is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47 this morning. The prompt's premise conflated the two; the register now says which is which. A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out of STATUS's "Waiting on you", which is now empty. A3 — day0-install §C.1 said pushing the installer publishes it. It has not since R-110. Corrected, with the two manifest pins named and an outside-verification command; the one copy that repeated it (a dated audit, true when written) carries a superseded note. A5 — standing rule 5: evidence comes off the machine at the end of the phase that produced it, before any revert. Earned twice in three days on the same box at the same point (R-320). Four homes, plus what to do when it is already gone. R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll` mail kind so the mail names the page a REBUILT box actually shows („A szerver beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach. Naming only; the acceptance pin proves the secret is untouched. R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never drawn as healthy — three absences, three sentences. No alarm, deliberately. Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190. B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now. Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled` reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed. Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
606 lines
37 KiB
HTML
606 lines
37 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 < 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 ≤ one report interval (~15 min),
|
|
agent ≈ 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>
|
|
|
|
<!-- Network (v0.85.0): where this box actually is. Addresses come from the agent's
|
|
addresses[] (agent >= 0.119.0); the WireGuard row pairs the HUB's allocation with
|
|
whether the box confirms holding it, because an allocation alone cannot tell a live
|
|
tunnel from a peer that was never applied. -->
|
|
<section class="card">
|
|
<h2>Network</h2>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">WireGuard</span>
|
|
<span class="value">
|
|
{{if .Network.WGAssignedIP}}<code>{{.Network.WGAssignedIP}}</code>
|
|
{{if .Network.WGConfirmed}}<span class="badge badge-ok" title="The box reports holding this address">confirmed</span>
|
|
{{else if .Network.AgentTooOld}}<span class="badge badge-neutral" title="This agent does not report its addresses">allocated</span>
|
|
{{else}}<span class="badge badge-warn" title="The hub allocated this peer but the box does not report holding the address">not confirmed by the box</span>{{end}}
|
|
{{else}}<span class="text-muted">no peer allocated</span>{{end}}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{{if .Network.AgentTooOld}}
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
|
|
This host's agent (<code>{{.AgentVersion}}</code>) does not report its addresses — they are
|
|
<strong>unknown</strong>, not absent. Needs agent <code>0.119.0</code> or newer.
|
|
</p>
|
|
{{else if .Network.Addresses}}
|
|
<table class="data-table" style="margin-top: 0.75rem;">
|
|
<thead>
|
|
<tr><th>Interface</th><th>Address</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Network.Addresses}}
|
|
<tr>
|
|
<td><code>{{.Iface}}</code></td>
|
|
<td><code>{{.CIDR}}</code></td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
|
|
Every routable address the box holds, as the kernel sees it. Loopback and link-local are
|
|
excluded — including the <code>169.254.253.1</code> local-API island, which is identical on
|
|
every box. The PVE web console is at <code>https://<the LAN address>:8006</code>.
|
|
</p>
|
|
{{else if .HasReport}}
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
|
|
The agent reports no routable address on this host.
|
|
</p>
|
|
{{else}}
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
|
|
Waiting for the first host report.
|
|
</p>
|
|
{{end}}
|
|
</section>
|
|
|
|
<!-- Guest network (R-319): the R-54 watchdog's verdict per owned guest, and — the reason this
|
|
card exists — HOW OFTEN IT HAD TO REPAIR EACH ONE. A guest the watchdog keeps fixing is
|
|
healthy every time anyone looks and is nevertheless failing; showing only the state would
|
|
give it a green tick, which is the failed-disk-drawn-as-a-healthy-empty-disk defect.
|
|
|
|
An unknown is NEVER drawn as healthy. Three absences, three sentences: an agent too old to
|
|
report it, a capable agent that reported nothing, and a guest whose own state the watchdog
|
|
did not assert. Each branch of this gate has its own render test (the template-gate rule).
|
|
The agent has sent this on every heartbeat since v0.92.0 and nothing read it until now. -->
|
|
<section class="card">
|
|
<h2>Guest network
|
|
{{if .GuestNet.AgentTooOld}}<span class="badge badge-neutral" title="This agent predates the guest-network watchdog">unknown</span>
|
|
{{else if not .GuestNet.Reported}}<span class="badge badge-neutral" title="A capable agent sent no guest-network stanza">unknown</span>
|
|
{{else if .GuestNet.Degraded}}<span class="badge badge-warn" title="A guest is unhealthy, or is being repaired repeatedly">needs attention</span>
|
|
{{else if .GuestNet.UnknownCount}}<span class="badge badge-neutral" title="The watchdog did not assert a state for every guest">partly unknown</span>
|
|
{{else if .GuestNet.Guests}}<span class="badge badge-ok" title="Every owned guest has an address, a default route and a live dhclient">healthy</span>
|
|
{{end}}
|
|
</h2>
|
|
|
|
{{if .GuestNet.AgentTooOld}}
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
|
|
This host's agent (<code>{{.AgentVersion}}</code>) does not run the guest-network watchdog —
|
|
its guests' networking is <strong>unknown</strong>, not healthy. Needs agent <code>0.92.0</code> or newer.
|
|
</p>
|
|
{{else if not .GuestNet.Reported}}
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
|
|
This host reported no guest-network state, so it is <strong>unknown</strong> — not healthy.
|
|
The watchdog is default-on; a silent capable agent means it was switched off
|
|
(<code>guest_net.disable</code>), or this report predates it on this box.
|
|
</p>
|
|
{{else if .GuestNet.Guests}}
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr><th>Guest</th><th>State</th><th>Address</th><th>Route</th><th>dhclient</th><th>Repairs (1h)</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .GuestNet.Guests}}
|
|
<tr>
|
|
<td><code>{{.VMID}}</code></td>
|
|
<td>
|
|
{{if .Unknown}}<span class="badge badge-neutral" title="{{.Message}}">unknown</span>
|
|
{{else if eq .State "healthy"}}<span class="badge badge-ok" title="{{.Message}}">healthy</span>
|
|
{{else if eq .State "static_fault"}}<span class="badge badge-warn" title="{{.Message}}">static fault</span>
|
|
{{else}}<span class="badge badge-error" title="{{.Message}}">unhealthy</span>{{end}}
|
|
{{if .HealFailed}}<span class="badge badge-error" title="The watchdog TRIED to repair this guest and did not succeed">repair failed</span>{{end}}
|
|
{{if .Damped}}<span class="badge badge-neutral" title="Repairs are rate-limited on this guest">damped</span>{{end}}
|
|
</td>
|
|
<td>{{if .IP}}<code>{{.IP}}</code> <span class="text-muted">({{.Mode}})</span>{{else}}<span class="text-muted">—</span>{{end}}</td>
|
|
<td>{{if .HasRoute}}yes{{else}}<strong>no</strong>{{end}}</td>
|
|
<td>{{if .DHClientAlive}}yes{{else if eq .Mode "static"}}<span class="text-muted">n/a</span>{{else}}<strong>no</strong>{{end}}</td>
|
|
<td>
|
|
{{if .Repairing}}<span class="badge badge-warn" title="The watchdog repaired this guest's network {{.RepairCount}} time(s) in the last hour — last at {{.LastHealAt}}">{{.RepairCount}}</span>
|
|
{{else}}<span class="text-muted">0</span>{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{if .GuestNet.RepairingCount}}
|
|
<p class="hint" style="font-size: 0.85rem; margin-top: 0.5rem;">
|
|
<strong>{{.GuestNet.RepairingCount}} guest(s) had their networking repaired in the last hour.</strong>
|
|
A guest that keeps being repaired reads healthy between repairs and is not. A killed
|
|
<code>dhclient</code> once took a tunnel down for 1 h 15 m with nobody told; this row is that signal.
|
|
</p>
|
|
{{end}}
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
|
|
Last swept {{.GuestNet.CheckedAt}}. One row per owned <em>running</em> guest the watchdog has probed.
|
|
</p>
|
|
{{else}}
|
|
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
|
|
The watchdog ran and has no guest to report (swept {{.GuestNet.CheckedAt}}). This is
|
|
distinguishable from silence: the stanza arrived, its guest list is empty.
|
|
</p>
|
|
{{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}}">••••••••••••••••</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}}')">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). <strong>Copy</strong> puts it straight on the clipboard without showing it; <strong>Reveal</strong> displays it for 60 s. Either one 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 consoleHint(hostID, msg) {
|
|
var h = document.getElementById('console-hint-' + hostID);
|
|
if (h) { h.textContent = msg; }
|
|
}
|
|
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'; }
|
|
}
|
|
// fetchConsolePassword is the ONE retrieval path, shared by Reveal and Copy — so Copy cannot
|
|
// drift onto a different endpoint, and both are audited identically server-side.
|
|
function fetchConsolePassword(hostID, onOK, onErr) {
|
|
var btn = document.getElementById('console-reveal-' + hostID);
|
|
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){ onOK(d.password); }).catch(onErr);
|
|
}
|
|
function showConsolePassword(hostID, pw) {
|
|
document.getElementById('console-pw-' + hostID).textContent = pw;
|
|
consolePwState[hostID] = {pw: pw, timer: setTimeout(function(){ maskConsolePassword(hostID); }, 60000)};
|
|
document.getElementById('console-reveal-' + hostID).textContent = 'Hide';
|
|
}
|
|
function revealConsolePassword(hostID) {
|
|
if (consolePwState[hostID]) { maskConsolePassword(hostID); return; } // second click hides
|
|
document.getElementById('console-pw-' + hostID).textContent = 'Revealing…';
|
|
fetchConsolePassword(hostID, function(pw){
|
|
showConsolePassword(hostID, pw);
|
|
}, function(e){
|
|
document.getElementById('console-pw-' + hostID).textContent = consolePwMask;
|
|
consoleHint(hostID, 'Could not reveal the credential (' + e.message + '). The global-key curl path in the break-glass runbook §3.1 still works.');
|
|
});
|
|
}
|
|
// Copy works WITHOUT revealing — the safer default, since the secret never renders on screen
|
|
// and so never lands in a screenshot or a shoulder-surf.
|
|
//
|
|
// Every branch below reports its outcome. The version this replaced was disabled until a
|
|
// Reveal and did nothing when clicked, which left the operator's clipboard holding whatever
|
|
// was in it before — in the incident that prompted this, ANOTHER HOST'S console password,
|
|
// pasted into a login that then failed with no clue why. A copy button that silently
|
|
// no-ops is worse than no copy button.
|
|
function copyConsolePassword(hostID) {
|
|
var st = consolePwState[hostID];
|
|
if (st) { writeConsoleClipboard(hostID, st.pw); return; } // already revealed — reuse it
|
|
consoleHint(hostID, 'Copying…');
|
|
fetchConsolePassword(hostID, function(pw){
|
|
writeConsoleClipboard(hostID, pw);
|
|
}, function(e){
|
|
consoleHint(hostID, 'Could not copy the credential (' + e.message + '). The global-key curl path in the break-glass runbook §3.1 still works.');
|
|
});
|
|
}
|
|
function writeConsoleClipboard(hostID, pw) {
|
|
// No clipboard API at all (an insecure context) — degrade to showing it, and SAY so.
|
|
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
|
showConsolePassword(hostID, pw);
|
|
consoleHint(hostID, 'This browser will not give the page clipboard access, so the password is shown instead — copy it manually. It hides again in 60 s.');
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(pw).then(function(){
|
|
maskConsolePassword(hostID);
|
|
// Name the HOST in the confirmation: the clipboard is fleet-wide and every box has a
|
|
// different console password, so "copied" alone does not say copied for WHICH box.
|
|
consoleHint(hostID, '✓ Copied ' + hostID + '\u2019s root@pam password to the clipboard. It stays there until you copy something else.');
|
|
}).catch(function(e){
|
|
// The write was REFUSED (permissions, or not a user gesture). Never claim success.
|
|
showConsolePassword(hostID, pw);
|
|
consoleHint(hostID, 'The clipboard write was refused (' + (e && e.message ? e.message : 'no reason given') + '), so the password is shown instead — copy it manually. It hides again in 60 s.');
|
|
});
|
|
}
|
|
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…</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;">…</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…" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteSubmit('{{.HostID}}')">Confirm & 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}}
|