hub v0.28.0 + host-install v1.2.0: settings→Configuration, online install, vmid auto-detect

Part A (hub): move the global-floor + Day-0-artifacts cards from the Customers
page to the Configuration tab; routes → /configuration/{global-floor,artifacts};
redirects + flashes to /configuration. Customers page back to list + Add.

Part B: online setup command on the customer page (download-then-run, passphrase
at prompt, not templated); serve /scripts/ from the website (sparse-checkout +
nginx location) so felhom.eu/scripts/felhom-host-install.sh resolves; script
passphrase prompt reads < /dev/tty (works for pipe-to-bash too).

Part C (script): --vmid auto-detect — default 9201 in use + no --force → pick the
next free id from pct+qm and confirm; explicit --vmid stays die-unless-force.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 06:43:29 +02:00
parent 54daaf949a
commit 562c0dfeb6
10 changed files with 236 additions and 97 deletions
+25
View File
@@ -1,5 +1,30 @@
# Felhom Hub — Changelog
## v0.28.0 — global settings → Configuration tab + online setup command (2026-07-01)
Three operator-requested improvements (companion: host-install script v1.2.0).
- **Global settings moved from the Customers page to the Configuration tab** (`web/configuration.html`,
`configs.html`, `server.go`, `configs.go`). The two **global** cards — "Managed updates — global floor"
and "Day-0 artifacts — agent & golden" — were on the Customers list; they now render + save on
Configuration (where they belong). `handleConfiguration` supplies `GlobalFloor` + `Artifacts` +
`CSRFField`; the save handlers (`handleSetGlobalFloor`/`handleSetArtifacts`) now redirect to
`/configuration?flash=…` and are mounted at `/configuration/global-floor` + `/configuration/artifacts`;
their flash banners moved too. The Customers page is back to just the list + "Add Customer" (its
per-customer effective-floor column is unchanged).
- **Online setup command added** to a customer's Setup Command block (`customer_unified.html`). New
**"Option 1: Online install (recommended)"** — download-then-run: `curl -fsSL
https://felhom.eu/scripts/felhom-host-install.sh -o … && sudo bash … --customer-id <id>` — with a copy
button and the customer id filled. The passphrase is **not** templated in (entered at the prompt). The
former local-file command becomes Option 2, the debug curl Option 3. Download-then-run (not
`curl | sudo bash`) stays the recommended form — inspect before running.
- **Website now serves `/scripts/`** (`manifests/webpage.yaml`). The host-install script lives at the
repo's `/scripts` (outside the website doc-root); added `/scripts/` to the git-sync sparse-checkout and
an nginx `location /scripts/` (root `.../current`, `text/plain`) so
`https://felhom.eu/scripts/felhom-host-install.sh` resolves — single source of truth, no duplicated copy.
- **Remaining audit follow-ups unchanged:** controller-side geo intent sync; a read-only reported-vs-desired
"Show Diff"; the cosmetic `controllerURL` cleanup in `configs.go`.
## v0.27.0 — Hosts page: read-only fleet view (audit F-M1) (2026-07-01)
Resolves audit finding **F-M1**: the agent enrolls as a *host* and the hub stores rich host state
+14 -21
View File
@@ -137,24 +137,17 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
return entries[i].CustomerID < entries[j].CustomerID
})
artifacts := s.store.GetArtifactManifest()
// GlobalFloor + the artifact manifest are global settings — they render + save on the
// Configuration tab now (handleConfiguration), not here. globalFloor above is still used
// for per-customer effective-floor resolution.
data := struct {
Customers []customerListEntry
GlobalFloor string
Artifacts store.ArtifactManifest
ActiveNav string
Flash string
CSRFToken string
CSRFField template.HTML
Customers []customerListEntry
ActiveNav string
Flash string
}{
Customers: entries,
GlobalFloor: globalFloor,
Artifacts: artifacts,
ActiveNav: "configs",
Flash: r.URL.Query().Get("flash"),
CSRFToken: s.csrfToken(r),
CSRFField: s.csrfField(r),
Customers: entries,
ActiveNav: "configs",
Flash: r.URL.Query().Get("flash"),
}
s.templates.ExecuteTemplate(w, "configs.html", data)
}
@@ -612,7 +605,7 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
}
v, ok := normalizeFloorInput(r.FormValue("min_controller_version"))
if !ok {
http.Redirect(w, r, "/configs?flash=floor_invalid", http.StatusSeeOther)
http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther)
return
}
if err := s.store.SetGlobalMinControllerVersion(v); err != nil {
@@ -621,7 +614,7 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
return
}
s.logger.Printf("[INFO] Global controller-version floor set to %q", v)
http.Redirect(w, r, "/configs?flash=floor_set", http.StatusSeeOther)
http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther)
}
// validSHA256 matches a lowercase 64-hex sha256 digest. Empty is also accepted by the artifact
@@ -655,11 +648,11 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
goldenSHA, okGS := normalizeSHA256(r.FormValue("golden_sha256"))
if !okAV || !okGV {
http.Redirect(w, r, "/configs?flash=artifact_ver_invalid", http.StatusSeeOther)
http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther)
return
}
if !okAS || !okGS {
http.Redirect(w, r, "/configs?flash=artifact_sha_invalid", http.StatusSeeOther)
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
return
}
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
@@ -673,7 +666,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
return
}
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer)
http.Redirect(w, r, "/configs?flash=artifacts_set", http.StatusSeeOther)
http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther)
}
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
+38 -15
View File
@@ -4,15 +4,17 @@ import (
"bytes"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Catches template SYNTAX errors (template.Must in New panics) and that the floor fields render on the
// two edited pages. Field-level execution errors surface as a non-nil ExecuteTemplate error.
// Catches template SYNTAX errors (template.Must in New panics) and that the per-customer floor renders
// on the Customers list. Field-level execution errors surface as a non-nil ExecuteTemplate error.
func TestTemplates_FloorRender(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
if err != nil {
@@ -21,29 +23,50 @@ func TestTemplates_FloorRender(t *testing.T) {
t.Cleanup(func() { st.Close() })
s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)) // template.Must runs here
// configs.html — the list page data shape used by handleConfigList.
// configs.html — the list page data shape used by handleConfigList (global floor/artifacts moved
// to the Configuration tab; the per-customer effective floor still renders here).
cfgData := struct {
Customers []customerListEntry
GlobalFloor string
Artifacts store.ArtifactManifest
ActiveNav string
Flash string
CSRFToken string
CSRFField string
Customers []customerListEntry
ActiveNav string
Flash string
}{
Customers: []customerListEntry{{
CustomerID: "c", EffectiveFloor: "0.87.0", FloorOverride: "0.87.0", BelowFloor: true,
ControllerVersion: "0.86.0", HasConfig: true,
}},
GlobalFloor: "0.86.0",
Artifacts: store.ArtifactManifest{AgentVersion: "0.43.0", GoldenVersion: "0.85.1"},
ActiveNav: "configs",
ActiveNav: "configs",
}
var buf bytes.Buffer
if err := s.templates.ExecuteTemplate(&buf, "configs.html", cfgData); err != nil {
t.Fatalf("render configs.html: %v", err)
}
if !bytes.Contains(buf.Bytes(), []byte("0.87.0")) || !bytes.Contains(buf.Bytes(), []byte("global floor")) {
t.Errorf("configs.html missing floor content")
if !bytes.Contains(buf.Bytes(), []byte("0.87.0")) {
t.Errorf("configs.html missing per-customer floor content")
}
// The global floor + artifact cards must NOT be on the Customers page anymore.
if bytes.Contains(buf.Bytes(), []byte("global floor")) || bytes.Contains(buf.Bytes(), []byte("Day-0 artifacts")) {
t.Errorf("configs.html still renders global settings that moved to Configuration")
}
// configuration.html — the Configuration tab now carries the global floor + artifact settings.
confData := map[string]interface{}{
"CSRFToken": "tok",
"CSRFField": s.csrfField(httptest.NewRequest("GET", "/", nil)),
"AssetCount": 0,
"AssetLastSync": "",
"GlobalFloor": "0.86.0",
"Artifacts": store.ArtifactManifest{AgentVersion: "0.43.0", GoldenVersion: "0.85.1"},
"Flash": "artifacts_set",
}
buf.Reset()
if err := s.templates.ExecuteTemplate(&buf, "configuration.html", confData); err != nil {
t.Fatalf("render configuration.html: %v", err)
}
body := buf.String()
for _, want := range []string{"global floor", "Day-0 artifacts", "0.86.0", "0.43.0",
"/configuration/global-floor", "/configuration/artifacts", "Artifact manifest saved"} {
if !strings.Contains(body, want) {
t.Errorf("configuration.html missing %q", want)
}
}
}
+6 -2
View File
@@ -241,13 +241,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
s.handleConfigNewForm(w, r)
}
case path == "/configs/global-floor":
// Global settings live under the Configuration tab (moved from Customers).
case path == "/configuration/global-floor":
if r.Method == http.MethodPost {
s.handleSetGlobalFloor(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configs/artifacts":
case path == "/configuration/artifacts":
if r.Method == http.MethodPost {
s.handleSetArtifacts(w, r)
} else {
@@ -587,8 +588,11 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"CSRFToken": csrfToken,
"CSRFField": s.csrfField(r),
"AssetCount": assetCount,
"AssetLastSync": assetLastSync,
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
"Artifacts": s.store.GetArtifactManifest(),
"Flash": r.URL.Query().Get("flash"),
}
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
+1 -47
View File
@@ -21,56 +21,10 @@
{{if .Flash}}
<div class="flash flash-success">
{{if eq .Flash "deleted"}}Customer configuration deleted.
{{else if eq .Flash "floor_set"}}Controller-version floor saved.
{{else if eq .Flash "floor_invalid"}}Invalid version — use X.Y.Z (or blank to clear).
{{else if eq .Flash "artifacts_set"}}Artifact manifest saved.
{{else if eq .Flash "artifact_ver_invalid"}}Invalid artifact version — use X.Y.Z (or blank to clear).
{{else if eq .Flash "artifact_sha_invalid"}}Invalid sha256 — use 64 hex chars (or blank to clear).
{{end}}
{{if eq .Flash "deleted"}}Customer configuration deleted.{{end}}
</div>
{{end}}
<!-- Phase 2 managed updates: global controller-version floor -->
<div class="card" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #334155; border-radius: 8px;">
<h2 style="margin: 0 0 0.5rem;">Managed updates — global floor</h2>
<p style="font-size: 0.85em; color: #94a3b8; margin: 0 0 0.5rem;">
The minimum controller version every box auto-updates to (unless a per-customer override is set).
Boxes below the floor update on their next report — no customer action. Blank = no global floor.
</p>
<form method="POST" action="/configs/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<input type="text" name="min_controller_version" value="{{.GlobalFloor}}" placeholder="e.g. 0.86.0 (blank = none)" style="padding: 0.3em 0.5em; width: 14em;">
<button class="btn btn-sm" type="submit">Save global floor</button>
<span style="font-size: 0.85em; color: #cbd5e1;">Current: {{if .GlobalFloor}}<code>v{{.GlobalFloor}}</code>{{else}}<span class="text-muted">unset</span>{{end}}</span>
</form>
</div>
<!-- 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. -->
<div class="card" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #334155; border-radius: 8px;">
<h2 style="margin: 0 0 0.5rem;">Day-0 artifacts — agent &amp; golden</h2>
<p style="font-size: 0.85em; color: #94a3b8; margin: 0 0 0.5rem;">
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). Paste the version + sha256 printed by <code>publish-agent.sh</code> /
<code>build-golden.sh</code>. Blank a field to clear it.
</p>
<form method="POST" action="/configs/artifacts" style="display: grid; grid-template-columns: auto 8em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
{{.CSRFField}}
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.43.0" style="padding: 0.3em 0.5em;">
<input type="text" name="agent_sha256" value="{{.Artifacts.AgentSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
<input type="text" name="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
<span></span><span></span>
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
</form>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Customers</h2>
<a href="/configs/new" class="btn">+ Add Customer</a>
@@ -30,6 +30,62 @@
{{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">Invalid sha256 — use 64 hex chars (or blank to clear).</div>
{{end}}
<!-- Phase 2 managed updates: global controller-version floor (moved from the Customers page —
it is a global setting). -->
<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).
Boxes below the floor update on their next report — no customer action. Blank = no global floor.
</p>
<form method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<input type="text" name="min_controller_version" value="{{.GlobalFloor}}" placeholder="e.g. 0.86.0 (blank = none)" style="padding: 0.3em 0.5em; width: 14em;">
<button class="btn btn-sm" type="submit">Save global floor</button>
<span style="font-size: 0.85em; color: #cbd5e1;">Current: {{if .GlobalFloor}}<code>v{{.GlobalFloor}}</code>{{else}}<span class="text-muted">unset</span>{{end}}</span>
</form>
</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). Paste the version + sha256 printed by <code>publish-agent.sh</code> /
<code>build-golden.sh</code>. Blank a field to clear it.
</p>
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 8em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
{{.CSRFField}}
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.43.0" style="padding: 0.3em 0.5em;">
<input type="text" name="agent_sha256" value="{{.Artifacts.AgentSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
<input type="text" name="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
<span></span><span></span>
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
</form>
</section>
<!-- Assets section -->
<section class="card">
@@ -389,13 +389,25 @@
The retrieval passphrase is entered at the no-echo prompt — never on the command line.
</p>
<h3>Option 1: Host install (recommended)</h3>
<h3>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.
</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>
<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>
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">&#x2398;</button>
</div>
<h3 style="margin-top: 1rem;">Option 2: Manual config fetch (debug only)</h3>
<h3 style="margin-top: 1rem;">Option 3: Manual config fetch (debug only)</h3>
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">The same payload the controller pulls itself — for inspection, not normal provisioning.</p>
<div class="credential-box">
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>