diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index dd071e2..fe43766 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -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 ` — 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 diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 65c3209..00e4c15 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -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 diff --git a/hub/internal/web/render_test.go b/hub/internal/web/render_test.go index 883cc3f..1da7fb7 100644 --- a/hub/internal/web/render_test.go +++ b/hub/internal/web/render_test.go @@ -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) + } } } diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index fe60eaf..4b56cfa 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -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 { diff --git a/hub/internal/web/templates/configs.html b/hub/internal/web/templates/configs.html index b611b9d..162dec0 100644 --- a/hub/internal/web/templates/configs.html +++ b/hub/internal/web/templates/configs.html @@ -21,56 +21,10 @@ {{if .Flash}}
- {{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}}
{{end}} - -
-

Managed updates — global floor

-

- 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. -

-
- {{.CSRFField}} - - - Current: {{if .GlobalFloor}}v{{.GlobalFloor}}{{else}}unset{{end}} -
-
- - -
-

Day-0 artifacts — agent & golden

-

- 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 publish-agent.sh / - build-golden.sh. Blank a field to clear it. -

-
- {{.CSRFField}} - - - - - - - - -
-
-

Customers

+ Add Customer diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html index b5354b5..cac44ad 100644 --- a/hub/internal/web/templates/configuration.html +++ b/hub/internal/web/templates/configuration.html @@ -30,6 +30,62 @@ {{if eq .Flash "assets_not_configured"}}
Asset manager is not configured.
{{end}} + {{if eq .Flash "floor_set"}} +
Controller-version floor saved.
+ {{end}} + {{if eq .Flash "floor_invalid"}} +
Invalid version — use X.Y.Z (or blank to clear).
+ {{end}} + {{if eq .Flash "artifacts_set"}} +
Artifact manifest saved.
+ {{end}} + {{if eq .Flash "artifact_ver_invalid"}} +
Invalid artifact version — use X.Y.Z (or blank to clear).
+ {{end}} + {{if eq .Flash "artifact_sha_invalid"}} +
Invalid sha256 — use 64 hex chars (or blank to clear).
+ {{end}} + + +
+

Managed updates — global floor

+

+ 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. +

+
+ {{.CSRFField}} + + + Current: {{if .GlobalFloor}}v{{.GlobalFloor}}{{else}}unset{{end}} +
+
+ + +
+

Day-0 artifacts — agent & golden

+

+ 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 publish-agent.sh / + build-golden.sh. Blank a field to clear it. +

+
+ {{.CSRFField}} + + + + + + + + +
+
diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 1bddd4c..d69cb17 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -389,13 +389,25 @@ The retrieval passphrase is entered at the no-echo prompt — never on the command line.

-

Option 1: Host install (recommended)

+

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. +

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

Option 2: Local install (script already on host)

sudo ./felhom-host-install.sh --customer-id {{.CustomerID}}
-

Option 2: Manual config fetch (debug only)

+

Option 3: Manual config fetch (debug only)

The same payload the controller pulls itself — for inspection, not normal provisioning.

curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml diff --git a/manifests/webpage.yaml b/manifests/webpage.yaml index 1c88525..f995f9d 100644 --- a/manifests/webpage.yaml +++ b/manifests/webpage.yaml @@ -67,7 +67,15 @@ data: types { application/xml xml; } default_type application/xml; } - + + # Host-install script. It lives at the repo's /scripts (outside the website doc-root), + # synced into .../current/scripts by git-sync (see the sparse-checkout ConfigMap). Served + # as text/plain so operators can inspect it in a browser before download-then-run. + location /scripts/ { + root /usr/share/nginx/html/current; + default_type text/plain; + } + # Cache static assets location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 7d; @@ -207,6 +215,7 @@ metadata: data: sparse-checkout: | /website/ + /scripts/ --- # =================== # WEBPAGE (nginx) diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 06a7b62..36941e9 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,5 +1,25 @@ # Felhom scripts — Changelog +## felhom-host-install.sh v1.2.0 — /dev/tty passphrase read + vmid auto-detect (2026-07-01) + +Two operator-experience fixes so a colleague can install online (via the hub's new "Option 1: Online +install" one-liner) and onto a host that already runs a guest at 9201. + +- **Passphrase prompt reads from `/dev/tty`, not stdin** (`read_passphrase`). `read -rsp … < /dev/tty` + makes the no-echo prompt work regardless of how stdin is wired — both download-then-run **and** + `curl … | sudo bash` (where stdin is the pipe). Strictly more correct; the `--passphrase-file` path is + unchanged. The passphrase is still never on argv / in logs / in the state file. +- **VMID auto-detect (`--vmid` now optional-smart).** New `VMID_EXPLICIT` flag (set by `--vmid`). The + pre-flight vmid guard now determines "in use" against the **`pct list` + `qm list`** id-set (LXC and + VMs share the id space — more complete than the old `pct status`, which only knew LXC): + - **explicit `--vmid`** → unchanged deterministic behavior: die if the id is in use unless `--force` + (destructive over-provision). + - **default 9201, in use, no `--force`** → **auto-pick the next free id** (scan upward from 9201 over + the used-set) and **ask to confirm** from the terminal (`read … < /dev/tty`, `[y/N]`); proceed on + yes, `die "no free vmid confirmed"` otherwise. Never a silent auto-pick. + - **default 9201 + `--force`** → over-provision 9201 (destructive) without prompting, as before. + - New helpers `used_vmids` / `_vmid_in_use` / `next_free_vmid`. `--vmid` help text + `usage()` updated. + ## felhom-host-install.sh v1.1.0 — self-install the agent + fetch the golden from Gitea (2026-06-28) The script now **installs the agent itself** (the last big manual Day-0 prerequisite is gone). It diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 380c289..7c2cb90 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -30,7 +30,9 @@ # Options: # --mode provision|dr provision (Day-0, default) | dr (10D stub — not impl.) # --hub-url URL default https://hub.felhom.eu -# --vmid N guest VMID to provision (default 9201) +# --vmid N guest VMID to provision. Default 9201; if omitted and 9201 is already +# in use, the script auto-picks the next free id (pct+qm) and asks to +# confirm. An EXPLICIT --vmid stays deterministic (dies unless --force). # --golden VOLID golden archive volid (default: newest vzdump of the # golden build VMID on the archive storage; else fetched # from Gitea per the hub artifact manifest) @@ -73,7 +75,7 @@ set -euo pipefail -SCRIPT_VERSION="1.1.0" +SCRIPT_VERSION="1.2.0" #------------------------------------------------------------------------------- # Logging (mirrors felhom-controller/scripts/docker-setup.sh) @@ -96,6 +98,7 @@ CUSTOMER_ID="" MODE="provision" HUB_URL="https://hub.felhom.eu" VMID="9201" +VMID_EXPLICIT=false # set true when --vmid is given; gates the auto-pick-a-free-vmid behavior GOLDEN_VOLID="" GOLDEN_VMID="9100" ARCHIVE_STORAGE="local" @@ -150,12 +153,35 @@ ART_GOLDEN_SHA="" #------------------------------------------------------------------------------- # Helpers #------------------------------------------------------------------------------- -usage() { sed -n '2,61p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } +usage() { sed -n '2,65p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } run() { # simple (no pipes/redirects) mutating command if $DRY_RUN; then log_dry "$*"; else "$@"; fi } +# used_vmids — every in-use guest id on this host. LXC (pct) and VMs (qm) SHARE the id space, +# so both are consulted; headers (non-numeric first column) are filtered out. +used_vmids() { + { pct list 2>/dev/null; qm list 2>/dev/null; } | awk '{print $1}' | grep -E '^[0-9]+$' +} + +# _vmid_in_use ID — true if ID is present in the pct+qm used-set (more complete than `pct status`, +# which only knows LXC). +_vmid_in_use() { + local target="$1" used + used=" $(used_vmids | tr '\n' ' ') " + [[ "$used" == *" $target "* ]] +} + +# next_free_vmid BASE — the first id >= BASE not in the used-set, scanning upward. +next_free_vmid() { + local base="$1" used id + used=" $(used_vmids | tr '\n' ' ') " + id="$base" + while [[ "$used" == *" $id "* ]]; do id=$((id + 1)); done + echo "$id" +} + # State helpers (robust JSON via python3). _state_has() { [[ -f "$STATE_FILE" ]] || return 1 @@ -240,7 +266,7 @@ while [[ $# -gt 0 ]]; do --customer-id) CUSTOMER_ID="$2"; shift 2 ;; --mode) MODE="$2"; shift 2 ;; --hub-url) HUB_URL="$2"; shift 2 ;; - --vmid) VMID="$2"; shift 2 ;; + --vmid) VMID="$2"; VMID_EXPLICIT=true; shift 2 ;; --golden) GOLDEN_VOLID="$2"; shift 2 ;; --golden-vmid) GOLDEN_VMID="$2"; shift 2 ;; --archive-storage) ARCHIVE_STORAGE="$2"; shift 2 ;; @@ -304,7 +330,9 @@ read_passphrase() { [[ "$perm" == "600" || "$perm" == "400" ]] || log_warn "passphrase file $PASSPHRASE_FILE is mode $perm (want 600)" PASSPHRASE="$(< "$PASSPHRASE_FILE")"; PASSPHRASE="${PASSPHRASE%$'\n'}" else - read -rsp "Retrieval passphrase for customer '${CUSTOMER_ID}': " PASSPHRASE; echo "" + # Read from the terminal explicitly (not stdin), so the no-echo prompt works whether the + # script is run from a file OR piped to bash (curl … | sudo bash) — where stdin is the pipe. + read -rsp "Retrieval passphrase for customer '${CUSTOMER_ID}': " PASSPHRASE < /dev/tty; echo "" fi [[ -n "$PASSPHRASE" ]] || die "empty passphrase" } @@ -382,14 +410,29 @@ step_preflight() { log_info " golden: none local — will fetch + verify from Gitea in step 7/8" fi - # vmid guard (irrelevant when --skip-provision: we never touch a guest) + # vmid guard (irrelevant when --skip-provision: we never touch a guest). "In use" is checked + # against the pct+qm id-set (LXC and VMs share the space), not just `pct status`. if $SKIP_PROVISION; then log_info " --skip-provision: agent install/config only, no guest will be provisioned" - elif pct status "$VMID" >/dev/null 2>&1; then - if $FORCE; then + elif _vmid_in_use "$VMID"; then + if $VMID_EXPLICIT; then + # Explicit --vmid stays deterministic: die unless --force (which over-provisions, destructive). + if $FORCE; then + log_warn " vmid $VMID already exists — --force given, it WILL be destroyed by provision" + else + die "vmid $VMID already exists. Refusing to clobber a live guest. Pass --force to provision over it." + fi + elif $FORCE; then + # Default vmid + --force: honor the destructive over-provision without prompting. log_warn " vmid $VMID already exists — --force given, it WILL be destroyed by provision" else - die "vmid $VMID already exists. Refusing to clobber a live guest. Pass --force to provision over it." + # Default vmid in use, no --force: auto-pick the next free id and CONFIRM (never silent). + local free_vmid; free_vmid=$(next_free_vmid "$VMID") + log_info " vmid $VMID is in use; next free vmid is $free_vmid" + local ans; read -rp "VMID $VMID is in use. Use next free VMID $free_vmid? [y/N] " ans < /dev/tty + [[ "$ans" == "y" || "$ans" == "Y" ]] || die "no free vmid confirmed" + VMID="$free_vmid" + log_success " using auto-selected vmid $VMID" fi fi _state_mark preflight