hub v0.16.0 + host-install v1.1.0: Day-0 artifact manifest + self-install the agent (BUNDLE slice)
Hub (v0.16.0):
- store: ArtifactManifest{agent,golden version+sha256} in hub_settings; Get/SetArtifactManifest.
- handler: GET /api/v1/artifacts/{id} (passphrase auth, mirrors config-retrieve). Unset => 200 empty.
- web: operator UI "Day-0 artifacts" card (POST /configs/artifacts), semver + 64-hex validation.
- artifact_test.go: returned-verbatim / unset-empty / 401 / 404 / store round-trip.
host-install (v1.1.0):
- new step 5/8 agent-install: manifest + git token (config-retrieve) -> fetch binary from Gitea ->
verify sha256 vs hub manifest (abort on mismatch) -> install non-root felhom-agent user + binary +
sudoers (visudo -cf) + canonical unit. Idempotent.
- new step 7/8 golden: local fallback else fetch+verify+import from Gitea (--force-gitea-golden).
- agent now runs non-root (privileged.mode sudo), config chowned to the service user.
- README prerequisites trimmed to: install PVE + create customer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,32 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.16.0 — Day-0 artifact manifest (hub-vouched agent + golden checksums) (2026-06-28)
|
||||
|
||||
The hub now serves a passphrase-authed **artifact manifest** so the host-bootstrap script can
|
||||
**fetch-then-verify** the agent binary + golden archive from Gitea before installing them. The hub is
|
||||
the checksum **trust root** — a different root than Gitea (which only stores the bytes). Part of the
|
||||
BUNDLE slice that lets a fresh PVE box self-install the agent (no more manual binary/unit step).
|
||||
|
||||
- **`store.go`:** `ArtifactManifest{agent_version, agent_sha256, golden_version, golden_sha256}` with
|
||||
`Get/SetArtifactManifest`, persisted as four discrete rows in the existing `hub_settings` key/value
|
||||
table (no schema change — same mechanism as the controller-version floor; survives restarts; partial
|
||||
sets round-trip). Added generic `getSetting/setSetting` helpers.
|
||||
- **`handler.go`:** new `GET /api/v1/artifacts/{customer_id}` — auth mirrors `handleConfigRetrieve`
|
||||
EXACTLY (`X-Retrieval-Password`, 404-then-401 order, constant-time compare). Returns
|
||||
`{"agent":{version,sha256},"golden":{version,sha256}}`. An unset manifest returns **200 with empty
|
||||
fields** (not an error) so the script falls back to the local golden / fails clearly on a missing
|
||||
binary. v0.16.0 returns the GLOBAL current set for every customer (per-customer pinning is a future
|
||||
hook).
|
||||
- **`configs.go` + `configs.html` (operator UI):** a "Day-0 artifacts — agent & golden" card beside the
|
||||
managed-update floor controls, with version + sha256 fields for each artifact. `POST /configs/artifacts`
|
||||
(CSRF-protected); versions validated as bare semver (reusing the floor validator), sha256s as 64-hex,
|
||||
blank-to-clear.
|
||||
- **Tests (`artifact_test.go`):** recorded set returned verbatim; unset → 200 empty; wrong/missing
|
||||
passphrase → 401; unknown customer → 404; store partial-set round-trip. Plus the floor-render smoke
|
||||
test updated for the new list-page data field.
|
||||
- Reuse: no new auth path (passphrase, like config-retrieve + host-enroll); no new table (hub_settings);
|
||||
no new fetch credential downstream (the script reuses the config-retrieve git token).
|
||||
|
||||
## v0.15.0 — Phase 2 managed updates: per-customer controller-version floor (2026-06-27)
|
||||
|
||||
The operator can now set a **minimum controller version** (FLOOR) — per-customer, defaulting to a
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// doArtifacts GETs /artifacts/{id} with the passphrase header (the do() helper only sets Bearer).
|
||||
func doArtifacts(h *Handler, customerID, pw string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/artifacts/"+customerID, nil)
|
||||
if pw != "" {
|
||||
req.Header.Set("X-Retrieval-Password", pw)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// Scenario A — a recorded manifest is returned verbatim.
|
||||
func TestArtifacts_ReturnsRecordedSet(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
if err := st.SetArtifactManifest(store.ArtifactManifest{
|
||||
AgentVersion: "0.43.0",
|
||||
AgentSHA256: strings.Repeat("a", 64),
|
||||
GoldenVersion: "0.85.1",
|
||||
GoldenSHA256: strings.Repeat("b", 64),
|
||||
}); err != nil {
|
||||
t.Fatalf("SetArtifactManifest: %v", err)
|
||||
}
|
||||
|
||||
rr := doArtifacts(h, "c1", "pass-phrase")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got artifactManifestResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Agent.Version != "0.43.0" || got.Agent.SHA256 != strings.Repeat("a", 64) {
|
||||
t.Errorf("agent = %+v", got.Agent)
|
||||
}
|
||||
if got.Golden.Version != "0.85.1" || got.Golden.SHA256 != strings.Repeat("b", 64) {
|
||||
t.Errorf("golden = %+v", got.Golden)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B — an unset manifest returns 200 with empty fields (NOT an error). The script falls back
|
||||
// to the local golden / fails clearly on a missing binary.
|
||||
func TestArtifacts_EmptyWhenUnset(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
|
||||
rr := doArtifacts(h, "c1", "pass-phrase")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got artifactManifestResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Agent.Version != "" || got.Agent.SHA256 != "" || got.Golden.Version != "" || got.Golden.SHA256 != "" {
|
||||
t.Errorf("expected all-empty manifest, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C — wrong passphrase is 401 (auth mirrors config-retrieve).
|
||||
func TestArtifacts_WrongPassphrase(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
|
||||
rr := doArtifacts(h, "c1", "wrong")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario D — missing passphrase header is 401.
|
||||
func TestArtifacts_MissingPassphrase(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
|
||||
rr := doArtifacts(h, "c1", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E — unknown customer is 404 (before any auth comparison leaks).
|
||||
func TestArtifacts_UnknownCustomer(t *testing.T) {
|
||||
h, _, _ := newTestHandler(t)
|
||||
|
||||
rr := doArtifacts(h, "nope", "whatever")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario F — store round-trip: a partial set (agent only) persists independently.
|
||||
func TestArtifacts_PartialSetRoundTrips(t *testing.T) {
|
||||
_, st, _ := newTestHandler(t)
|
||||
if err := st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.43.0", AgentSHA256: strings.Repeat("c", 64)}); err != nil {
|
||||
t.Fatalf("SetArtifactManifest: %v", err)
|
||||
}
|
||||
m := st.GetArtifactManifest()
|
||||
if m.AgentVersion != "0.43.0" || m.AgentSHA256 != strings.Repeat("c", 64) {
|
||||
t.Errorf("agent fields not round-tripped: %+v", m)
|
||||
}
|
||||
if m.GoldenVersion != "" || m.GoldenSHA256 != "" {
|
||||
t.Errorf("golden fields should be empty: %+v", m)
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
||||
customerID := strings.TrimPrefix(path, "/config/")
|
||||
h.handleConfigRetrieve(w, r, customerID)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
|
||||
customerID := strings.TrimPrefix(path, "/artifacts/")
|
||||
h.handleArtifactManifest(w, r, customerID)
|
||||
case r.Method == http.MethodGet && path == "/assets/manifest":
|
||||
h.handleAssetsManifest(w, r)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/assets/file/"):
|
||||
@@ -1497,6 +1500,64 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
|
||||
w.Write([]byte(yamlOutput))
|
||||
}
|
||||
|
||||
// artifactManifestResponse is the wire shape the host-bootstrap script consumes: the operator-vouched
|
||||
// current agent binary + golden archive (version + sha256 each). The script fetches each artifact from
|
||||
// Gitea with the config-retrieve git token and verifies its sha256 against THESE values before install
|
||||
// — so the hub is the checksum trust root, a different root than Gitea (which only stores the bytes).
|
||||
type artifactManifestResponse struct {
|
||||
Agent artifactEntry `json:"agent"`
|
||||
Golden artifactEntry `json:"golden"`
|
||||
}
|
||||
|
||||
type artifactEntry struct {
|
||||
Version string `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// handleArtifactManifest serves the current artifact set for a customer. Auth mirrors
|
||||
// handleConfigRetrieve EXACTLY (X-Retrieval-Password header, 404-then-401 order, constant-time
|
||||
// compare) so a script that can pull the controller.yaml can pull the manifest with the same secret.
|
||||
// v0.16.0 returns the GLOBAL current set for every customer (per-customer pinning is a future hook).
|
||||
// An unset manifest returns empty fields (not an error) — the script falls back to the local golden
|
||||
// and fails clearly on a missing binary.
|
||||
func (h *Handler) handleArtifactManifest(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-Retrieval-Password")
|
||||
if password == "" {
|
||||
http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.store.GetCustomerConfig(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] artifacts: customer lookup failed for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
|
||||
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
m := h.store.GetArtifactManifest()
|
||||
resp := artifactManifestResponse{
|
||||
Agent: artifactEntry{Version: m.AgentVersion, SHA256: m.AgentSHA256},
|
||||
Golden: artifactEntry{Version: m.GoldenVersion, SHA256: m.GoldenSHA256},
|
||||
}
|
||||
h.logger.Printf("[INFO] Artifact manifest served for customer %s (agent=%s golden=%s)", customerID, m.AgentVersion, m.GoldenVersion)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// sendResendEmail sends an email via the Resend HTTP API.
|
||||
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
|
||||
payload := map[string]interface{}{
|
||||
|
||||
@@ -868,6 +868,75 @@ func (s *Store) SetGlobalMinControllerVersion(version string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ArtifactManifest is the operator-vouched current artifact set (agent binary + golden archive)
|
||||
// served to the host-bootstrap script so it can verify-before-install. The hub is the TRUST ROOT
|
||||
// for these checksums (a different root than Gitea, which only STORES the bytes): the script fetches
|
||||
// each artifact from Gitea with the config-retrieve git token, then checks its sha256 against the
|
||||
// value recorded here before installing/using it. Empty fields = nothing published yet (the script
|
||||
// then falls back to the local golden / fails clearly on a missing binary).
|
||||
type ArtifactManifest struct {
|
||||
AgentVersion string `json:"agent_version"`
|
||||
AgentSHA256 string `json:"agent_sha256"`
|
||||
GoldenVersion string `json:"golden_version"`
|
||||
GoldenSHA256 string `json:"golden_sha256"`
|
||||
}
|
||||
|
||||
// hub_settings keys for the artifact manifest (BUNDLE slice). Stored as discrete key/value rows in
|
||||
// the existing hub_settings table — same mechanism as the controller-version floor, so it survives
|
||||
// restarts and needs no schema change.
|
||||
const (
|
||||
settingArtifactAgentVersion = "artifact_agent_version"
|
||||
settingArtifactAgentSHA256 = "artifact_agent_sha256"
|
||||
settingArtifactGoldenVersion = "artifact_golden_version"
|
||||
settingArtifactGoldenSHA256 = "artifact_golden_sha256"
|
||||
)
|
||||
|
||||
// getSetting reads a single hub_settings value ("" if the row is absent).
|
||||
func (s *Store) getSetting(key string) string {
|
||||
var v string
|
||||
if err := s.db.QueryRow(`SELECT value FROM hub_settings WHERE key = ?`, key).Scan(&v); err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// setSetting upserts a single hub_settings value.
|
||||
func (s *Store) setSetting(key, value string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO hub_settings (key, value, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||||
key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetArtifactManifest returns the operator-recorded current artifact set. All-empty when nothing
|
||||
// has been published yet.
|
||||
func (s *Store) GetArtifactManifest() ArtifactManifest {
|
||||
return ArtifactManifest{
|
||||
AgentVersion: s.getSetting(settingArtifactAgentVersion),
|
||||
AgentSHA256: s.getSetting(settingArtifactAgentSHA256),
|
||||
GoldenVersion: s.getSetting(settingArtifactGoldenVersion),
|
||||
GoldenSHA256: s.getSetting(settingArtifactGoldenSHA256),
|
||||
}
|
||||
}
|
||||
|
||||
// SetArtifactManifest persists the operator-recorded current artifact set (all four fields). Each
|
||||
// field is stored independently so a partial form submission (e.g. agent only) still round-trips.
|
||||
func (s *Store) SetArtifactManifest(m ArtifactManifest) error {
|
||||
if err := s.setSetting(settingArtifactAgentVersion, m.AgentVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.setSetting(settingArtifactAgentSHA256, m.AgentSHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.setSetting(settingArtifactGoldenVersion, m.GoldenVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.setSetting(settingArtifactGoldenSHA256, m.GoldenSHA256)
|
||||
}
|
||||
|
||||
// EffectiveMinControllerVersion resolves the floor that actually applies to a customer: the
|
||||
// per-customer override when set (non-empty), otherwise the global floor (hub_settings → config/env
|
||||
// default). Returns "" when no floor applies at all (Phase 2 inert for that customer).
|
||||
|
||||
@@ -139,9 +139,12 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
|
||||
return entries[i].CustomerID < entries[j].CustomerID
|
||||
})
|
||||
|
||||
artifacts := s.store.GetArtifactManifest()
|
||||
|
||||
data := struct {
|
||||
Customers []customerListEntry
|
||||
GlobalFloor string
|
||||
Artifacts store.ArtifactManifest
|
||||
ActiveNav string
|
||||
Flash string
|
||||
CSRFToken string
|
||||
@@ -149,6 +152,7 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
|
||||
}{
|
||||
Customers: entries,
|
||||
GlobalFloor: globalFloor,
|
||||
Artifacts: artifacts,
|
||||
ActiveNav: "configs",
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
CSRFToken: s.csrfToken(r),
|
||||
@@ -636,6 +640,58 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/configs?flash=floor_set", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// validSHA256 matches a lowercase 64-hex sha256 digest. Empty is also accepted by the artifact
|
||||
// handler (clears that artifact's checksum).
|
||||
var validSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// normalizeSHA256 trims/lowercases and validates a sha256 submitted from the operator UI.
|
||||
// Returns (value, true) on a valid 64-hex digest or empty string; (_, false) otherwise.
|
||||
func normalizeSHA256(raw string) (string, bool) {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
if v == "" {
|
||||
return "", true
|
||||
}
|
||||
if !validSHA256.MatchString(v) {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden
|
||||
// archive: version + sha256 each) into hub_settings. This is the checksum TRUST ROOT the
|
||||
// host-bootstrap script verifies fetched artifacts against. Versions are validated as bare semver
|
||||
// (reusing the floor validator); sha256s as 64-hex. Empty fields are allowed (clears that field).
|
||||
func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
|
||||
agentSHA, okAS := normalizeSHA256(r.FormValue("agent_sha256"))
|
||||
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)
|
||||
return
|
||||
}
|
||||
if !okAS || !okGS {
|
||||
http.Redirect(w, r, "/configs?flash=artifact_sha_invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
|
||||
AgentVersion: agentVer,
|
||||
AgentSHA256: agentSHA,
|
||||
GoldenVersion: goldenVer,
|
||||
GoldenSHA256: goldenSHA,
|
||||
}); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer)
|
||||
http.Redirect(w, r, "/configs?flash=artifacts_set", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
|
||||
// override. Empty clears the override (the customer then uses the global floor).
|
||||
func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
|
||||
@@ -23,8 +23,9 @@ func TestTemplates_FloorRender(t *testing.T) {
|
||||
|
||||
// configs.html — the list page data shape used by handleConfigList.
|
||||
cfgData := struct {
|
||||
Customers []customerListEntry
|
||||
Customers []customerListEntry
|
||||
GlobalFloor string
|
||||
Artifacts store.ArtifactManifest
|
||||
ActiveNav string
|
||||
Flash string
|
||||
CSRFToken string
|
||||
@@ -35,6 +36,7 @@ func TestTemplates_FloorRender(t *testing.T) {
|
||||
ControllerVersion: "0.86.0", HasConfig: true,
|
||||
}},
|
||||
GlobalFloor: "0.86.0",
|
||||
Artifacts: store.ArtifactManifest{AgentVersion: "0.43.0", GoldenVersion: "0.85.1"},
|
||||
ActiveNav: "configs",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -268,6 +268,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configs/artifacts":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleSetArtifacts(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/delete")
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{{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}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -42,6 +45,31 @@
|
||||
</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;">
|
||||
<h2 style="margin: 0;">Customers</h2>
|
||||
<a href="/configs/new" class="btn">+ Add Customer</a>
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Felhom scripts — Changelog
|
||||
|
||||
## 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
|
||||
fetches the agent binary + golden from Gitea generic packages and **verifies each against the
|
||||
hub-vouched artifact manifest** before installing/using it. BUNDLE slice; pairs with hub v0.16.0
|
||||
(artifact manifest endpoint + operator UI) and felhom-agent v0.43.0 (canonical unit + publish).
|
||||
|
||||
- **New step `5/8 agent install`** (before agent-config): resolves the manifest
|
||||
(`GET /api/v1/artifacts/{id}`, passphrase) + the git fetch token (from the customer's
|
||||
`controller.yaml` via config-retrieve — **NO new credential**); fetches
|
||||
`/api/packages/admin/generic/felhom-agent/<ver>/felhom-agent`, **verifies sha256 vs the hub
|
||||
manifest** (aborts on mismatch — verify-before-use), backs up any existing binary, installs
|
||||
`0755 /usr/local/bin/felhom-agent`; ensures the non-root `felhom-agent` system user; installs the
|
||||
canonical sudoers (`0440`, `visudo -cf`-validated) + systemd unit; `daemon-reload` + enable. Idempotent:
|
||||
same version already installed + service active → skip.
|
||||
- **New step `7/8 golden`:** local auto-discovery stays the default/fallback; otherwise fetches
|
||||
`/api/packages/admin/generic/felhom-golden/<ver>/golden.tar.zst`, **verifies sha256**, and imports it
|
||||
into the archive storage's dump dir for the restore. `--force-gitea-golden` forces the Gitea path.
|
||||
- **Non-root agent model:** the agent now runs as `felhom-agent` with `privileged.mode: "sudo"` (was the
|
||||
dev/CI `direct`+root shortcut). The config is `chown`ed to the service user (0600) so the daemon can
|
||||
read it; `systemctl is-active` after restart is the real proof the non-root user can read the config.
|
||||
- **Pre-flight relaxed:** a missing agent binary is no longer fatal (step 5 installs it); the local
|
||||
golden requirement is deferred to step 7.
|
||||
- **Trust model:** checksum **trust root = the hub** (manifest), not Gitea; the fetch credential is the
|
||||
existing config-retrieve git token; artifacts are pinned to a version (never `:latest`).
|
||||
- **Secrets:** the git token is a never-logged runtime carrier (cleared on EXIT alongside the passphrase
|
||||
/ pve-token / hub api_key); the sudoers is `0440` and `visudo -cf`-validated before install.
|
||||
- `bash -n` + `shellcheck` clean.
|
||||
|
||||
## felhom-host-install.sh v1.0.0 — Day-0 host bootstrap (provision mode) (2026-06-26)
|
||||
|
||||
First release. A single operator-run script that automates Day-0 on a freshly-PVE-installed
|
||||
|
||||
+31
-13
@@ -5,11 +5,17 @@ Operator-side scripts for standing up a Felhom Proxmox host.
|
||||
## `felhom-host-install.sh` — Day-0 host bootstrap (operator-deploy)
|
||||
|
||||
Run on a **freshly-PVE-installed** box to fully automate Day-0: Proxmox API token →
|
||||
hub host enrollment (single secret) → agent config → guest provision → verify. It
|
||||
composes already-proven mechanisms (the `pveum` role/token sequence, the hub
|
||||
`POST /host-enroll` enrollment from option C, and `felhom-agent --selftest=provision`).
|
||||
The agent renders `bootstrap.json` into the guest and the **controller pulls its own
|
||||
`controller.yaml`** in-guest — the script never fetches it.
|
||||
hub host enrollment (single secret) → **agent install (fetch + verify + install)** →
|
||||
agent config → golden → guest provision → verify. It composes already-proven mechanisms
|
||||
(the `pveum` role/token sequence, the hub `POST /host-enroll` enrollment from option C, and
|
||||
`felhom-agent --selftest=provision`). The agent renders `bootstrap.json` into the guest and
|
||||
the **controller pulls its own `controller.yaml`** in-guest — the script never fetches that.
|
||||
|
||||
Since **v1.1.0** (BUNDLE slice) the script also **installs the agent itself**: it fetches the
|
||||
agent binary + golden from Gitea generic packages and **verifies each artifact's sha256 against
|
||||
the hub-vouched manifest** (`GET /api/v1/artifacts/{id}`) before installing/using it. The fetch
|
||||
credential is the **git token already inside the customer's `controller.yaml`** (config-retrieve) —
|
||||
**no new credential**, and the checksum **trust root is the hub**, not Gitea.
|
||||
|
||||
Grounding: [`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`](../documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md).
|
||||
|
||||
@@ -22,10 +28,11 @@ Grounding: [`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`]
|
||||
2. **SSH into the box as root.**
|
||||
3. **Create the customer in the hub first** (hub UI → new customer). The customer's
|
||||
**retrieval passphrase** (a 5-word Hungarian phrase) is the only secret you carry to the box.
|
||||
4. A **golden archive** must exist on the archive storage (newest `vzdump-lxc-<golden-vmid>`).
|
||||
If none exists, build one with `felhom-agent/configs/build-golden.sh` first.
|
||||
5. The **felhom-agent binary** + its systemd unit installed (the script auto-detects the
|
||||
unit's `-config` path; if the binary is absent it tells you to install it).
|
||||
|
||||
That's it. The agent binary + golden are fetched + verified + installed by the script (provided the
|
||||
operator has recorded the current artifact set in the hub UI → Configs → **Day-0 artifacts**, and
|
||||
published them via `felhom-agent/scripts/publish-agent.sh` + `configs/build-golden.sh`). A local
|
||||
golden, if present, is still used as a fallback.
|
||||
|
||||
### Usage
|
||||
|
||||
@@ -74,6 +81,16 @@ per-host hub api_key live **only** in the agent config (`0600`, root).
|
||||
- **Token automation.** Creates/normalises the 16-priv `FelhomAgent` role, the
|
||||
`felhom-agent@pve` user + privsep token, and **both** ACL grants (user **and** token — the
|
||||
ACL is applied *after* the token exists, because `pveum user token remove` purges it).
|
||||
- **Agent install (v1.1.0).** Fetches the binary from Gitea
|
||||
(`/api/packages/admin/generic/felhom-agent/<ver>/felhom-agent`), **verifies its sha256** against the
|
||||
hub manifest, then installs the non-root `felhom-agent` service user + binary + sudoers (0440,
|
||||
`visudo -cf`-validated) + the canonical systemd unit. Idempotent: same version already installed +
|
||||
service active → skips. A sha256 mismatch **aborts** the install (verify-before-use). The agent runs
|
||||
**non-root** (`privileged.mode: "sudo"` + the sudoers allowlist), never as root.
|
||||
- **Golden (v1.1.0).** Uses a local golden when present; otherwise fetches it from Gitea
|
||||
(`/api/packages/admin/generic/felhom-golden/<ver>/golden.tar.zst`), **verifies its sha256**, and
|
||||
imports it into the archive storage's dump dir for the restore. `--force-gitea-golden` forces the
|
||||
Gitea path even when a local golden exists.
|
||||
- **DR mode** (`--mode dr`) is a documented seam only — it restores the customer's **own** PBS
|
||||
whole-CT snapshot instead of the golden. Not implemented (10D).
|
||||
|
||||
@@ -81,7 +98,8 @@ per-host hub api_key live **only** in the agent config (`0600`, root).
|
||||
|
||||
- **Serving:** place this file where the felhom.eu site serves it at
|
||||
`https://felhom.eu/scripts/felhom-host-install.sh` (a static route; verify on deploy).
|
||||
- **Agent binary delivery:** the script expects the agent pre-installed; a fetch-from-release
|
||||
step is the documented hook.
|
||||
- **Golden delivery:** the test used a local golden; central download + checksum verify is the
|
||||
remaining hook.
|
||||
- **Per-customer artifact pinning:** the hub manifest currently returns the global current artifact
|
||||
set for every customer; per-customer pinning is a future hook (`GET /api/v1/artifacts/{id}` already
|
||||
takes the customer id).
|
||||
- **Unit/sudoers integrity:** the binary + golden are sha256-verified against the hub; the unit +
|
||||
sudoers are fetched from the agent repo `main` (canonical text) and the sudoers is `visudo -cf`-validated.
|
||||
|
||||
+280
-35
@@ -1,15 +1,23 @@
|
||||
#!/bin/bash
|
||||
#===============================================================================
|
||||
# felhom-host-install.sh v1.0.0
|
||||
# felhom-host-install.sh v1.1.0
|
||||
# Day-0 host-bootstrap for a Felhom Proxmox host (operator-deploy model).
|
||||
#
|
||||
# Run by the operator on a FRESHLY-PVE-INSTALLED box (after a manual PVE install
|
||||
# + SSH in). Given a customer-id + retrieval passphrase, it fully automates
|
||||
# Day-0: Proxmox API token -> hub host enrollment -> agent config -> guest
|
||||
# provision -> verify. It composes already-proven mechanisms (the pveum role/
|
||||
# token sequence, hub POST /host-enroll [option C], felhom-agent
|
||||
# --selftest=provision). The agent renders bootstrap.json and the controller
|
||||
# pulls its own controller.yaml in-guest; this script does NOT fetch it.
|
||||
# Day-0: Proxmox API token -> hub host enrollment -> AGENT INSTALL (fetch from
|
||||
# Gitea + verify sha256 + install) -> agent config -> golden -> guest provision
|
||||
# -> verify. It composes already-proven mechanisms (the pveum role/token
|
||||
# sequence, hub POST /host-enroll [option C], felhom-agent --selftest=provision).
|
||||
# The agent renders bootstrap.json and the controller pulls its own
|
||||
# controller.yaml in-guest; this script does NOT fetch that.
|
||||
#
|
||||
# v1.1.0 (BUNDLE slice): the agent binary + golden are now fetched from Gitea
|
||||
# generic packages and VERIFIED against the hub-vouched artifact manifest
|
||||
# (GET /api/v1/artifacts/{id}) before install/use. The fetch credential is the
|
||||
# git token already inside the customer's controller.yaml (config-retrieve) — NO
|
||||
# new credential. The checksum trust root is the HUB, not Gitea. This removes the
|
||||
# old prerequisite "install the agent binary + unit manually".
|
||||
#
|
||||
# Grounding: documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md
|
||||
#
|
||||
@@ -24,9 +32,12 @@
|
||||
# --hub-url URL default https://hub.felhom.eu
|
||||
# --vmid N guest VMID to provision (default 9201)
|
||||
# --golden VOLID golden archive volid (default: newest vzdump of the
|
||||
# golden build VMID on the archive storage)
|
||||
# golden build VMID on the archive storage; else fetched
|
||||
# from Gitea per the hub artifact manifest)
|
||||
# --golden-vmid N golden build guest vmid for auto-discovery (default 9100)
|
||||
# --archive-storage NAME storage holding the golden vzdump (default local)
|
||||
# --force-gitea-golden ignore any local golden; fetch+verify the golden from
|
||||
# Gitea (proves the fetch path; used by the live test)
|
||||
# --node NAME PVE node name (default: pvesh /nodes, else hostname)
|
||||
# --bridge-ip IP[:PORT] local-api listen addr (default: vmbr0 IP : 8443)
|
||||
# --rootfs-grow N grow OS rootfs by N GiB (default: auto-compute)
|
||||
@@ -52,7 +63,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_VERSION="1.0.0"
|
||||
SCRIPT_VERSION="1.1.0"
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Logging (mirrors felhom-controller/scripts/docker-setup.sh)
|
||||
@@ -86,9 +97,20 @@ SYSDATA_GROW=""
|
||||
PASSPHRASE_FILE=""
|
||||
PRESERVE_FROM=""
|
||||
FORCE=false
|
||||
FORCE_GITEA_GOLDEN=false
|
||||
DRY_RUN=false
|
||||
RESUME=false
|
||||
|
||||
# --- Gitea (artifact source) + agent install model (BUNDLE slice) ---
|
||||
GITEA_BASE="https://gitea.dooplex.hu"
|
||||
GITEA_OWNER="admin"
|
||||
AGENT_REPO="felhom-agent" # for the raw unit/sudoers fetch (config text, canonical source)
|
||||
AGENT_USER="felhom-agent" # the non-root service user the unit + sudoers name
|
||||
AGENT_BIN="/usr/local/bin/felhom-agent"
|
||||
AGENT_SUDOERS="/etc/sudoers.d/felhom-agent"
|
||||
AGENT_UNIT="/etc/systemd/system/felhom-agent.service"
|
||||
AGENT_STATE_DIR="/var/lib/felhom-agent"
|
||||
|
||||
PVE_USER="felhom-agent@pve"
|
||||
PVE_TOKENID="agent"
|
||||
PVE_ROLE="FelhomAgent"
|
||||
@@ -105,11 +127,17 @@ PASSPHRASE=""
|
||||
PVE_TOKEN="" # felhom-agent@pve!agent=<secret>
|
||||
HOST_ID=""
|
||||
HOST_API_KEY=""
|
||||
GIT_USER="" # from controller.yaml (config-retrieve) — Gitea fetch credential
|
||||
GIT_TOKEN="" # from controller.yaml — NEVER logged
|
||||
ART_AGENT_VER="" # hub artifact manifest: agent version + sha256
|
||||
ART_AGENT_SHA=""
|
||||
ART_GOLDEN_VER="" # hub artifact manifest: golden version + sha256
|
||||
ART_GOLDEN_SHA=""
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Helpers
|
||||
#-------------------------------------------------------------------------------
|
||||
usage() { sed -n '2,55p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }
|
||||
usage() { sed -n '2,61p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }
|
||||
|
||||
run() { # simple (no pipes/redirects) mutating command
|
||||
if $DRY_RUN; then log_dry "$*"; else "$@"; fi
|
||||
@@ -134,6 +162,63 @@ http_code() { # GET, prints status code only (read-only preflight)
|
||||
curl -fsS -o /dev/null -w '%{http_code}' "$@" 2>/dev/null || curl -sS -o /dev/null -w '%{http_code}' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Artifact + Gitea helpers (BUNDLE slice)
|
||||
#-------------------------------------------------------------------------------
|
||||
# Resolve the hub-vouched artifact manifest (agent + golden version+sha256). Passphrase-authed,
|
||||
# same trust root as config-retrieve. Sets ART_* globals. Empty fields are valid (caller falls back).
|
||||
resolve_artifacts() {
|
||||
local resp code body
|
||||
resp=$(curl -sS -w $'\n%{http_code}' "$HUB_URL/api/v1/artifacts/$CUSTOMER_ID" \
|
||||
-H "X-Retrieval-Password: $PASSPHRASE")
|
||||
code=$(tail -n1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||
[[ "$code" == "200" ]] || die "artifact manifest fetch failed: HTTP $code"
|
||||
ART_AGENT_VER=$(python3 -c "import json,sys;print(json.loads(sys.argv[1])['agent']['version'])" "$body" 2>/dev/null || echo "")
|
||||
ART_AGENT_SHA=$(python3 -c "import json,sys;print(json.loads(sys.argv[1])['agent']['sha256'])" "$body" 2>/dev/null || echo "")
|
||||
ART_GOLDEN_VER=$(python3 -c "import json,sys;print(json.loads(sys.argv[1])['golden']['version'])" "$body" 2>/dev/null || echo "")
|
||||
ART_GOLDEN_SHA=$(python3 -c "import json,sys;print(json.loads(sys.argv[1])['golden']['sha256'])" "$body" 2>/dev/null || echo "")
|
||||
}
|
||||
|
||||
# Resolve the Gitea fetch credential (git username + token) from the customer's controller.yaml —
|
||||
# the SAME secret config-retrieve already hands out (NO new credential). Sets GIT_USER / GIT_TOKEN.
|
||||
# Parses the git: block without a YAML lib (fresh PVE has no PyYAML).
|
||||
resolve_git_creds() {
|
||||
local yaml
|
||||
yaml=$(curl -fsS "$HUB_URL/api/v1/config/$CUSTOMER_ID" -H "X-Retrieval-Password: $PASSPHRASE") \
|
||||
|| die "controller.yaml fetch failed (for the git fetch token)"
|
||||
GIT_USER=$(awk '/^[^[:space:]#]/{ingit=($1=="git:")} ingit&&$1=="username:"{print $2}' <<<"$yaml" | head -1)
|
||||
GIT_TOKEN=$(awk '/^[^[:space:]#]/{ingit=($1=="git:")} ingit&&$1=="token:"{print $2}' <<<"$yaml" | head -1)
|
||||
# strip any surrounding quotes
|
||||
GIT_USER="${GIT_USER%\"}"; GIT_USER="${GIT_USER#\"}"
|
||||
GIT_TOKEN="${GIT_TOKEN%\"}"; GIT_TOKEN="${GIT_TOKEN#\"}"
|
||||
[[ -n "$GIT_TOKEN" ]] || die "no git token in controller.yaml — cannot fetch artifacts from Gitea"
|
||||
}
|
||||
|
||||
# Fetch a Gitea generic-package URL to a dest with the git token, then VERIFY its sha256 against the
|
||||
# expected (hub-vouched) value. Aborts on any mismatch — verify-before-use. $1=url $2=dest $3=expected_sha
|
||||
fetch_verify() {
|
||||
local url="$1" dest="$2" want="$3"
|
||||
[[ -n "$want" ]] || die "refusing to install an artifact with no expected sha256 (manifest incomplete): $url"
|
||||
curl -fsS -u "${GIT_USER}:${GIT_TOKEN}" -o "$dest" "$url" || die "fetch failed: $url"
|
||||
local got; got=$(sha256sum "$dest" | awk '{print $1}')
|
||||
if [[ "$got" != "$want" ]]; then
|
||||
rm -f "$dest"
|
||||
die "sha256 MISMATCH for $url — expected $want got $got. Refusing to install (verify-before-use)."
|
||||
fi
|
||||
log_success " verified sha256 ${got:0:16}… matches the hub manifest"
|
||||
}
|
||||
|
||||
# Fetch a raw config file (the canonical unit/sudoers) from the agent repo with the git token. These
|
||||
# are non-executable text (not the integrity-checked binary); the sudoers is `visudo -cf`-validated
|
||||
# before install, which catches corruption/tampering that would matter. $1=repo-path $2=dest
|
||||
fetch_raw() {
|
||||
local path="$1" dest="$2"
|
||||
curl -fsS -u "${GIT_USER}:${GIT_TOKEN}" -o "$dest" \
|
||||
"$GITEA_BASE/$GITEA_OWNER/$AGENT_REPO/raw/branch/main/$path" \
|
||||
|| die "raw fetch failed: $path"
|
||||
[[ -s "$dest" ]] || die "raw fetch empty: $path"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Arg parse
|
||||
#-------------------------------------------------------------------------------
|
||||
@@ -154,6 +239,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--passphrase-file) PASSPHRASE_FILE="$2"; shift 2 ;;
|
||||
--preserve-from) PRESERVE_FROM="$2"; shift 2 ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--force-gitea-golden) FORCE_GITEA_GOLDEN=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--resume) RESUME=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
@@ -211,7 +297,7 @@ read_passphrase() {
|
||||
# STEP 1 — pre-flight (fail fast before any mutation)
|
||||
#-------------------------------------------------------------------------------
|
||||
step_preflight() {
|
||||
log_step "1/7 pre-flight"
|
||||
log_step "1/8 pre-flight"
|
||||
[[ $EUID -eq 0 ]] || die "must run as root"
|
||||
command -v pveum >/dev/null || die "pveum not found — is this a Proxmox VE host?"
|
||||
command -v pct >/dev/null || die "pct not found — is this a Proxmox VE host?"
|
||||
@@ -235,8 +321,13 @@ step_preflight() {
|
||||
fi
|
||||
[[ -n "$AGENT_CONFIG" ]] || AGENT_CONFIG="/etc/felhom-agent/agent.json"
|
||||
log_info " agent config: $AGENT_CONFIG"
|
||||
command -v felhom-agent >/dev/null || die "felhom-agent binary not installed (fetch/install it first — see scripts/README.md)"
|
||||
log_info " agent: $(felhom-agent --version 2>&1 | head -1)"
|
||||
# v1.1.0: the agent binary is no longer a prerequisite — the agent-install step (5/8) fetches it
|
||||
# from Gitea + verifies it. Just report what's present (if anything).
|
||||
if command -v felhom-agent >/dev/null 2>&1; then
|
||||
log_info " agent (existing): $(felhom-agent --version 2>&1 | head -1)"
|
||||
else
|
||||
log_info " agent: not installed yet — will be fetched + installed in step 5/8"
|
||||
fi
|
||||
|
||||
# local-lvm free space
|
||||
local free_gib
|
||||
@@ -263,13 +354,17 @@ step_preflight() {
|
||||
*) die "unexpected hub status $code on config preflight" ;;
|
||||
esac
|
||||
|
||||
# golden archive
|
||||
if [[ -z "$GOLDEN_VOLID" ]]; then
|
||||
# golden archive — auto-discover a LOCAL one for info; the golden step (7/8) ensures one exists
|
||||
# (local else Gitea-fetched + verified), so a missing local golden is no longer fatal here.
|
||||
if [[ -z "$GOLDEN_VOLID" ]] && ! $FORCE_GITEA_GOLDEN; then
|
||||
GOLDEN_VOLID=$(pvesm list "$ARCHIVE_STORAGE" --content backup 2>/dev/null | awk -v v="$GOLDEN_VMID" '$0 ~ ("vzdump-lxc-" v "-"){print $1}' | sort | tail -1)
|
||||
fi
|
||||
[[ -n "$GOLDEN_VOLID" ]] || die "no golden archive found for vmid $GOLDEN_VMID on $ARCHIVE_STORAGE — run build-golden.sh first"
|
||||
pvesm list "$ARCHIVE_STORAGE" --content backup 2>/dev/null | grep -q "$(basename "$GOLDEN_VOLID")" || die "golden volid not resolvable: $GOLDEN_VOLID"
|
||||
log_info " golden: $GOLDEN_VOLID"
|
||||
if [[ -n "$GOLDEN_VOLID" ]]; then
|
||||
pvesm list "$ARCHIVE_STORAGE" --content backup 2>/dev/null | grep -q "$(basename "$GOLDEN_VOLID")" || die "golden volid not resolvable: $GOLDEN_VOLID"
|
||||
log_info " golden (local): $GOLDEN_VOLID"
|
||||
else
|
||||
log_info " golden: none local — will fetch + verify from Gitea in step 7/8"
|
||||
fi
|
||||
|
||||
# vmid guard
|
||||
if pct status "$VMID" >/dev/null 2>&1; then
|
||||
@@ -287,7 +382,7 @@ step_preflight() {
|
||||
# STEP 2 — Proxmox API token (idempotent pveum; reuse-if-working else rotate)
|
||||
#-------------------------------------------------------------------------------
|
||||
step_token() {
|
||||
log_step "2/7 Proxmox API token"
|
||||
log_step "2/8 Proxmox API token"
|
||||
if should_skip token && [[ -n "$PVE_TOKEN" ]]; then return 0; fi
|
||||
|
||||
# role: create or modify to the exact 16 privs
|
||||
@@ -348,7 +443,7 @@ step_token() {
|
||||
# STEP 3 — compute grows (floors) if not passed
|
||||
#-------------------------------------------------------------------------------
|
||||
step_grows() {
|
||||
log_step "3/7 compute volume grows"
|
||||
log_step "3/8 compute volume grows"
|
||||
# Golden base: rootfs 32G + Docker-data 16G + user-data 8G (build-golden.sh).
|
||||
if [[ -z "$ROOTFS_GROW$DATAVOL_GROW$SYSDATA_GROW" ]]; then
|
||||
local free_gib
|
||||
@@ -373,7 +468,7 @@ step_grows() {
|
||||
# STEP 4 — host enroll (option C; single secret, no global key)
|
||||
#-------------------------------------------------------------------------------
|
||||
step_enroll() {
|
||||
log_step "4/7 host enrollment (POST /host-enroll)"
|
||||
log_step "4/8 host enrollment (POST /host-enroll)"
|
||||
if $DRY_RUN; then
|
||||
log_dry "curl -fsS -X POST $HUB_URL/api/v1/host-enroll -H 'X-Retrieval-Password: <pass>' -d '{\"customer_id\":\"$CUSTOMER_ID\"}'"
|
||||
HOST_ID="<dry-run-host-id>"; HOST_API_KEY="<dry-run-key>"; _state_mark enroll; return 0
|
||||
@@ -398,10 +493,96 @@ step_enroll() {
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# STEP 5 — write agent config + ensure service healthy
|
||||
# STEP 5 — agent install: fetch+verify the binary, ensure the service user, sudoers, unit
|
||||
#-------------------------------------------------------------------------------
|
||||
# Closes the old prerequisite "install the agent binary + unit manually". Fetches the binary from
|
||||
# Gitea (git token from controller.yaml), VERIFIES its sha256 against the hub manifest, then installs
|
||||
# the non-root felhom-agent user + binary + sudoers + unit. The SERVICE is started in step 6 (after the
|
||||
# config is written) — here we only install + daemon-reload + enable.
|
||||
step_agent_install() {
|
||||
log_step "5/8 agent install (fetch + verify + install)"
|
||||
|
||||
# Manifest + git fetch credential (both passphrase / config-retrieve — NO new credential).
|
||||
resolve_artifacts
|
||||
resolve_git_creds
|
||||
[[ -n "$ART_AGENT_VER" ]] || die "hub artifact manifest has no agent version — set it in the operator UI (Configs → Day-0 artifacts)"
|
||||
log_info " manifest: agent v$ART_AGENT_VER (sha ${ART_AGENT_SHA:0:16}…), golden v${ART_GOLDEN_VER:-<unset>}"
|
||||
|
||||
# Idempotent skip: same version already installed AND the service is healthy.
|
||||
local cur=""
|
||||
[[ -x "$AGENT_BIN" ]] && cur=$("$AGENT_BIN" --version 2>/dev/null | awk '{print $2}')
|
||||
if [[ "$cur" == "$ART_AGENT_VER" ]] && systemctl is-active --quiet felhom-agent 2>/dev/null; then
|
||||
log_skip " agent v$cur already installed + service active — skipping binary install"
|
||||
else
|
||||
local url="$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$ART_AGENT_VER/felhom-agent"
|
||||
log_info " fetching agent binary v$ART_AGENT_VER from Gitea …"
|
||||
if $DRY_RUN; then
|
||||
log_dry "curl -u <git> -o /tmp/felhom-agent.new $url ; verify sha256=$ART_AGENT_SHA ; install -m0755 -> $AGENT_BIN"
|
||||
else
|
||||
local tmp; tmp=$(mktemp -t felhom-agent.XXXXXX)
|
||||
fetch_verify "$url" "$tmp" "$ART_AGENT_SHA"
|
||||
# back up any existing binary before replacing
|
||||
if [[ -f "$AGENT_BIN" ]]; then
|
||||
cp -a "$AGENT_BIN" "${AGENT_BIN}.bak-$(date +%s)" 2>/dev/null || true
|
||||
fi
|
||||
install -m 0755 -o root -g root "$tmp" "$AGENT_BIN"
|
||||
rm -f "$tmp"
|
||||
log_success " installed $AGENT_BIN ($("$AGENT_BIN" --version 2>&1 | head -1))"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Service user (system, no login, no home dir creation needed beyond state).
|
||||
if $DRY_RUN; then
|
||||
log_dry "useradd --system --no-create-home --shell /usr/sbin/nologin $AGENT_USER # if absent"
|
||||
elif id "$AGENT_USER" >/dev/null 2>&1; then
|
||||
log_info " service user $AGENT_USER exists"
|
||||
else
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$AGENT_USER"
|
||||
log_success " created service user $AGENT_USER"
|
||||
fi
|
||||
|
||||
# State dir (the old root deployment may have created it root-owned; StateDirectory= also adjusts
|
||||
# on start, but chown here so the very first start has a writable dir).
|
||||
run mkdir -p "$AGENT_STATE_DIR"
|
||||
run chown -R "${AGENT_USER}:${AGENT_USER}" "$AGENT_STATE_DIR"
|
||||
run chmod 0750 "$AGENT_STATE_DIR"
|
||||
|
||||
# Sudoers — fetch the canonical file, validate with visudo -cf BEFORE installing (0440 root:root).
|
||||
if $DRY_RUN; then
|
||||
log_dry "fetch configs/felhom-agent.sudoers ; visudo -cf ; install 0440 -> $AGENT_SUDOERS"
|
||||
else
|
||||
local sdtmp; sdtmp=$(mktemp -t felhom-sudoers.XXXXXX)
|
||||
fetch_raw "configs/felhom-agent.sudoers" "$sdtmp"
|
||||
visudo -cf "$sdtmp" >/dev/null || { rm -f "$sdtmp"; die "fetched sudoers failed visudo -cf — refusing to install"; }
|
||||
install -m 0440 -o root -g root "$sdtmp" "$AGENT_SUDOERS"
|
||||
rm -f "$sdtmp"
|
||||
# re-validate the live drop-in in the full sudoers context
|
||||
visudo -cf /etc/sudoers >/dev/null || die "sudoers invalid after installing $AGENT_SUDOERS"
|
||||
log_success " installed $AGENT_SUDOERS (0440, visudo-validated)"
|
||||
fi
|
||||
|
||||
# systemd unit — fetch the canonical unit, install, daemon-reload, enable (NOT start — no config yet).
|
||||
if $DRY_RUN; then
|
||||
log_dry "fetch configs/felhom-agent.service -> $AGENT_UNIT ; systemctl daemon-reload ; systemctl enable felhom-agent"
|
||||
else
|
||||
local untmp; untmp=$(mktemp -t felhom-unit.XXXXXX)
|
||||
fetch_raw "configs/felhom-agent.service" "$untmp"
|
||||
grep -q "User=$AGENT_USER" "$untmp" || { rm -f "$untmp"; die "fetched unit does not run as $AGENT_USER — refusing"; }
|
||||
if [[ -f "$AGENT_UNIT" ]]; then cp -a "$AGENT_UNIT" "${AGENT_UNIT}.bak-$(date +%s)" 2>/dev/null || true; fi
|
||||
install -m 0644 -o root -g root "$untmp" "$AGENT_UNIT"
|
||||
rm -f "$untmp"
|
||||
systemctl daemon-reload
|
||||
systemctl enable felhom-agent >/dev/null 2>&1 || true
|
||||
log_success " installed $AGENT_UNIT + enabled (started in step 6 after config)"
|
||||
fi
|
||||
_state_mark agent_install
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# STEP 6 — write agent config + ensure service healthy
|
||||
#-------------------------------------------------------------------------------
|
||||
step_agent_config() {
|
||||
log_step "5/7 agent config + service"
|
||||
log_step "6/8 agent config + service"
|
||||
# TLS pin: the SERVED leaf cert fingerprint (not pvesh node info — may differ)
|
||||
local fp
|
||||
fp=$(echo | openssl s_client -connect 127.0.0.1:8006 2>/dev/null | openssl x509 -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//')
|
||||
@@ -436,7 +617,15 @@ if pf and os.path.exists(pf):
|
||||
except Exception: base = {}
|
||||
# fresh-host defaults for any section not preserved
|
||||
base.setdefault('log_level','info')
|
||||
base.setdefault('privileged', {"mode":"direct","unit_dir":"/etc/systemd/system","stage_dir":"/var/lib/felhom-agent/units","systemctl":"/usr/bin/systemctl","install":"/usr/bin/install","smartctl":"/usr/sbin/smartctl","lvs":"/usr/sbin/lvs"})
|
||||
# privileged.mode = "sudo": the canonical unit runs the agent as the NON-root felhom-agent user, so
|
||||
# every host-root op goes through `sudo -n` against /etc/sudoers.d/felhom-agent. ("direct" was the old
|
||||
# dev/CI shortcut for a root agent.) Force the mode authoritative (a stale preserved "direct" config
|
||||
# would otherwise break the non-root daemon); the binary paths MUST match the sudoers allowlist.
|
||||
base.setdefault('privileged', {})
|
||||
base['privileged']['mode'] = 'sudo'
|
||||
base['privileged'].setdefault('sudo_path','sudo')
|
||||
for _k,_v in {"unit_dir":"/etc/systemd/system","stage_dir":"/var/lib/felhom-agent/units","systemctl":"/usr/bin/systemctl","install":"/usr/bin/install","smartctl":"/usr/sbin/smartctl","lvs":"/usr/sbin/lvs"}.items():
|
||||
base['privileged'].setdefault(_k,_v)
|
||||
base.setdefault('storage', {"watchdog_interval_seconds":5,"watchdog_debounce_seconds":15,"known_refresh_seconds":20})
|
||||
base.setdefault('backup', {"local_backup_target":"local","restore_storage":"local-lvm","restore_test_cadence_seconds":0,"scratch_vmid_min":990000,"scratch_vmid_max":990009,"pbs_secret_dir":"/etc/pve/priv/storage","backup_cadence_seconds":0})
|
||||
base.setdefault('local_api', {})
|
||||
@@ -464,8 +653,11 @@ fd = os.open(out, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd,'w') as f:
|
||||
json.dump(base, f, indent=2); f.write('\n')
|
||||
PY
|
||||
# The non-root felhom-agent daemon must READ this config (token + hub api_key live here). Own it by
|
||||
# the service user, 0600 (root still reads it for the provision one-shot).
|
||||
chown "${AGENT_USER}:${AGENT_USER}" "$AGENT_CONFIG" 2>/dev/null || chmod 600 "$AGENT_CONFIG"
|
||||
chmod 600 "$AGENT_CONFIG"
|
||||
log_success " wrote $AGENT_CONFIG (0600)"
|
||||
log_success " wrote $AGENT_CONFIG (0600 ${AGENT_USER})"
|
||||
|
||||
# health: read-only selftest (proxmox) must pass before provisioning
|
||||
if ! felhom-agent --config "$AGENT_CONFIG" --selftest >/dev/null 2>&1; then
|
||||
@@ -474,11 +666,21 @@ PY
|
||||
fi
|
||||
log_success " agent --selftest (read-only) passed"
|
||||
|
||||
# restart the daemon (host-report loop) and confirm a report lands
|
||||
# start the daemon (host-report loop) as the felhom-agent user and confirm it stays up. is-active is
|
||||
# the real proof the NON-root user can read the 0600 config (the root selftest above can't show that).
|
||||
if systemctl list-unit-files felhom-agent.service >/dev/null 2>&1; then
|
||||
run systemctl enable felhom-agent >/dev/null 2>&1 || true
|
||||
run systemctl restart felhom-agent
|
||||
log_info " felhom-agent service restarted"
|
||||
if ! $DRY_RUN; then
|
||||
sleep 3
|
||||
if systemctl is-active --quiet felhom-agent; then
|
||||
log_success " felhom-agent service active (non-root $AGENT_USER reads the config OK)"
|
||||
else
|
||||
systemctl status felhom-agent --no-pager -l 2>&1 | tail -20 >&2
|
||||
journalctl -u felhom-agent -n 20 --no-pager 2>&1 | tail -20 >&2
|
||||
die "felhom-agent did not stay active after restart — see status/journal above"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_warn " no felhom-agent systemd unit — daemon host-report loop not started (provision one-shot still works)"
|
||||
fi
|
||||
@@ -486,10 +688,51 @@ PY
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# STEP 6 — provision (golden restore -> resize -> bootstrap.json -> onboot:1)
|
||||
# STEP 7 — golden: ensure a restorable golden archive (local else Gitea-fetched + verified)
|
||||
#-------------------------------------------------------------------------------
|
||||
# Local auto-discovery is the default + fallback. When no local golden exists (or --force-gitea-golden),
|
||||
# fetch the golden from Gitea (git token), VERIFY its sha256 against the hub manifest, and import it
|
||||
# into the archive storage's dump dir under a valid vzdump name so the provision restore can use it.
|
||||
step_golden() {
|
||||
log_step "7/8 golden archive"
|
||||
|
||||
if [[ -n "$GOLDEN_VOLID" ]] && ! $FORCE_GITEA_GOLDEN; then
|
||||
log_skip " using local golden: $GOLDEN_VOLID"
|
||||
_state_mark golden; return 0
|
||||
fi
|
||||
|
||||
# Need the manifest + git creds (already resolved in step 5, but re-resolve on a fresh --resume run).
|
||||
[[ -n "$ART_GOLDEN_VER" ]] || resolve_artifacts
|
||||
[[ -n "$GIT_TOKEN" ]] || resolve_git_creds
|
||||
[[ -n "$ART_GOLDEN_VER" && -n "$ART_GOLDEN_SHA" ]] || die "hub manifest has no golden version/sha256 — set it in the operator UI, or pass --golden VOLID"
|
||||
|
||||
local url="$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-golden/$ART_GOLDEN_VER/golden.tar.zst"
|
||||
if $DRY_RUN; then
|
||||
log_dry "curl -u <git> -o <dump>/vzdump-lxc-${GOLDEN_VMID}-<ts>.tar.zst $url ; verify sha256=$ART_GOLDEN_SHA ; set GOLDEN_VOLID"
|
||||
GOLDEN_VOLID="${ARCHIVE_STORAGE}:backup/vzdump-lxc-${GOLDEN_VMID}-<dry-run>.tar.zst"
|
||||
_state_mark golden; return 0
|
||||
fi
|
||||
|
||||
# Resolve the archive storage's dump dir (pvesm path maps a volid → fs path without needing it to exist).
|
||||
local dump_dir fname dest
|
||||
dump_dir=$(dirname "$(pvesm path "${ARCHIVE_STORAGE}:backup/vzdump-lxc-${GOLDEN_VMID}-2000_01_01-00_00_00.tar.zst" 2>/dev/null)")
|
||||
[[ -d "$dump_dir" ]] || die "could not resolve dump dir for storage $ARCHIVE_STORAGE (got '$dump_dir')"
|
||||
fname="vzdump-lxc-${GOLDEN_VMID}-$(date +%Y_%m_%d-%H_%M_%S).tar.zst"
|
||||
dest="${dump_dir}/${fname}"
|
||||
log_info " fetching golden v$ART_GOLDEN_VER from Gitea → $dest"
|
||||
fetch_verify "$url" "$dest" "$ART_GOLDEN_SHA"
|
||||
GOLDEN_VOLID="${ARCHIVE_STORAGE}:backup/${fname}"
|
||||
pvesm list "$ARCHIVE_STORAGE" --content backup 2>/dev/null | grep -q "$fname" \
|
||||
|| log_warn " imported golden not yet listed by pvesm (continuing — restore uses the volid directly)"
|
||||
log_success " golden imported + verified: $GOLDEN_VOLID"
|
||||
_state_mark golden
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# STEP 8 — provision (golden restore -> resize -> bootstrap.json -> onboot:1)
|
||||
#-------------------------------------------------------------------------------
|
||||
step_provision() {
|
||||
log_step "6/7 provision guest $VMID"
|
||||
log_step "8/8 provision guest $VMID"
|
||||
# NOTE: -hub-password is passed on argv (the agent's only input for it) — briefly
|
||||
# visible in ps. Tracked as an Observation (candidate: env/stdin in the agent).
|
||||
if $DRY_RUN; then
|
||||
@@ -510,7 +753,7 @@ step_provision() {
|
||||
# STEP 7 — verify
|
||||
#-------------------------------------------------------------------------------
|
||||
step_verify() {
|
||||
log_step "7/7 verify"
|
||||
log_step "verify"
|
||||
if $DRY_RUN; then log_dry "pct status/config $VMID; docker ps in-guest; host-report includes $VMID"; return 0; fi
|
||||
local ok=true
|
||||
local st; st=$(pct status "$VMID" 2>/dev/null | awk '{print $2}')
|
||||
@@ -539,7 +782,7 @@ step_verify() {
|
||||
#-------------------------------------------------------------------------------
|
||||
# Main
|
||||
#-------------------------------------------------------------------------------
|
||||
trap 'PASSPHRASE=""; PVE_TOKEN=""; HOST_API_KEY=""' EXIT
|
||||
trap 'PASSPHRASE=""; PVE_TOKEN=""; HOST_API_KEY=""; GIT_TOKEN=""' EXIT
|
||||
|
||||
if $RESUME && _state_has preflight; then
|
||||
# still need the passphrase for enroll/provision even on resume
|
||||
@@ -557,9 +800,11 @@ else
|
||||
step_preflight
|
||||
fi
|
||||
|
||||
should_skip token || step_token
|
||||
should_skip grows || step_grows
|
||||
should_skip enroll || step_enroll
|
||||
should_skip agent_config || step_agent_config
|
||||
should_skip provision || step_provision
|
||||
should_skip token || step_token
|
||||
should_skip grows || step_grows
|
||||
should_skip enroll || step_enroll
|
||||
should_skip agent_install || step_agent_install
|
||||
should_skip agent_config || step_agent_config
|
||||
should_skip golden || step_golden
|
||||
should_skip provision || step_provision
|
||||
step_verify
|
||||
|
||||
Reference in New Issue
Block a user