From 844fbfa7492aa78ac6871e493808bd2d572ad0ca Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 9 Jul 2026 08:29:15 +0200 Subject: [PATCH] GL-7 Part 2: install-command generator on the customer page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- hub/internal/web/configs.go | 11 ++ hub/internal/web/render_test.go | 54 +++++++ .../web/templates/customer_unified.html | 145 ++++++++++++++++-- hub/internal/web/templates/style.css | 58 +++++++ 4 files changed, 258 insertions(+), 10 deletions(-) diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 6d76c6f..4f6c810 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -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") diff --git a/hub/internal/web/render_test.go b/hub/internal/web/render_test.go index 6580632..b44fe11 100644 --- a/hub/internal/web/render_test.go +++ b/hub/internal/web/render_test.go @@ -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) + } + } +} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index da0b6c2..f8846fb 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -387,27 +387,83 @@

Setup Command

- Day-0 host bootstrap. Run on a freshly-PVE-installed Proxmox host 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 controller.yaml. - The retrieval passphrase is entered at the no-echo prompt — never on the command line. + Day-0 host bootstrap for host-install {{.ScriptVersion}}. Run on a + freshly-PVE-installed Proxmox host 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 controller.yaml. The retrieval + passphrase is entered at the no-echo prompt — never on the command line.

-

Option 1: Online install (recommended)

+ +
+
+ +
+ + +
+
+
+
+ + + byo: required — a conservative slice of the host +
+
+ + + byo: required — MiB (32 GB → 32768) +
+
+ + + pick from pct list+qm list; blank = default/auto +
+
+ + + required only on a multi-node cluster +
+
+ + + grant the token write access on exactly these storages +
+
+ + + arm self-update from day-0 +
+
+ + + reinstall keeping the leaf pin stable +
+
+
+ + + + +
+
+ + + +

Option 1: Online install (recommended)

- Fetches the script over TLS from felhom.eu, then runs it. Download-then-run (not - curl | sudo bash) 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 curl | sudo bash) so you can inspect the script first — + the right default for a sovereignty product. The passphrase is entered at the no-echo prompt.

curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \ - && sudo bash felhom-host-install.sh --customer-id {{.CustomerID}} + && sudo bash felhom-host-install.sh --customer-id {{.CustomerID}} --mode <appliance|byo>

Option 2: Local install (script already on host)

- sudo ./felhom-host-install.sh --customer-id {{.CustomerID}} + sudo ./felhom-host-install.sh --customer-id {{.CustomerID}} --mode <appliance|byo>
@@ -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: "", err: ""} + 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 '; + local.textContent = 'sudo ./' + base + ' --mode '; + 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'); diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css index e644bb4..a8cffec 100644 --- a/hub/internal/web/templates/style.css +++ b/hub/internal/web/templates/style.css @@ -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;