GL-7 Part 2: install-command generator on the customer page

Replaces the static Option-1/2 Setup Command blocks with an interactive,
client-side builder: mode (required radio), cores/memory (required for byo,
optional for appliance), vmid, node, acl-storages (quote-wrapped),
operator-pubkey-file, preserve-state-from, and --dry-run/--preflight-only/
--skip-provision/--allow-new-leaf checkboxes. genFlags()/genUpdate() assemble
a live-updating download-then-run command (never curl|bash) + a local-run
variant, enforcing the script's own rules client-side (mode required; byo
requires caps → shows a warning + no runnable command; appliance hides the
caps requirement; allow-new-leaf shows its leaf-regen warning). Emits ONLY
real host-install v1.12.0 flags; the dangerous/operator-only set
(--force/--rotate-recovery/--enable-oob/--remove-golden/--uninstall/
--adopt-pool/--rescope-acl) is never offered. Graceful static fallback:
the server-rendered Option-1/2 commands keep --customer-id + a --mode
placeholder when JS is off. No framework/CDN/network; ScriptVersion (const,
in sync with SCRIPT_VERSION) drives the header. Render/structure test covers
the control ids, version, fallback, and the excluded-flag absence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 08:29:15 +02:00
parent b93dd03f0a
commit 844fbfa749
4 changed files with 258 additions and 10 deletions
+11
View File
@@ -18,6 +18,12 @@ import (
var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)
// hostInstallVersion is the felhom-host-install.sh version the customer page's install-command
// generator targets. Kept in sync with scripts/felhom-host-install.sh SCRIPT_VERSION — the generator
// only ever emits flags this version parses. Display-only (the Option-1 command downloads the served
// script, which is always current); bump when the generator's flag surface follows a new script.
const hostInstallVersion = "1.12.0"
// validSemver matches a bare X.Y.Z controller version (the floor format). Empty is also accepted by
// the floor handlers (clears the override).
var validSemver = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
@@ -307,6 +313,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
ActiveNav string
CSRFField template.HTML
CSRFToken string
// ScriptVersion drives the install-command generator's header (GL-7). Display-only.
ScriptVersion string
}
// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
@@ -364,6 +373,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
ActiveNav: "configs",
CSRFField: s.csrfField(r),
CSRFToken: s.csrfToken(r),
ScriptVersion: hostInstallVersion,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
+54
View File
@@ -174,3 +174,57 @@ func TestTemplates_PassphraseHardened(t *testing.T) {
t.Errorf("raw secret is the retrieval-pw node's visible default text (must be masked)")
}
}
// GL-7 Part 2/3: the install-command generator renders its control surface, targets the right
// script version, keeps a JS-off static fallback command, and NEVER offers the dangerous/operator-
// only flags as controls.
func TestTemplates_InstallGenerator(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "peti-felhom", CustomerName: "Peti", Domain: "sajatfelhom.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0))
rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/configs/peti-felhom", nil), "peti-felhom")
if rr.Code != 200 {
t.Fatalf("status = %d", rr.Code)
}
html := rr.Body.String()
// control surface present (curated subset — all real v1.12.0 flags)
for _, id := range []string{
`name="gen-mode" value="appliance"`, `name="gen-mode" value="byo"`,
`id="gen-cores"`, `id="gen-memory"`, `id="gen-vmid"`, `id="gen-node"`, `id="gen-acl"`,
`id="gen-pubkey"`, `id="gen-preserve"`, `id="gen-dry"`, `id="gen-preflight"`,
`id="gen-skip"`, `id="gen-leaf"`,
} {
if !strings.Contains(html, id) {
t.Errorf("generator control missing: %s", id)
}
}
// targets the right script version + carries the client-side customer id
if !strings.Contains(html, hostInstallVersion) {
t.Errorf("ScriptVersion %s not rendered", hostInstallVersion)
}
if !strings.Contains(html, `data-customer-id="peti-felhom"`) {
t.Errorf("generator missing data-customer-id")
}
// JS-off static fallback: the Option-1/2 commands still show --customer-id + a mode placeholder
if !strings.Contains(html, "--customer-id peti-felhom --mode") {
t.Errorf("static fallback command missing customer-id + mode")
}
// the dangerous/operator-only flags are NEVER offered as generator controls
for _, f := range []string{"--force", "--rotate-recovery", "--enable-oob", "--remove-golden",
"--uninstall", "--adopt-pool", "--rescope-acl"} {
if strings.Contains(html, f) {
t.Errorf("excluded flag %s must not appear on the customer page", f)
}
}
}
+135 -10
View File
@@ -387,27 +387,83 @@
<section class="card">
<h2>Setup Command</h2>
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
Day-0 host bootstrap. Run on a freshly-PVE-installed Proxmox <strong>host</strong> as root
(create the customer in the hub first). It enrolls the host, installs + verifies the agent,
and provisions the guest; the in-guest controller then pulls its own <code>controller.yaml</code>.
The retrieval passphrase is entered at the no-echo prompt — never on the command line.
Day-0 host bootstrap for host-install <strong>{{.ScriptVersion}}</strong>. Run on a
freshly-PVE-installed Proxmox <strong>host</strong> as root (create the customer in the
hub first). It enrolls the host, installs + verifies the agent, and provisions the guest;
the in-guest controller then pulls its own <code>controller.yaml</code>. The retrieval
passphrase is entered at the no-echo prompt — never on the command line.
</p>
<h3>Option 1: Online install (recommended)</h3>
<!-- Generator controls (client-side only; nothing is submitted) -->
<div class="gen-controls" id="gen-controls" data-customer-id="{{.CustomerID}}">
<div class="form-group">
<label>Mode <span style="color:var(--red,#E5534B)">*</span></label>
<div class="gen-radios">
<label class="gen-radio"><input type="radio" name="gen-mode" value="appliance" onchange="genUpdate()"> appliance <span class="form-hint">(Felhom-owned box)</span></label>
<label class="gen-radio"><input type="radio" name="gen-mode" value="byo" onchange="genUpdate()"> byo <span class="form-hint">(a host you do not own)</span></label>
</div>
</div>
<div class="form-grid" style="margin-top:0.75rem;">
<div class="form-group" id="gen-cores-grp">
<label>Cores <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
<input type="number" id="gen-cores" min="1" placeholder="e.g. 12" oninput="genUpdate()">
<span class="form-hint">byo: required — a conservative slice of the host</span>
</div>
<div class="form-group" id="gen-memory-grp">
<label>Memory (MiB) <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
<input type="number" id="gen-memory" min="256" placeholder="e.g. 32768 (= 32 GB)" oninput="genUpdate()">
<span class="form-hint">byo: required — MiB (32 GB → 32768)</span>
</div>
<div class="form-group">
<label>VMID <span class="form-hint">(optional)</span></label>
<input type="number" id="gen-vmid" min="100" placeholder="e.g. 9201" oninput="genUpdate()">
<span class="form-hint">pick from <code>pct list</code>+<code>qm list</code>; blank = default/auto</span>
</div>
<div class="form-group">
<label>Node <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-node" placeholder="e.g. pve1" oninput="genUpdate()">
<span class="form-hint">required only on a multi-node cluster</span>
</div>
<div class="form-group">
<label>ACL storages <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-acl" placeholder='e.g. local local-lvm' oninput="genUpdate()">
<span class="form-hint">grant the token write access on exactly these storages</span>
</div>
<div class="form-group">
<label>Operator pubkey file <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-pubkey" placeholder="/path/to/operator-keys" oninput="genUpdate()">
<span class="form-hint">arm self-update from day-0</span>
</div>
<div class="form-group">
<label>Preserve state from <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-preserve" placeholder="/var/lib/felhom-agent.old" oninput="genUpdate()">
<span class="form-hint">reinstall keeping the leaf pin stable</span>
</div>
</div>
<div class="gen-checks">
<label class="gen-check"><input type="checkbox" id="gen-dry" onchange="genUpdate()"> <code>--dry-run</code> <span class="form-hint">print, don't execute</span></label>
<label class="gen-check"><input type="checkbox" id="gen-preflight" onchange="genUpdate()"> <code>--preflight-only</code> <span class="form-hint">checks + verdict, no install</span></label>
<label class="gen-check"><input type="checkbox" id="gen-skip" onchange="genUpdate()"> <code>--skip-provision</code> <span class="form-hint">agent only, no guest</span></label>
<label class="gen-check"><input type="checkbox" id="gen-leaf" onchange="genUpdate()"> <code>--allow-new-leaf</code> <span class="form-hint" id="gen-leaf-warn"></span></label>
</div>
</div>
<p class="gen-msg" id="gen-msg" style="display:none;"></p>
<h3 style="margin-top:1rem;">Option 1: Online install (recommended)</h3>
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">
Fetches the script over TLS from felhom.eu, then runs it. Download-then-run (not
<code>curl | sudo bash</code>) so you can inspect the script before executing it — the
right default for a sovereignty product. The passphrase is entered at the no-echo prompt.
Download-then-run (not <code>curl | sudo bash</code>) so you can inspect the script first —
the right default for a sovereignty product. The passphrase is entered at the no-echo prompt.
</p>
<div class="credential-box">
<code id="cmd-online">curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \
&& sudo bash felhom-host-install.sh --customer-id {{.CustomerID}}</code>
&amp;&amp; sudo bash felhom-host-install.sh --customer-id {{.CustomerID}} --mode &lt;appliance|byo&gt;</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-online')" title="Copy">&#x2398;</button>
</div>
<h3 style="margin-top: 1rem;">Option 2: Local install (script already on host)</h3>
<div class="credential-box">
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}}</code>
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}} --mode &lt;appliance|byo&gt;</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">&#x2398;</button>
</div>
@@ -722,6 +778,75 @@
});
}
// ── Install-command generator (GL-7) — client-side only; assembles a command from the controls
// and only ever emits real host-install flags. Nothing is submitted.
function genFlags() {
// returns {flags: "<assembled>", err: "<message or empty>"}
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
if (!mode) return { flags: '', err: 'Pick a mode (appliance or byo) to generate the command.' };
var f = ['--mode ' + mode];
var cores = (document.getElementById('gen-cores').value || '').trim();
var memory = (document.getElementById('gen-memory').value || '').trim();
var isInt = function (v) { return v !== '' && /^[0-9]+$/.test(v) && parseInt(v, 10) > 0; };
if (mode === 'byo') {
if (!isInt(cores) || !isInt(memory)) {
return { flags: '', err: 'byo mode requires --cores and --memory (noisy-neighbor caps on a host you do not own).' };
}
f.push('--cores ' + cores, '--memory ' + memory);
} else {
// appliance: caps optional; emit only if both are valid positive integers
if (isInt(cores)) f.push('--cores ' + cores);
if (isInt(memory)) f.push('--memory ' + memory);
}
var vmid = (document.getElementById('gen-vmid').value || '').trim();
if (vmid !== '' && isInt(vmid)) f.push('--vmid ' + vmid);
var node = (document.getElementById('gen-node').value || '').trim();
if (node) f.push('--node ' + node);
var acl = (document.getElementById('gen-acl').value || '').trim().replace(/\s+/g, ' ');
if (acl) f.push('--acl-storages "' + acl + '"');
var pub = (document.getElementById('gen-pubkey').value || '').trim();
if (pub) f.push('--operator-pubkey-file ' + pub);
var pre = (document.getElementById('gen-preserve').value || '').trim();
if (pre) f.push('--preserve-state-from ' + pre);
if (document.getElementById('gen-skip').checked) f.push('--skip-provision');
if (document.getElementById('gen-dry').checked) f.push('--dry-run');
if (document.getElementById('gen-preflight').checked) f.push('--preflight-only');
if (document.getElementById('gen-leaf').checked) f.push('--allow-new-leaf');
return { flags: f.join(' '), err: '' };
}
function genUpdate() {
var ctrl = document.getElementById('gen-controls');
if (!ctrl) return;
var cid = ctrl.getAttribute('data-customer-id') || '';
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
// byo requires caps → show the required markers only in byo
var reqOn = (mode === 'byo');
document.querySelectorAll('.gen-req').forEach(function (e) { e.style.display = reqOn ? 'inline' : 'none'; });
// allow-new-leaf inline warning
var lw = document.getElementById('gen-leaf-warn');
lw.textContent = document.getElementById('gen-leaf').checked
? '⚠ regenerates the agent leaf — every provisioned guest must then be re-bootstrapped' : '';
var r = genFlags();
var msg = document.getElementById('gen-msg');
var online = document.getElementById('cmd-online');
var local = document.getElementById('cmd-setup');
var base = 'felhom-host-install.sh --customer-id ' + cid;
if (r.err) {
msg.textContent = r.err; msg.style.display = 'block';
// Do NOT emit a runnable command — show the incomplete shape with a placeholder.
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' --mode <appliance|byo>';
local.textContent = 'sudo ./' + base + ' --mode <appliance|byo>';
return;
}
msg.style.display = 'none';
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' ' + r.flags;
local.textContent = 'sudo ./' + base + ' ' + r.flags;
}
// initialize on load (also gives JS-enabled users the "pick a mode" prompt state)
if (document.getElementById('gen-controls')) { genUpdate(); }
function disableGeo(customerID) {
if (!confirm('Összes geo-korlátozás eltávolítása?\n\nEz közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.')) return;
var btn = document.getElementById('btn-geo-disable');
+58
View File
@@ -535,6 +535,64 @@ code {
color: var(--text-3);
}
/* Install-command generator (GL-7) */
.gen-controls {
background: var(--bg-0);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 1rem;
}
.gen-radios {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.gen-radio, .gen-check {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-1);
text-transform: none;
letter-spacing: normal;
font-weight: 400;
cursor: pointer;
}
.gen-radio input, .gen-check input {
accent-color: var(--blue-bright);
cursor: pointer;
}
.gen-checks {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.85rem;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
}
.gen-check code, .gen-radio code { color: var(--blue-bright); font-size: 0.8rem; }
.gen-controls input[type="number"] {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
color: var(--text-1);
font-size: 0.9rem;
font-family: inherit;
}
.gen-controls input[type="number"]:focus,
.gen-controls input[type="text"]:focus { outline: none; border-color: var(--blue-bright); }
.gen-msg {
color: #E5534B;
font-size: 0.82rem;
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
background: rgba(229,83,75,.10);
border: 1px solid rgba(229,83,75,.35);
border-radius: var(--radius);
}
/* Credentials */
.credential-row {
margin-bottom: 0.5rem;