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:
@@ -1,5 +1,30 @@
|
|||||||
# Felhom Hub — Changelog
|
# 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)
|
## 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
|
Resolves audit finding **F-M1**: the agent enrolls as a *host* and the hub stores rich host state
|
||||||
|
|||||||
+14
-21
@@ -137,24 +137,17 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return entries[i].CustomerID < entries[j].CustomerID
|
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 {
|
data := struct {
|
||||||
Customers []customerListEntry
|
Customers []customerListEntry
|
||||||
GlobalFloor string
|
ActiveNav string
|
||||||
Artifacts store.ArtifactManifest
|
Flash string
|
||||||
ActiveNav string
|
|
||||||
Flash string
|
|
||||||
CSRFToken string
|
|
||||||
CSRFField template.HTML
|
|
||||||
}{
|
}{
|
||||||
Customers: entries,
|
Customers: entries,
|
||||||
GlobalFloor: globalFloor,
|
ActiveNav: "configs",
|
||||||
Artifacts: artifacts,
|
Flash: r.URL.Query().Get("flash"),
|
||||||
ActiveNav: "configs",
|
|
||||||
Flash: r.URL.Query().Get("flash"),
|
|
||||||
CSRFToken: s.csrfToken(r),
|
|
||||||
CSRFField: s.csrfField(r),
|
|
||||||
}
|
}
|
||||||
s.templates.ExecuteTemplate(w, "configs.html", data)
|
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"))
|
v, ok := normalizeFloorInput(r.FormValue("min_controller_version"))
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/configs?flash=floor_invalid", http.StatusSeeOther)
|
http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.SetGlobalMinControllerVersion(v); err != nil {
|
if err := s.store.SetGlobalMinControllerVersion(v); err != nil {
|
||||||
@@ -621,7 +614,7 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Printf("[INFO] Global controller-version floor set to %q", v)
|
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
|
// 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"))
|
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
|
||||||
goldenSHA, okGS := normalizeSHA256(r.FormValue("golden_sha256"))
|
goldenSHA, okGS := normalizeSHA256(r.FormValue("golden_sha256"))
|
||||||
if !okAV || !okGV {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !okAS || !okGS {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
|
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
|
||||||
@@ -673,7 +666,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer)
|
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
|
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
"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
|
// Catches template SYNTAX errors (template.Must in New panics) and that the per-customer floor renders
|
||||||
// two edited pages. Field-level execution errors surface as a non-nil ExecuteTemplate error.
|
// on the Customers list. Field-level execution errors surface as a non-nil ExecuteTemplate error.
|
||||||
func TestTemplates_FloorRender(t *testing.T) {
|
func TestTemplates_FloorRender(t *testing.T) {
|
||||||
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -21,29 +23,50 @@ func TestTemplates_FloorRender(t *testing.T) {
|
|||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)) // template.Must runs here
|
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 {
|
cfgData := struct {
|
||||||
Customers []customerListEntry
|
Customers []customerListEntry
|
||||||
GlobalFloor string
|
ActiveNav string
|
||||||
Artifacts store.ArtifactManifest
|
Flash string
|
||||||
ActiveNav string
|
|
||||||
Flash string
|
|
||||||
CSRFToken string
|
|
||||||
CSRFField string
|
|
||||||
}{
|
}{
|
||||||
Customers: []customerListEntry{{
|
Customers: []customerListEntry{{
|
||||||
CustomerID: "c", EffectiveFloor: "0.87.0", FloorOverride: "0.87.0", BelowFloor: true,
|
CustomerID: "c", EffectiveFloor: "0.87.0", FloorOverride: "0.87.0", BelowFloor: true,
|
||||||
ControllerVersion: "0.86.0", HasConfig: true,
|
ControllerVersion: "0.86.0", HasConfig: true,
|
||||||
}},
|
}},
|
||||||
GlobalFloor: "0.86.0",
|
ActiveNav: "configs",
|
||||||
Artifacts: store.ArtifactManifest{AgentVersion: "0.43.0", GoldenVersion: "0.85.1"},
|
|
||||||
ActiveNav: "configs",
|
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := s.templates.ExecuteTemplate(&buf, "configs.html", cfgData); err != nil {
|
if err := s.templates.ExecuteTemplate(&buf, "configs.html", cfgData); err != nil {
|
||||||
t.Fatalf("render configs.html: %v", err)
|
t.Fatalf("render configs.html: %v", err)
|
||||||
}
|
}
|
||||||
if !bytes.Contains(buf.Bytes(), []byte("0.87.0")) || !bytes.Contains(buf.Bytes(), []byte("global floor")) {
|
if !bytes.Contains(buf.Bytes(), []byte("0.87.0")) {
|
||||||
t.Errorf("configs.html missing floor content")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,13 +241,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
s.handleConfigNewForm(w, r)
|
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 {
|
if r.Method == http.MethodPost {
|
||||||
s.handleSetGlobalFloor(w, r)
|
s.handleSetGlobalFloor(w, r)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
case path == "/configs/artifacts":
|
case path == "/configuration/artifacts":
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
s.handleSetArtifacts(w, r)
|
s.handleSetArtifacts(w, r)
|
||||||
} else {
|
} else {
|
||||||
@@ -587,8 +588,11 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"CSRFToken": csrfToken,
|
"CSRFToken": csrfToken,
|
||||||
|
"CSRFField": s.csrfField(r),
|
||||||
"AssetCount": assetCount,
|
"AssetCount": assetCount,
|
||||||
"AssetLastSync": assetLastSync,
|
"AssetLastSync": assetLastSync,
|
||||||
|
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
|
||||||
|
"Artifacts": s.store.GetArtifactManifest(),
|
||||||
"Flash": r.URL.Query().Get("flash"),
|
"Flash": r.URL.Query().Get("flash"),
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
|
||||||
|
|||||||
@@ -21,56 +21,10 @@
|
|||||||
|
|
||||||
{{if .Flash}}
|
{{if .Flash}}
|
||||||
<div class="flash flash-success">
|
<div class="flash flash-success">
|
||||||
{{if eq .Flash "deleted"}}Customer configuration deleted.
|
{{if eq .Flash "deleted"}}Customer configuration deleted.{{end}}
|
||||||
{{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}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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 & 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;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
<h2 style="margin: 0;">Customers</h2>
|
<h2 style="margin: 0;">Customers</h2>
|
||||||
<a href="/configs/new" class="btn">+ Add Customer</a>
|
<a href="/configs/new" class="btn">+ Add Customer</a>
|
||||||
|
|||||||
@@ -30,6 +30,62 @@
|
|||||||
{{if eq .Flash "assets_not_configured"}}
|
{{if eq .Flash "assets_not_configured"}}
|
||||||
<div class="flash flash-error">Asset manager is not configured.</div>
|
<div class="flash flash-error">Asset manager is not configured.</div>
|
||||||
{{end}}
|
{{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 & 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 -->
|
<!-- Assets section -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
@@ -389,13 +389,25 @@
|
|||||||
The retrieval passphrase is entered at the no-echo prompt — never on the command line.
|
The retrieval passphrase is entered at the no-echo prompt — never on the command line.
|
||||||
</p>
|
</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">⎘</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 1rem;">Option 2: Local install (script already on host)</h3>
|
||||||
<div class="credential-box">
|
<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}}</code>
|
||||||
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
||||||
</div>
|
</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>
|
<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">
|
<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>
|
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
|
||||||
|
|||||||
+10
-1
@@ -67,7 +67,15 @@ data:
|
|||||||
types { application/xml xml; }
|
types { application/xml xml; }
|
||||||
default_type application/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
|
# Cache static assets
|
||||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
expires 7d;
|
expires 7d;
|
||||||
@@ -207,6 +215,7 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
/website/
|
/website/
|
||||||
|
/scripts/
|
||||||
---
|
---
|
||||||
# ===================
|
# ===================
|
||||||
# WEBPAGE (nginx)
|
# WEBPAGE (nginx)
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# Felhom scripts — Changelog
|
# 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)
|
## 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
|
The script now **installs the agent itself** (the last big manual Day-0 prerequisite is gone). It
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
# Options:
|
# Options:
|
||||||
# --mode provision|dr provision (Day-0, default) | dr (10D stub — not impl.)
|
# --mode provision|dr provision (Day-0, default) | dr (10D stub — not impl.)
|
||||||
# --hub-url URL default https://hub.felhom.eu
|
# --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 VOLID golden archive volid (default: newest vzdump of the
|
||||||
# golden build VMID on the archive storage; else fetched
|
# golden build VMID on the archive storage; else fetched
|
||||||
# from Gitea per the hub artifact manifest)
|
# from Gitea per the hub artifact manifest)
|
||||||
@@ -73,7 +75,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_VERSION="1.1.0"
|
SCRIPT_VERSION="1.2.0"
|
||||||
|
|
||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
# Logging (mirrors felhom-controller/scripts/docker-setup.sh)
|
# Logging (mirrors felhom-controller/scripts/docker-setup.sh)
|
||||||
@@ -96,6 +98,7 @@ CUSTOMER_ID=""
|
|||||||
MODE="provision"
|
MODE="provision"
|
||||||
HUB_URL="https://hub.felhom.eu"
|
HUB_URL="https://hub.felhom.eu"
|
||||||
VMID="9201"
|
VMID="9201"
|
||||||
|
VMID_EXPLICIT=false # set true when --vmid is given; gates the auto-pick-a-free-vmid behavior
|
||||||
GOLDEN_VOLID=""
|
GOLDEN_VOLID=""
|
||||||
GOLDEN_VMID="9100"
|
GOLDEN_VMID="9100"
|
||||||
ARCHIVE_STORAGE="local"
|
ARCHIVE_STORAGE="local"
|
||||||
@@ -150,12 +153,35 @@ ART_GOLDEN_SHA=""
|
|||||||
#-------------------------------------------------------------------------------
|
#-------------------------------------------------------------------------------
|
||||||
# Helpers
|
# 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
|
run() { # simple (no pipes/redirects) mutating command
|
||||||
if $DRY_RUN; then log_dry "$*"; else "$@"; fi
|
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 helpers (robust JSON via python3).
|
||||||
_state_has() {
|
_state_has() {
|
||||||
[[ -f "$STATE_FILE" ]] || return 1
|
[[ -f "$STATE_FILE" ]] || return 1
|
||||||
@@ -240,7 +266,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--customer-id) CUSTOMER_ID="$2"; shift 2 ;;
|
--customer-id) CUSTOMER_ID="$2"; shift 2 ;;
|
||||||
--mode) MODE="$2"; shift 2 ;;
|
--mode) MODE="$2"; shift 2 ;;
|
||||||
--hub-url) HUB_URL="$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) GOLDEN_VOLID="$2"; shift 2 ;;
|
||||||
--golden-vmid) GOLDEN_VMID="$2"; shift 2 ;;
|
--golden-vmid) GOLDEN_VMID="$2"; shift 2 ;;
|
||||||
--archive-storage) ARCHIVE_STORAGE="$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)"
|
[[ "$perm" == "600" || "$perm" == "400" ]] || log_warn "passphrase file $PASSPHRASE_FILE is mode $perm (want 600)"
|
||||||
PASSPHRASE="$(< "$PASSPHRASE_FILE")"; PASSPHRASE="${PASSPHRASE%$'\n'}"
|
PASSPHRASE="$(< "$PASSPHRASE_FILE")"; PASSPHRASE="${PASSPHRASE%$'\n'}"
|
||||||
else
|
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
|
fi
|
||||||
[[ -n "$PASSPHRASE" ]] || die "empty passphrase"
|
[[ -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"
|
log_info " golden: none local — will fetch + verify from Gitea in step 7/8"
|
||||||
fi
|
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
|
if $SKIP_PROVISION; then
|
||||||
log_info " --skip-provision: agent install/config only, no guest will be provisioned"
|
log_info " --skip-provision: agent install/config only, no guest will be provisioned"
|
||||||
elif pct status "$VMID" >/dev/null 2>&1; then
|
elif _vmid_in_use "$VMID"; then
|
||||||
if $FORCE; 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"
|
log_warn " vmid $VMID already exists — --force given, it WILL be destroyed by provision"
|
||||||
else
|
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
|
||||||
fi
|
fi
|
||||||
_state_mark preflight
|
_state_mark preflight
|
||||||
|
|||||||
Reference in New Issue
Block a user