Files
felhom.eu/hub/internal/web/templates/configuration.html
T
admin 107f74ea3c hub v0.68.0 — auth_failed self-heal, consumed_at honesty gauge, wrapper drift (R-39 + R-50b(a))
Completes the hub half of R-39's fleet fix on top of the generation core (c484aa2).

pbsdrheal gains an auth_failed TRIGGER — a new trigger in the existing machine, not a
new machine. A box whose credential PBS rejects escalates to a fresh mint, never a
re-stage (which would re-feed the secret PBS just rejected), through the EXISTING damper:
a 401 flap must not become a secret-minting chain. With the generation stamp this closes
the loop end to end — agent proves the 401, hub re-keys, generation advances, descriptor
hash moves, agent re-consumes.

consumed_at honesty gauge: a staged secret still unconsumed past a 15-minute grace while
the box reports `applied` is surfaced with its own event. That is the exact 2026-07-18
fingerprint and a disagreement no single tier can see alone. Deliberately a SURFACE, not
a heal — auto-re-issuing on it would mint a second secret on top of an unconsumed one,
which is the mint/consume race R-39(a) already recorded. One event per distinct report,
and an honestly-stuck box does not double-report (its unconsumed secret is the symptom
being healed, not a contradiction).

R-50b(a): ArtifactManifest.WrapperSHA256 + operator field + host-page drift surface. The
PBS wrapper is root-owned 0755 and the pinned sudoers vector, yet installed unversioned
from raw/branch/main and absent from every manifest. Agents >=0.91.0 report the installed
hash; a mismatch is surfaced. An unknown on EITHER side reads as quiet, never as drift —
lighting every host amber on rollout day is how a warning becomes background noise. The
delivery channel itself stays R-50b(b)/(c).

Compatibility unchanged: safe for 0.90.0 agents (unknown JSON key dropped); the re-arm
and auth-honesty guarantees need agent >=0.91.0, so MinAgent moves only after the fleet
has self-updated.

Tests: auth_failed escalate/debounce/recovery-forgets-streak; honesty gauge incl. grace
window, the restage edge (consumed_at deliberately NULLed), consumed-never-alarms, and
honest-stuck-no-double-report; wrapper drift incl. both unknown directions. Red-proof run
at the assertion level: removing the auth_failed arm fails the escalation tests with
reissues=0.
2026-07-21 10:01:35 +02:00

