1a68b53b06
The golden's version IS the controller it bakes (build-golden.sh:345 defaults GOLDEN_VERSION to the controller tag), so a golden behind the newest deployed controller means every FRESH install lands on stale application code. On the R-120 occurrence that stale code shipped a customer-facing falsehood: a box from the 0.185.1 golden told a customer whose backup drive had fallen out that the backup was on the same disk as the system -- false, the drive was gone -- and offered a different drive as the remedy. WHY A GATE, NOT A REMINDER. The gap has opened three times: R-111 (golden's agent 17 releases behind), R-115 (agent built and deployed, never published), R-120 (this). The first two were closed by re-baking and remembering; remembering then failed again. R-29 is the standing proof that a check nobody runs is worse than none because it reads as coverage -- hostinstall_gates.py sat RED and uninvoked across three version bumps and hub_confirm_gate.py has never run at all. So the property that matters is not whether a check exists but whether it BLOCKS. - Wired into handleSetArtifacts (internal/web/configs.go), immediately before the only write, on the sole UI path to SetArtifactManifest -- it runs on every vouch without anyone choosing to. A script in scripts/ would have been a fourth orphan. - It REFUSES (operator ruling, 2026-07-30), with a flash naming the remedy. - Signal: store.NewestReportedControllerVersion() over reports.controller_version, SEMVER-compared in Go -- MAX() in SQL ranks 0.99.0 above 0.186.0, a pair this fleet has shipped. No outbound call, no new credential. - Fail-open in exactly two deliberate cases: an empty golden field (clearing the manifest is legitimate) and an unknown fleet version (a new hub must vouch its first golden). NEAR-MISS RECORDED: the first draft read guests.controller_version, a column that exists in the schema and that NOTHING writes -- it would always have seen "" and failed open, i.e. inert, this gate's own failure shape. Caught by grepping for a writer before trusting the column. Blind spot stated rather than papered over: a controller no box has ever run is invisible to this signal. Not the failure that has bitten -- all three instances were deployed-newer-than-baked. 4 tests through the PRODUCTION handler over httptest, never an injected seam. The refusal asserts both the flash and that the manifest was NOT written, because a gate that redirects and saves anyway reads as enforcement while providing none. Red-proof: deleting the block makes the stale golden vouchable and both assertions fail. ROADMAP R-29's audit list now records this as the FIRST enforced gate, so the contrast with its three orphans is kept rather than lost. The orphans are unchanged. Suite rc=0 read separately from this commit.
281 lines
19 KiB
HTML
281 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 "golden_behind_fleet"}}
|
|
<div class="flash flash-error"><strong>Refused: that golden is older than the controller the fleet already runs.</strong> A fresh install would land on stale application code — which is R-120, where new boxes shipped a controller that told customers the wrong thing about a missing backup drive. Manifest unchanged. Re-bake the golden on the current controller, publish it, then vouch it here.</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 & 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 & 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>
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">PBS wrapper</label>
|
|
<input type="text" name="wrapper_sha256" value="{{.Artifacts.WrapperSHA256}}" placeholder="64-hex sha256 (blank = not vouched)" style="grid-column: 2 / 4; padding: 0.3em 0.5em; font-family: monospace;">
|
|
<span></span>
|
|
<span style="grid-column: 2 / 4; 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 page.</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>
|