279 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configuration — Felhom Hub</title>
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
</head>
<body>
{{template "icon_sprite"}}
<div class="container">
<header>
<h1>Felhom <span>Hub</span></h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link">Customers</a>
<a href="/apps" class="nav-link">Apps</a>
<a href="/hosts" class="nav-link">Hosts</a>
<a href="/offsite" class="nav-link">Offsite</a>
<a href="/configuration" class="nav-link active">Configuration</a>
</nav>
</header>
<h2 style="margin-bottom: 1rem;">Configuration</h2>
{{if eq .Flash "assets_refreshed"}}
<div class="flash flash-success">Assets refreshed successfully from image seed.</div>
{{end}}
{{if eq .Flash "assets_error"}}
<div class="flash flash-error">Asset refresh failed. Check server logs for details.</div>
{{end}}
{{if eq .Flash "assets_not_configured"}}
<div class="flash flash-error">Asset manager is not configured.</div>
{{end}}
{{if eq .Flash "floor_set"}}
<div class="flash flash-success">Controller-version floor saved.</div>
{{end}}
{{if eq .Flash "floor_invalid"}}
<div class="flash flash-error">Invalid version — use X.Y.Z (or blank to clear).</div>
{{end}}
{{if eq .Flash "artifacts_set"}}
<div class="flash flash-success">Artifact manifest saved.</div>
{{end}}
{{if eq .Flash "artifact_ver_invalid"}}
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
{{end}}
{{if eq .Flash "artifact_sha_invalid"}}
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
{{end}}
{{if eq .Flash "pw_changed"}}
<div class="flash flash-success">Login password changed. It is already in effect — use it next time you sign in. Existing sessions stay logged in.</div>
{{end}}
{{if eq .Flash "pw_current_wrong"}}
<div class="flash flash-error">Current password is incorrect — password unchanged.</div>
{{end}}
{{if eq .Flash "pw_too_short"}}
<div class="flash flash-error">New password is too short (minimum 8 characters) — password unchanged.</div>
{{end}}
{{if eq .Flash "pw_too_long"}}
<div class="flash flash-error">New password is too long (maximum 72 characters) — password unchanged.</div>
{{end}}
{{if eq .Flash "pw_mismatch"}}
<div class="flash flash-error">New password and confirmation don't match — password unchanged.</div>
{{end}}
{{if eq .Flash "pw_unchanged"}}
<div class="flash flash-error">New password is the same as the current one — nothing changed.</div>
{{end}}
<!-- Phase 2 managed updates: global controller-version floor. ITS OWN card, separate from the
Day-0 artifact manifest below (a manifest save must NEVER touch the live floor — the
publish-train 0.81/0.113 incident). Saving acts IMMEDIATELY, so it is behind a
type-to-confirm dialog that states the live below-floor blast radius first. -->
<section class="card">
<h3 style="margin-top: 0;">Managed updates — global floor</h3>
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
The minimum controller version every box auto-updates to (unless a per-customer override is set).
<strong>Saving takes effect immediately</strong> — boxes below the floor update on their next
report, no customer action. Blank = no global floor. This setting is independent of the Day-0
artifact manifest below.
</p>
<!-- Effective-floor-and-source line: makes the DB-override-vs-env-fallback precedence
permanently visible (the 9-minute-skew incident's root cause). -->
<p style="margin: 0 0 0.75rem; font-size: 0.85em;">
Effective floor:
{{if .FloorRes.Effective}}<code>v{{.FloorRes.Effective}}</code>{{else}}<span class="text-muted">none</span>{{end}}
{{if eq .FloorRes.Source "db"}}
<span style="color: #cbd5e1;">— source: <strong>DB (hub_settings)</strong>{{if .FloorRes.EnvValue}}; env fallback would be <code>v{{.FloorRes.EnvValue}}</code>{{end}}</span>
{{else if eq .FloorRes.Source "env"}}
<span style="color: #cbd5e1;">— source: <strong>env fallback (DEFAULT_MIN_CONTROLLER_VERSION)</strong>; no DB override set</span>
{{else}}
<span class="text-muted">— no floor from either source</span>
{{end}}
</p>
<form id="global-floor-form" method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<input type="text" id="global-floor-input" name="min_controller_version" value="{{.FloorRes.DBValue}}" placeholder="e.g. 0.86.0 (blank = clear DB override)" style="padding: 0.3em 0.5em; width: 16em;">
<button class="btn btn-sm" type="button" onclick="confirmGlobalFloor()">Save global floor…</button>
<span style="font-size: 0.85em; color: #cbd5e1;">DB override: {{if .FloorRes.DBValue}}<code>v{{.FloorRes.DBValue}}</code>{{else}}<span class="text-muted">none</span>{{end}}</span>
</form>
<div id="global-floor-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid #7c3f00; background: #241a0a; border-radius: 6px; max-width: 44em;">
<p id="global-floor-impact" style="margin: 0 0 0.5rem; font-size: 0.9em;"></p>
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: #cbd5e1;">Type the version again to confirm (or <code>CLEAR</code> to remove the DB override):</p>
<input type="text" id="global-floor-confirm-input" placeholder="retype the version…" style="padding: 0.3em 0.5em; width: 16em;">
<button class="btn btn-sm" type="button" onclick="submitGlobalFloor()">Confirm &amp; apply</button>
<button class="btn btn-sm btn-ghost" type="button" onclick="document.getElementById('global-floor-confirm').style.display='none';">Cancel</button>
<p id="global-floor-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: #f87171;"></p>
</div>
<script>
function confirmGlobalFloor() {
var v = document.getElementById('global-floor-input').value.trim();
var box = document.getElementById('global-floor-confirm');
var impact = document.getElementById('global-floor-impact');
document.getElementById('global-floor-confirm-input').value = '';
document.getElementById('global-floor-confirm-err').textContent = '';
box.style.display = 'block';
if (v === '') {
impact.textContent = 'This will CLEAR the DB floor override (the box falls back to the env default). Type CLEAR to confirm.';
return;
}
impact.textContent = 'Checking blast radius…';
fetch('/configuration/global-floor/impact?v=' + encodeURIComponent(v))
.then(function(r){ return r.json(); })
.then(function(d){
if (!d.valid) { impact.textContent = 'Invalid version — use X.Y.Z.'; return; }
impact.textContent = 'Saving the minimum version v' + d.version +
' takes effect immediately — currently ' + d.below +
' box(es) are below this version and would update on their next report.';
})
.catch(function(){ impact.textContent = 'Could not compute the blast radius; proceed with caution.'; });
}
function submitGlobalFloor() {
var v = document.getElementById('global-floor-input').value.trim();
var typed = document.getElementById('global-floor-confirm-input').value.trim();
var err = document.getElementById('global-floor-confirm-err');
var expected = (v === '') ? 'CLEAR' : v;
if (typed !== expected) { err.textContent = 'Confirmation does not match (' + expected + ').'; return; }
document.getElementById('global-floor-form').submit();
}
</script>
</section>
<!-- BUNDLE slice: Day-0 artifact manifest (agent binary + golden archive). The hub is the
checksum TRUST ROOT — the host-bootstrap script verifies Gitea-fetched artifacts against
these sha256s before installing them. Record the version + sha256 printed by
publish-agent.sh / build-golden.sh. -->
<section class="card">
<h3 style="margin-top: 0;">Day-0 artifacts — agent &amp; golden</h3>
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
The current agent binary + golden archive the host-bootstrap script fetches from Gitea and
verifies (sha256) before installing. The hub vouches for these checksums (a different trust
root than Gitea). Pick a version — the sha256 is read from Gitea automatically (no manual
copy). Choose <em>— none —</em> to clear an artifact.
</p>
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 12em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
{{.CSRFField}}
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
{{if .AgentChoices}}
<select name="agent_version" id="agent_version" onchange="syncArtifactSha('agent')" style="padding: 0.3em 0.5em;">
<option value="" data-sha="">— none —</option>
{{range .AgentChoices}}
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.AgentVersion}}selected{{end}}>{{.Version}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.52.0" style="padding: 0.3em 0.5em;">
{{end}}
<input type="text" name="agent_sha256" id="agent_sha256" value="{{.Artifacts.AgentSHA256}}" {{if .AgentChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .AgentChoices}}opacity: 0.7;{{end}}">
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
{{if .GoldenChoices}}
<select name="golden_version" id="golden_version" onchange="syncArtifactSha('golden')" style="padding: 0.3em 0.5em;">
<option value="" data-sha="">— none —</option>
{{range .GoldenChoices}}
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.GoldenVersion}}selected{{end}}>{{.Version}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
{{end}}
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
<label style="font-size: 0.9em; color: #cbd5e1;">Min agent</label>
<input type="text" name="min_agent" value="{{.Artifacts.MinAgent}}" placeholder="e.g. 0.81.0 (blank = uncoupled)" style="padding: 0.3em 0.5em;">
<span style="font-size: 0.8em; color: #94a6bf;">The golden's controller CHANGELOG <code>MinAgent:</code>. The hub HOLDS the floor for any box whose agent is below this — blank = uncoupled release, no gating.</span>
</div>
<div style="margin-bottom: 0.75em;">
<label style="display:inline-block; min-width: 12em;">PBS wrapper sha256</label>
<input type="text" name="wrapper_sha256" value="{{.Artifacts.WrapperSHA256}}" placeholder="64 hex chars (blank = not vouched)" style="padding: 0.3em 0.5em; width: 34em;">
<span style="font-size: 0.8em; color: #94a6bf;">sha256 of <code>configs/felhom-pbs-apply</code> (R-50b). Unlike the agent and golden, this root-owned wrapper is installed from <code>raw/branch/main</code> — unversioned and unpinned. Recording it here does not fix the channel; it makes host drift <em>visible</em>: agents report the installed file's hash and a mismatch is surfaced on the host.</span>
<span></span><span></span>
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
</form>
<script>
// When a version is picked, mirror that option's Gitea-resolved sha256 into the read-only
// display field. The hub re-derives the sha authoritatively on save regardless of this value.
function syncArtifactSha(kind) {
var sel = document.getElementById(kind + '_version');
var sha = document.getElementById(kind + '_sha256');
if (!sel || !sha) return;
var opt = sel.options[sel.selectedIndex];
sha.value = (opt && opt.getAttribute('data-sha')) || '';
}
</script>
</section>
<!-- Operator login password (v0.54.0). Changing it here writes a hub_settings DB override that
WINS over the hub.yaml ConfigMap seed (auth.password_hash); the ConfigMap stays the
break-glass fallback (blank the DB row / edit the manifest to reset a lost password).
Requires the current password. Existing sessions are intentionally kept valid. -->
<section class="card">
<h3 style="margin-top: 0;">Login password</h3>
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
The password for signing in to this hub UI. <strong>Changing it takes effect immediately</strong>
for the next sign-in — your current session stays logged in. Enter your current password to confirm.
If you ever lose it, the deployment ConfigMap (<code>auth.password_hash</code>) remains the reset path.
</p>
<form method="POST" action="/configuration/password" style="display: grid; grid-template-columns: auto 20em; gap: 0.5rem; align-items: center; max-width: 40em;"
onsubmit="return felhomCheckNewPw(this);">
{{.CSRFField}}
<label style="font-size: 0.9em; color: #cbd5e1;">Current password</label>
<input type="password" name="current_password" autocomplete="current-password" required style="padding: 0.3em 0.5em;">
<label style="font-size: 0.9em; color: #cbd5e1;">New password</label>
<input type="password" id="new_password" name="new_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
<label style="font-size: 0.9em; color: #cbd5e1;">Confirm new password</label>
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
<span></span>
<span>
<button class="btn btn-sm" type="submit">Change password</button>
<span id="pw-client-err" style="margin-left: 0.6em; font-size: 0.8em; color: #f87171;"></span>
</span>
</form>
<script>
// Client-side pre-check only (the server re-validates authoritatively): catch the
// mismatch before a round-trip so the operator sees it inline.
function felhomCheckNewPw(form) {
var a = form.new_password.value;
var b = form.confirm_password.value;
var err = document.getElementById('pw-client-err');
err.textContent = '';
if (a.length < 8) { err.textContent = 'New password must be at least 8 characters.'; return false; }
if (a !== b) { err.textContent = 'New password and confirmation do not match.'; return false; }
return true;
}
</script>
</section>
<!-- Assets section -->
<section class="card">
<h3 style="margin-top: 0;">Assets</h3>
<p class="text-muted" style="margin-bottom: 1rem;">
App logos and screenshots served to controllers. Assets are seeded from the Docker image
and synced to controllers daily via the asset manifest API.
</p>
<div class="info-grid">
<div class="info-item">
<span class="label">Files in manifest</span>
<span class="value">{{.AssetCount}}</span>
</div>
<div class="info-item">
<span class="label">Manifest generated</span>
<span class="value" style="font-family: var(--font-mono); font-size: 0.85em;">{{if .AssetLastSync}}{{.AssetLastSync}}{{else}}—{{end}}</span>
</div>
</div>
<form method="POST" action="/configuration" style="margin-top: 1rem;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="refresh_assets">
<button type="submit" class="btn" onclick="this.disabled=true;this.textContent='Refreshing…';this.form.submit();">Refresh Assets from Image</button>
</form>
<p class="text-muted" style="margin-top: 0.75rem; font-size: 0.8rem;">
Re-reads the baked-in asset seed directory and updates changed files.
Controllers will pick up changes on their next daily sync or manual trigger.
</p>
</section>
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
</footer>
</div>
</body>
</html>