Files
felhom.eu/hub/CHANGELOG.md
T

84 KiB
Raw Blame History

Felhom Hub — Changelog

v0.30.0 — TASK-D4: design system v2 re-skin (appearance only) (2026-07-02)

Last surface of the design sprint (controller D0/D1, website D3). The hub leaves its Tailwind-slate theme for the canonical navy v2 language. API surface untouched (/api/* ingestion, artifact manifest, config generation, DR/escrow — git diff clean under internal/api + internal/store).

  • Fonts (internal/web/static/fonts/, embed.go, server.go): the 4 vendored woff2 (byte-copied from felhom-controller; latin-ext for Hungarian customer names in an English UI), embedded and served at /static/fonts/ (font/woff2, immutable), mirroring the chart.min.js pattern. No CDN before or after.
  • statusColor semantic remap (server.go): returns nominal/warn/crit/neutral class tokens instead of raw hex colors — ok→nominal, warn+stale→warn, down+fail→crit, pending+disabled→neutral (a not-yet-provisioned or deliberately paused customer is a normal fleet state), blocked→warn (intentional operator cut-off: attention-worthy, not an outage). The inline style="color: {{statusColor}}" pattern is dead (dashboard + customer_unified use class-based .status-dot-<token>); statusIcon ("●") retired. Truth-table test red-proven vs the old implementation; new template-parse test (neither existed for the hub).
  • style.css v2: navy tokens + @font-face; 2px radius; hairline --line-soft table rows (fleet-NOC density kept); .status-badge re-expressed as an outline tag + CSS dot per the design-system addendum (ok=blue, warn/blocked/stale=amber+dim, down/fail=red+dim, pending/disabled=quiet neutral with hollow dot); severity badges stay filled amber/red (exceptions stay loud); config badges = filled informational chips in v2; row tint only for warn/down. Two-tone brand H1 (Felhom <span>Hub</span>) on all pages; 12-symbol Lucide sprite partial included per page.
  • Charts (app_detail): avg memory #2EA8F5, peak #8E7CE8 (secondary DATA series — not status red), catalog-limit line #E0A93E (threshold marker); legend/tick/grid → v2 literals.
  • customer_unified JS status-message colors → blue-bright/crit; login page inline HTML retinted.
  • Grep gate: all slate hexes (#0f172a #1e293b #334155 #60a5fa #4ade80 #facc15 #f87171 #94a3b8 #64748b #475569 #e2e8f0) at zero across internal/web (non-test).

v0.29.0 — Day-0 artifact manifest: version dropdowns + auto-derived sha (2026-07-01)

Removes the hand-copied sha256 from the Day-0 artifact manifest. The operator now picks a version from a dropdown of what's actually in Gitea (olders get pruned), and the hub reads that version's sha256 from Gitea itself — no transcription, no stale checksums. Keeps the human-in-the-loop trust gate (the operator still deliberately chooses the version; "latest" is never auto-promoted) while the hub stays the checksum trust root.

  • internal/gitea (new): a minimal read-only Gitea packages client — ListVersions (generic package versions, newest-semver first) + FileSHA256 (a version's file sha256 via the files-metadata API, without downloading the artifact — important for the ~GB golden). Basic-auth with the registry creds the hub already holds. Unit-tested against an httptest server (filter+sort, preferred file match + fallback, non-200 → error).
  • Configuration → Day-0 artifacts: the two version text inputs are now <select> dropdowns populated from Gitea; the sha256 fields are read-only, displayed (mirrored from the picked version via a tiny inline script). Choosing "— none —" clears an artifact.
  • handleSetArtifacts: derives each chosen version's sha256 from Gitea authoritatively (a client-submitted sha is ignored); a Gitea lookup failure REFUSES the save (never stores a version with a wrong/blank checksum) rather than corrupting the manifest.
  • Graceful degradation: with no registry creds (web.SetGiteaClient not wired) the form falls back to the previous manual text-entry path. main.go enables the Gitea browser when REGISTRY_USERNAME /REGISTRY_TOKEN are set.
  • go build/vet/test ./... clean.

v0.28.0 — global settings → Configuration tab + online setup command (2026-07-01)

Three operator-requested improvements (companion: host-install script v1.2.0).

  • Global settings moved from the Customers page to the Configuration tab (web/configuration.html, configs.html, server.go, configs.go). The two global cards — "Managed updates — global floor" and "Day-0 artifacts — agent & golden" — were on the Customers list; they now render + save on Configuration (where they belong). handleConfiguration supplies GlobalFloor + Artifacts + CSRFField; the save handlers (handleSetGlobalFloor/handleSetArtifacts) now redirect to /configuration?flash=… and are mounted at /configuration/global-floor + /configuration/artifacts; their flash banners moved too. The Customers page is back to just the list + "Add Customer" (its per-customer effective-floor column is unchanged).
  • Online setup command added to a customer's Setup Command block (customer_unified.html). New "Option 1: Online install (recommended)" — download-then-run: curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o … && sudo bash … --customer-id <id> — with a copy button and the customer id filled. The passphrase is not templated in (entered at the prompt). The former local-file command becomes Option 2, the debug curl Option 3. Download-then-run (not curl | sudo bash) stays the recommended form — inspect before running.
  • Website now serves /scripts/ (manifests/webpage.yaml). The host-install script lives at the repo's /scripts (outside the website doc-root); added /scripts/ to the git-sync sparse-checkout and an nginx location /scripts/ (root .../current, text/plain) so https://felhom.eu/scripts/felhom-host-install.sh resolves — single source of truth, no duplicated copy.
  • Remaining audit follow-ups unchanged: controller-side geo intent sync; a read-only reported-vs-desired "Show Diff"; the cosmetic controllerURL cleanup in configs.go.

v0.27.0 — Hosts page: read-only fleet view (audit F-M1) (2026-07-01)

Resolves audit finding F-M1: the agent enrolls as a host and the hub stores rich host state (identity, agent version, guests, storage targets with SMART, DR/escrow, staleness) and alerts on it — but the whole host domain was invisible in the GUI (email-only). Adds a Hosts nav section — a fleet list + a per-host detail page. Read-only (GET only, no host actions/mutation routes): this surfaces state the way the pull/desired-state model demands; it does not reintroduce inbound control (retired in v0.26.0).

  • New store reader ListGuestsForHost(hostID) (internal/store/store.go). SELECT … FROM guests WHERE host_id = ? ORDER BY vmid, via a new scanGuest helper over guestRealitySelectCols — the reality columns only. It deliberately omits the secret/inert columns (api_key, desired_spec_json), so the read-only view can never surface them. Returns [] (never nil-error) on no guests. (There was previously no guests reader — only UpsertGuestFromReport.)
  • New handlers (internal/web/hosts.go).
    • handleHostsListListHosts + a per-host status badge from hostStatus() (which reuses s.staleThreshold — the same thresholds as the HostStalenessChecker: stale after the threshold, down at 2× — so the badge agrees with the alerting) + per-host guest counts (ListGuestsForHost) + vitals parsed from GetLatestHostReportJSON + worst storage fill grouped from GetHostStorageTargets.
    • handleHostDetailGetHost (404 if absent) + ListGuestsForHost + rich storage targets (role, state, fill %, thin-pool, SMART health/temp/wear parsed from the latest report body) + vitals + GetHostDRBundle/GetHostEscrow presence booleans only (never the opaque blobs). Nil/missing (no report, no guests, no storage, no DR) render empty states — never a panic.
  • New templates hosts.html + host_detail.html (existing dark operator-console styling reused — data-table, status-badge-*, info-grid, empty-state; no restyle). A no-report host shows a STALE/NO-REPORT badge and "waiting for first report".
  • Nav: added the Hosts link (between Apps and Configuration) to every page's <nav> (the nav is duplicated per page, not a shared partial) + the new timeAgoPtr template helper for *time.Time.
  • Routes (internal/web/server.go): GET /hosts (+ /hosts/) → list, GET /hosts/{id} → detail, modelled on the /apps pair. GET only.
  • Tests: ListGuestsForHost (none→empty, multiple→vmid-ordered, secret column not surfaced); the list handler (N rows, ONLINE + NO-REPORT badges, worst-fill, no action buttons); the detail handler (guests + storage + SMART + DR present, customer cross-link, no-secret assertion that the host api_key is absent from the rendered body, no buttons); unknown host → 404; no-report host renders the waiting state. hostStatus band mapping unit-tested (pending/ok/stale/down).
  • Remaining audit follow-ups (not this slice): controller-side geo intent sync; a read-only reported-vs-desired "Show Diff"; the cosmetic controllerURL cleanup in configs.go.

v0.26.0 — pull-based config delivery + retire the inbound GUI controls (2026-06-30)

Closes audit documentation/audits/AUDIT-hub-gui-2026-06-30.md F-S1/F-S4 + the dead-template findings, and replaces the never-inbound-violating "Push Config" with a pull-based config-refresh that rides the report ACK (companion controller change: felhom-controller v0.94.0).

  • Config delivery is now pull-based (internal/store/store.go, internal/api/handler.go). New customer_configs.config_version column — a stored counter (NOT a hash of the rendered YAML; configgen emits a fresh web.session_secret + timestamp every call, so a content hash would change spuriously). SaveCustomerConfig bumps it on every save (new rows seed at 1, updates increment) — the one path that changes the generated controller.yaml (identity + the config_json overrides). The floor, block/unblock, and retrieval-password regen deliberately do NOT bump it. The report ACK (handleReport) now advertises config_version beside min_controller_version/latest_version; the controller compares it to its last-applied version and re-pulls + self-restarts on a change. Omitted for report-only (no-config) customers, so an old controller is unaffected.
  • Retired the five inbound (hub→box) controls that violated the never-inbound posture (01-topology-and-trust.md:11) and were broken behind the box's CF tunnel/NAT:
    • Trigger Update — handler + route deleted; controller updates are agent-driven (the version floor).
    • Push Config — handler + route deleted; replaced by the pull-based config-refresh above.
    • Pull Config — handler + route deleted.
    • Show Diff (handleConfigDiff + the compareYAMLValues/flattenYAML/maskSensitive helpers) — deleted, along with the now-dead ConfigSyncStatus/ConfigDiffCount plumbing.
    • Geo-disable — KEEPS its legitimate hub→Cloudflare WAF-rule removal (RemoveGeoRules); the secondary inbound notifyControllerGeoDisable is deleted. After this, grep client.Do internal/web/ has zero ControllerURL targets (only Gitea registry/template fetches remain; the ControllerURL is still shown as a display-only link).
  • GUI staleness (F-S1) + dead templates: the customer page's Setup Commands now show the Proxmox Day-0 host bootstrap (sudo ./felhom-host-install.sh --customer-id <id>, passphrase at the no-echo prompt) instead of the pre-Proxmox docker-setup.sh; Option 2 relabelled "Manual config fetch (debug only)". Deleted the orphaned customer.html + config_detail.html (rendered by nothing; /configs/{id} redirects to /customers/{id}).
  • Audit doc (deferred line): the GUI audit documentation/audits/AUDIT-hub-gui-2026-06-30.md (committed e51e03b) is the grounding for the above; its F-S1/F-S4 + dead-template findings are now resolved. Open follow-ups noted there remain: the Hosts page (F-M1), controller-side geo intent sync, and Show-Diff could return later as a read-only-vs-reported view.
  • Tests: store config_version bump (create=1, edits increment, per-customer independent) + the no-bump red-proof; ACK carries config_version and omits it for report-only customers + the no-bump red-proof. go build/vet/test ./... green.

v0.25.0 — per-storage worst-fill alerting (StorageFillChecker) (2026-06-30)

Generalizes the host-root disk alert (v0.23.0) to ANY reported storage target — so a dedicated dump/backup volume, data drive, lvmthin pool, or PBS datastore filling toward failure pages the operator with the storage named, even when host root itself is fine.

  • internal/monitor/storage_fill.go (NEW) — StorageFillChecker. A per-target mirror of HostDiskChecker on the same 60s sweep: born/persistent (already-breached (host,target) keys left UNSEEDED → first Check emits), escalation-only emit, recovery re-arm, the dispatcher's 1h cooldown. State is keyed per (host, target) so targets alert independently. Emits distinct storage_fill_warning / storage_fill_critical at the natural critical severity (exercises the v0.24.0 dispatcher fix with a second real caller). Default thresholds 90/95, hub-config overridable (alerting.storage_fill_warn_percent / _crit_percent), independent of the host-root thresholds.
  • Root excluded (no double-alert): the host root-backed builtin (Type=="local", or a target mounted at /) is skipped — HostDiskChecker owns root. So a root-backed vzdump dump is one alert (from host_disk), and storage_fill uniquely covers OFF-root storage.
  • internal/store/store.go: GetHostStorageTargets() + HostStorageTargetRow — parses report_json.storage_targets[] of each host's latest report (percent = used_fraction×100); modeled on GetHostDiskUsage, no denorm column / migration.
  • internal/notify/templates.go + internal/api/handler.go: Hungarian templates + allowlist entries for storage_fill_warning / storage_fill_critical.
  • cmd/hub/main.go: register storageFillChecker on the 60s tick beside HostDiskChecker.
  • Tests: per-target bands (independent warn/escalate/recover/re-arm), born/persistent companion red-proof (a seed-all model stays silent on the born-breach), root-exclusion companion (without the exclusion a root target IS in the critical band — the exclusion is what suppresses the double-alert), severity critical, and the store parse. go build/vet/test ./... green.

v0.24.0 — dispatcher routes critical severity (+ nil-prefs crash guard) (2026-06-30)

NAS Part A2's "Part 0": close the dispatcher's silent drop of critical-severity events.

  • internal/notify/dispatcher.go ProcessEvent: the severity gate was severity != "warning" && severity != "error" → a critical event was silently dropped (never emailed). Now routes warning / error / critical (severityNotifies); info stays an intentional non-notify; any unrecognized severity is logged ([WARN] Dispatcher: unrecognized severity …), never silently dropped. Verified safe first: no controller event emits critical (all are info/warning/error) and the hub's only would-be critical emitter is host_disk — so no surprise alert volume.
  • internal/monitor/host_disk.go: host_disk_critical now emits its natural critical severity (was forced to error to survive the old gate); FormatOperatorEmail styles critical 🔴 like error.
  • Latent crash guard: processCustomer dereferenced GetNotificationPrefs, which returns (nil, nil) for a customer with no notification row — an event for such a customer would panic the dispatcher goroutine and crash the hub. Now guards prefs == nil before use.
  • Seam: sendEmailFn field (defaults to the Resend sender) so routing is unit-tested without real HTTP.
  • Tests: severityNotifies (warning/error/critical notify; info/unknown don't) + companion red-proof (the pre-fix warning||error predicate drops critical); ProcessEvent routes critical to the operator; an unknown severity is logged not dropped; info is silent and not mis-logged. go build/vet/test ./... green.

v0.23.0 — host root-disk pressure monitoring + alert (2026-06-30)

Closes the silent-failure gap behind the felhom-pve incident: a Proxmox host root fs filling up (vzdump piling under /var/lib/vz/dump) went unnoticed because nothing alerted on the HOST root disk_percent the agent already reports. New hub-side checker on the existing 60s sweep.

  • internal/monitor/host_disk.go (NEW) — HostDiskChecker. A sibling of HostCapabilityChecker / HostLeafChecker: reads each host's latest root-fs disk_percent (store.GetHostDiskUsage) and emits an operator alert on a warning (default 90%) or critical (default 95%) crossing. Rank-based bands (ok→warning→critical) so an escalation always alerts and a de-escalation/recovery re-arms silently.
    • Born/persistent (the F2 lesson): a disk ALREADY over threshold when the hub/checker (re)starts alerts on cycle 1 — seeding leaves already-breached hosts UNSEEDED so the first Check emits (a transition-only design would stay silent forever on a persistently-full disk). The dispatcher's 1h operator cooldown dedups re-emits across a hub restart.
    • Distinct event types host_disk_warning / host_disk_critical — NOT the controller's GUEST disk_warning/disk_critical (the guest cgroup view), so the host and guest alerts never dedup or mask each other.
    • Severity: warning band → warning; critical band → error (NOT "critical"). The dispatcher only routes warning/error severities — a "critical" severity would be silently dropped — so the critical band maps to error (and the operator email's 🔴). (Deviation from the task's stated "critical → critical", made to match the live dispatcher.)
    • Thresholds are hub-config overridable (alerting.host_disk_warn_percent / host_disk_crit_percent, seed-only); an unset/invalid/misordered config falls back to 90/95 (normalizeDiskThresholds) so a typo can never silence or invert the alert.
  • internal/store/store.go: GetHostDiskUsage() + HostDiskRow — latest report per host (MAX(id)), disk_percent from the denorm column + total/used bytes parsed from report_json (event detail). No schema migration.
  • internal/notify/templates.go: Hungarian customer templates for host_disk_warning/_critical (customer delivery still requires per-customer opt-in via enabled events; operator alert is the headline).
  • internal/api/handler.go: host_disk_warning/host_disk_critical added to allowedEventTypes.
  • cmd/hub/main.go: register hostDiskChecker on the shared 60s tick.
  • Tests: band transitions (seed/escalate/steady/recover/re-arm), severity mapping, threshold defaults, and the born/persistent companion red-proof (a seed-all/transition-only model stays silent on a born-breach; the real unseeded design emits). go build/vet/test ./... green.
  • Follow-ups (noted, not built): per-storage StorageTargets worst-fill alerting (a dedicated dump/backup storage filling — host root disk_percent already covers the observed case); and the provisioning-side prune-backups retention default so a box can't refill its own root (operational fix, separate from this detector).

hub-config — enable operator email alerts (config-only, no image change) (2026-06-30)

manifests/hub.yaml hub-config ConfigMap: set notifications.operator_email: admin@felhom.eu + operator_enabled: true. The dispatcher's operator path (Dispatcher.processOperator) sends only when operatorOn && operatorEmail != ""; without these it returned early, so the self-health pipeline (probe → report → checker → dispatch) stopped one hop short of the inbox (the TESTRUN's unproven hop). No hub image change (live tag stays v0.22.1) — ConfigMap edit + pod restart to reload.

  • Proven end-to-end (2026-06-30): a real capability-degrade alert produced [INFO] Operator email sent for demo-felhom/agent_capability_degraded (the send-success line that never fired while the path was gated); the customer path (POST /api/v1/notify event_type:test) sent to the customer address via the same sendEmail → Resend. See documentation/audits/TESTRUN-fullstack-2026-06-29.md ("Findings closed", Part A). Operator email is not a secret; the Resend key stays injected from Secret/resend-api.

v0.22.1 — wire HostLeafChecker into the monitor loop (v0.22.0 missed the wiring) (2026-06-29)

The v0.22.0 commit added HostLeafChecker but the cmd/hub/main.go goroutine edit never applied, so the checker was never started. The live test caught it (a leaf regen produced no host_leaf_changed, only the controller's complementary agent_channel_pin_mismatch). Now started on the 60s sweep next to the staleness/capability checkers. No other change.

v0.22.0 — proactive agent re-key detection: HostLeafChecker (host_leaf_changed) (2026-06-29)

Companion to felhom-agent v0.48.0 (which now reports its served local-API leaf fp). The hub watches each host's leaf fingerprint and raises an operator alert when it changes (an agent re-key) — proactive, fleet-wide, independent of any controller's channel-health check. The last self-health leg.

  • monitor.HostLeafChecker (NEW): sibling of HostCapabilityChecker. Trust-on-first-report — the first fp per host is the baseline; a later change emits host_leaf_changed (operator, English; details carry old+new fp) and advances the baseline. First-obs seeds silently (a "change" needs a prior value, so no F2 issue). An empty reported fp (pre-v0.48.0 / local-API-disabled) is unknown — never seeds, never alerts, never overwrites a baseline. Customer-blocked hosts dropped; unseen pruned. Runs on the existing 60s sweep. KNOWN LIMITATION (documented): trust-on-first-report can't detect a re-key that happened before the hub's first report — but the controller channel-check catches the downstream pin mismatch, so this is defense-in-depth.
  • store.GetHostLeafFingerprints (NEW): latest reported fp per host, parsed from report_json (mirrors GetHostCapabilitiesMAX(id), no schema migration).
  • No allowlist change: host_leaf_changed is hub-GENERATED (via SaveEvent + dispatcher.ProcessEvent), not controller-pushed, so it bypasses the /api/v1/event allowedEventTypes gate — same as the host_* events. The generic operator template relays it (no template change).
  • Tests: change red-proof (A→B → one event + baseline advanced; companion: unchanged → none), first-obs seeds silently, change-back re-alerts, empty fp skipped, customer-blocked dropped. Cross-repo golden mirrors leaf_fingerprint. Version 0.21.0 → 0.22.0.

v0.21.0 — F2: alert on a host already degraded/stale at hub (re)start (2026-06-29)

Mirror of the controller's F2 fix, for the hub checkers: a host that was already degraded (capability) or stale/down (staleness) when the hub (re)started was seeded silently and never alerted. Now the constructors seed only HEALTHY hosts; an unhealthy host is left unseeded so the first Check() emits once. The dispatcher's 1 h operator cooldown dedups the re-emit across a hub restart (so a hub bounce doesn't re-page for an already-known issue within the window).

  • internal/monitor/host_capability.go + host_staleness.go: constructor seeds only the healthy state; Check() first-obs (oldState=="") emits emitTransition(…, "unknown", newState, …) when the observed state isn't healthy, else seeds silently.
  • Tests: born-degraded red-proof (host degraded at construction → unseeded → one agent_capability_degraded on first Check, no duplicate on the next); the staleness test updated to the F2 behavior (born-stale → unseeded → one host_stale on first Check). Version 0.20.0 → 0.21.0.

v0.20.0 — Accept controller agent_channel_* events (channel-health relay) (2026-06-29)

Companion to felhom-controller v0.90.0's controller→agent channel health-check. The controller pushes classified agent_channel_* events to /api/v1/event, but the handler's allowedEventTypes allowlist rejected them (HTTP 400). Added the 8 types (pin_mismatch, unauthorized, unreachable, timeout, misconfigured, construction_error, unknown, recovered) so the operator relay works. These are operator-only (not customer notification toggles, same as the host_* events); the existing dispatcher routes them generically (no template change). Version 0.19.0 → 0.20.0.

v0.19.0 — Agent capability-degraded operator alert (HostCapabilityChecker) (2026-06-29)

Companion to felhom-agent v0.44.0's privileged-capability self-probe: the agent now rides a capabilities snapshot on its host report (each required sudo -n grant: ok/degraded), and the hub alerts the operator when a host transitions into a degraded state — closing the loop that let five non-root-cutover regressions go undetected until user-visible breakage.

  • monitor.HostCapabilityChecker (NEW): a deliberate SIBLING of HostStalenessChecker — same per-host state map (ok/degraded), seed-without-event, emit-only-on-transition shape. A host is degraded iff its latest report has any Critical capability with status:"degraded"; non-critical degradations ride the report but never alert. Runs on the existing 60s sweep next to the staleness checkers.
  • Events: agent_capability_degraded (warning) on ok→degraded, naming the degraded capabilities
    • gated features in the message and details JSON; agent_capability_recovered (info) on degraded→ok. Routed through the existing Dispatcher.ProcessEventoperator-only (the type is not a customer notification toggle, same as host_stale) with the standard 1 h operator cooldown (no per-cycle re-alert).
  • store.GetHostCapabilities (NEW): reads the capability snapshot from the latest host-report's report_json per host (keyed on MAX(id) — within-second received_at ties would otherwise return multiple rows). No schema migration — the array rides the existing report body. A pre-v0.44.0 agent (no capabilities) reads as ok, so an old agent can't trip a false alert.
  • Cross-repo host-report.golden.json mirrors the new capabilities: [] field (byte-identical with the agent copy). Version 0.18.0 → 0.19.0.

v0.18.0 — App-email passthrough: POST /api/v1/mail → Resend SMTP (2026-06-29)

The hub can now relay a customer box's outbound app email to Resend, re-emitting the raw MIME unchanged over SMTP. This is the hub leg of the app-email relay (apps → on-box shim → hub → Resend); the Resend key stays hub-side. Implements documentation/audits/SPIKE-smtp-app-relay-2026-06-28.md.

  • New internal/mailrelay/relay.go: ResendSMTP (a Sender) — STARTTLS to smtp.resend.com:587, AUTH LOGIN resend/<key> (a small net/smtp.Auth LOGIN impl; stdlib ships PlainAuth only), then raw MAIL/RCPT/DATA. Raw passthrough — NOT the internal/notify Resend HTTP-API path, which is unchanged for the hub's own structured alerts and silently drops inline CID images (spike §4). No new external dependency (stdlib net/smtp).
  • New POST /api/v1/mail (internal/api/mail.go): authenticates the box (checkAuthCustomer), enforces the From-header domain allowlist (backstop; reject 403), applies a per-customer in-memory token-bucket rate limit (default 30/min → 429 so one box can't drain the shared Resend quota), then passes the raw bytes through to Resend. Success→200, Resend failure→502 (the box's shim maps that to the app).
  • Config: new mail section (per_customer_per_minute, from_domains); wired in cmd/hub/main.go only when a Resend key is present (else the endpoint returns 503). The existing notify/dispatcher.go alert path is untouched.
  • Tests: passthrough byte-equality (raw bytes reach the sender unchanged, not parsed), From-reject + companion red-proof, per-customer rate-limit + isolation + companion, send-failure→502, 401/503/400 paths, token-bucket unit (injected clock), LOGIN auth + From-domain parsing.

v0.17.0 — Resend key sourced from a Secret, out of git (2026-06-29)

Resend rotation + de-git hygiene. The hub's Resend API key was committed in plaintext in manifests/hub.yaml's hub-config ConfigMap; it is now sourced from an out-of-band Kubernetes Secret (resend-api) and never lives in git.

  • cmd/hub/main.go: new RESEND_API_KEY env override for notifications.resend_api_key, mirroring the existing REGISTRY_TOKEN / DEFAULT_MIN_CONTROLLER_VERSION k8s-Secret override pattern. When set, it wins over the (now empty) ConfigMap field. No behaviour change when unset.
  • The committed ConfigMap resend_api_key is now an empty placeholder pointing at documentation/runbooks/secrets.md; the live value is injected from Secret/resend-api via env.
  • Part of the Resend key rotation: the previously-exposed send-scoped key was rotated; the new key lives only in the out-of-band store and Secret/resend-api (created imperatively, never committed). See the secrets runbook. No secret value appears in this repo.

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.
  • main.go (env seed): seeds the manifest from ARTIFACT_AGENT_VERSION / ARTIFACT_AGENT_SHA256 / ARTIFACT_GOLDEN_VERSION / ARTIFACT_GOLDEN_SHA256 on startup — but only fields the DB doesn't already have, so a UI edit sticks across restarts. Same escape hatch the Phase-2 floor uses (the operator UI is password-gated).
  • 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 global floor — and any box below it auto-updates to the floor on its next report (no customer action). The customer "update to latest" button is unchanged (latest, opt-in); the floor is the operator's enforced minimum and the auto-target (controlled rollout: the operator raises the floor).

  • store.go:
    • customer_configs gains min_controller_version TEXT NOT NULL DEFAULT '' (per-customer override).
    • New hub_settings(key,value) table holds the operator-set global floor (survives restarts).
    • Global default fallback SetDefaultMinControllerVersion (config/env DEFAULT_MIN_CONTROLLER_VERSION).
    • EffectiveMinControllerVersion(customerID) = per-customer override if non-empty, else global (hub_settings → config default), else "". Plus Set/GetGlobalMinControllerVersion, SetMinControllerVersion.
  • handler.go (handleReport): the controller report ACK (previously just {status, customer_blocked}) now also returns {min_controller_version: <effective floor>, latest_version: <registry latest>}. Both omitted when empty, so old controllers / unconfigured hubs behave exactly as before. New SetLatestVersionProvider wires the registry VersionChecker (nil-safe).
  • configs.go + templates (operator UI, English): the Customers list shows a Floor column (effective floor, "override" tag, below-floor ● marker) + a global floor editor; each customer page shows the effective/global floor, below-floor status, and a per-customer override form. Routes POST /configs/global-floor and POST /customers/{id}/floor (CSRF-protected; X.Y.Z or blank-to-clear).
  • main.go: seeds the default floor from config/env; wires the latest-version provider.
  • Tests: effective-floor resolution (override beats global; empty when unset; DB-beats-default; preserved across save); report ACK carries effective floor + latest + omits when unset; template render smoke. Companion red-proof: breaking EffectiveMinControllerVersion to return the global when an override is set makes the override-precedence store test AND the ACK test FAIL (verified, then restored).
  • Reuse: rides the existing report cycle (no new endpoint); the swap itself is the controller's Phase 1 flow + the agent — untouched here.

v0.14.0 — Passphrase-authed host enrollment (Day-0 option C) (2026-06-26)

Adds the single-secret Day-0 host-enrollment path proven in documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md (option C). The operator / host-bootstrap script now carries only the customer's retrieval passphrase — the global operator key never enters the field deploy path.

  • New endpoint POST /api/v1/host-enroll (internal/api/handler.go, handleHostEnroll): passphrase-authed (X-Retrieval-Password header, body {customer_id}), returns {host_id, api_key}. Mint-once-reuse — mints on first call (201), returns the existing credential byte-for-byte on every subsequent call (200), so re-running the bootstrap never orphans a running agent's key. Auth is checked before any mint (a wrong passphrase never writes a row): wrong/missing passphrase → 401, unknown customer → 404, missing customer_id400. Mirrors handleConfigRetrieve's auth pattern + handleAdminCreateHost's mint block.
  • New store method Store.GetHostByCustomer (internal/store/store.go): SELECT … FROM hosts WHERE customer_id = ? ORDER BY updated_at DESC LIMIT 1 (uses idx_hosts_customer), nil-on-not-found. Backs the reuse lookup. >1 host for a customer (not expected in Day-0) → most-recent wins, never a duplicate mint.
  • Unchanged & deliberately untouched: GET /api/v1/config/{id} (controller pull — same raw-YAML body) and POST /api/v1/admin/hosts (global-key operator escape hatch, still PROVISIONAL pending the cutover lock-down).
  • Tests: internal/api/host_enroll_test.go (mint/reuse/401-no-mint/404/400, +5) and a GetHostByCustomer store test (+1); companion red-proof confirmed an always-mint variant fails the reuse assertion.

v0.13.1 — DR recipe v1 drive-shape sync: test-data + regression guard only (2026-06-16)

No behavior change — redeploy optional. Tracks the agent's v0.39.0 v1 host-half drive shape (which dropped role + restic_repo_coord from drives[]). Because the hub reads drives as json.RawMessage (verbatim passthrough), no store/handler/struct change was needed — only test-data + a regression guard.

  • internal/store/testdata/dr-recipe.golden.json + the drHostHalf test fixture: dropped the role key from drives[0] to match the v1 shape the agent now emits.
  • internal/api/testdata/host-report.golden.json: re-synced to be byte-identical with the agent's internal/hub/testdata/host-report.golden.json (sha256 57f2a5e7…18b2f2b5). The hub copy previously lacked the dr_recipe section entirely; it is now a verbatim copy, so the cross-repo golden truly matches and POSTing it through /host-report exercises the SaveDRRecipeHostHalf ingest path.
  • New TestAssembleDRRecipe_V1DriveShape — the regression guard: a stored host half whose drives[] carry NEITHER dropped field but WHICH HAS a pbs block assembles cleanly (pbs carried through, drives passed through verbatim, neither role nor restic_repo_coord present). Demonstrated to FAIL when the fixture re-adds role, then reverted.

v0.13.0 — DR recipe: assemble + store + view the secret-free reconstruction recipe (2026-06-16)

DR recipe slice (hub half) — the assemble-store-view side of the secret-free reconstruction recipe (documentation/audits/SPIKE-dr-recipe-2026-06-16.md). The hub receives two additive halves via the existing report paths — the agent's storage/guest/PBS half (on the host-report) and the controller's customer/apps half (on the controller report) — and assembles them into one operator-readable recipe per customer. This is the clean inverse of the retired infra-backup: same "re-provision plan" goal, but PLAINTEXT-at-rest is correct because the recipe has zero secrets.

  • Store (internal/store/dr_recipe.go): a DEDICATED dr_recipe table (NOT host_escrow, NOT the dropped infra_backup* tables) keyed by customer_id, holding host_half_json + app_half_json + recipe_version + host_id. SaveDRRecipeHostHalf / SaveDRRecipeAppHalf each upsert their half and PRESERVE the other (last-write-wins per half). AssembleDRRecipe stitches the two into an AssembledRecipe{recipe_version, customer, guests, pbs, drives, pve_storage, apps} — sub-sections pass through as json.RawMessage (verbatim), ignore-unknown at the top level and version-skew tolerant (recipe_version = max of the two halves) for forward-compat across the three repos.
  • Ingest (internal/api/handler.go): handleHostReport persists the dr_recipe host-half (keyed by the host's customer); handleReport persists the dr_recipe app-half (keyed by customer_id) — mirroring the app_telemetry pattern, backward-compatible (old agents/controllers omit the field), and never fatal to the heartbeat.
  • View (internal/web/dr_recipe.go + customer page): a DR-recipe panel on the customer detail page (which half has landed + last-updated) with a Download recipe (JSON) link → GET /customers/{id}/dr-recipe.json serves the assembled recipe (operator dashboard-auth, pretty JSON, Content-Disposition attachment). No decrypt, nothing to redact.
  • Tests: TestDRRecipe_StoreRoundTrip (each half preserves the other), TestAssembleDRRecipe_MatchesGolden (the assembled wire shape pinned in testdata/dr-recipe.golden.json), TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew (a forward-compat half still assembles; version = max), TestAssembleDRRecipe_PartialHalves (one half present), TestAssembleDRRecipe_NoSecrets (defense-in-depth credential-key sweep). Pairs with felhom-agent v0.38.0 + felhom-controller v0.73.0.

v0.12.0 — retire Infra Backup + purge its plaintext secrets + fix the daily backup-deadline email (2026-06-16)

Phase-1 of the Infra Backup retirement (per documentation/audits/SPIKE-infra-backup-2026-06-15.md). The mechanism had been dead since slice 8C, yet the hub still stored each version as a plaintext JSON blob at rest containing the customer's app-secret encryption key, restic password, and Cloudflare tokens — a zero-knowledge violation. Its absence was also the root cause of the daily expected_backup_missed false-alarm email.

Changed

  • Backup-deadline check repointed to PBS freshness. monitor.CheckBackupDeadlines no longer looks for a backup_completed event (no component emits it anymore — the disk-tier backup moved to the agent in slice 8C, so the check fired daily for every healthy customer). It now reads the customer's latest agent host-report and raises expected_backup_missed only on positive evidence: no PBS snapshot / successful vzdump at all, the newest backup older than 26h, or the newest PBS snapshot's verify_state == "failed". A fresh-but-not-yet-verified snapshot is not a failure (PBS verifies on its own cadence) — alarming on it would just re-create the false alarm. The db-dump half is unchanged (the in-guest controller still emits db_dump_completed). A customer with no host-report (legacy/defunct) gets no backup alarm here — liveness is the host-staleness checker's job. New store accessor GetLatestHostReportJSON. Tests: internal/monitor/deadline_test.go (fresh+verified→quiet, stale→alarm, failed-verify→alarm, no-report→quiet, db-dump half preserved, plus a pure assessBackupFreshness table). The fresh+verified→quiet test is the companion: it fails against the old event-based check.

Removed

  • The Infra Backup feature: ingest endpoint POST /api/v1/infra-backup, getters GET /api/v1/infra-backup/{id}[/versions] and their handlers; store methods SaveInfraBackup / GetInfraBackup / GetInfraBackupByID / GetInfraBackupMeta / ListInfraBackupVersions / pruneInfraBackups + the InfraBackupMeta / InfraBackupVersion types; the operator "Infra Backup" panel (customer_unified.html, customer.html). The GET /api/v1/recovery/{id} endpoint is kept but now returns only the generated config_yaml (no infra-backup payload). The customer-page config-drift badge that diffed against the stored controller.yaml is hidden (its at-rest source is gone); the live "Show Diff" path is unaffected.

Security / migration

  • Plaintext secret purge. migrate() now DROPs infra_backup_versions + infra_backups and runs VACUUM (+ wal_checkpoint(TRUNCATE)) so the freed pages holding the plaintext keys/ tokens are physically reclaimed, not merely delinked. Gated on table existence so normal restarts don't pay the VACUUM cost.
  • Out of scope (flagged for the operator): the exposed Cloudflare / hub / session credentials in the dropped blobs remain valid until rotated (operator step). Separately, the legacy reports table holds thousands of historical rows with a plaintext restic_password value from old controller versions — a distinct leak, not purged here (the live controller no longer sends it).

v0.11.0 — slice 10D: DR capstone — recovery mode + re-enroll + directive serving (2026-06-10)

The hub half of the slice-10 DR capstone (closes slice 10). The hub ORCHESTRATES recovery but holds no usable secret and no Cloudflare write-power: the escrow blobs it serves are opaque (need R, which the hub never has), and the destructive tunnel/PBS rotation is the operator's step from a trusted environment. A compromised hub can at most hand out opaque blobs + rotate/revoke its own per-host credential — it cannot hijack a customer's tunnel.

Added

  • PUT /admin/hosts/{id}/recovery-mode (global key) — arm recovery mode with a bounded TTL (ttl_seconds, clamped [60s, 4h], default 30m → auto-expires); DELETE to disable. The restore directive + re-enroll are served ONLY while recovery mode is active.
  • POST /hosts/{id}/re-enroll — gated ONLY on recovery mode (the lost box has no old key; the operator armed recovery mode after out-of-band validation). Rotates the host's API key to the new box's key (the old box's hub access is revoked instantly) and returns the DR directive + the two opaque escrow blobs. Without recovery mode → 403. Zero-knowledge: even a wrongful re-enroll in the window leaks nothing recoverable (the blobs need R).
  • GET /hosts/{id}/restore-directive (re-enrolled key, recovery-gated) — re-fetch the directive.
  • Store/escrow: hosts.recovery_mode_until (additive); host_escrow.identity_blob + directive_json (the age-wrapped identity blob + non-secret directive, stored alongside the K-escrow). Methods: SetRecoveryMode/ClearRecoveryMode, RotateHostAPIKey, SaveHostDRBundle/ GetHostDRBundle. The slice-7 escrow upload (PUT /hosts/{id}/escrow) now also accepts identity_blob_b64 + directive (additive).

Not built (by design — the locked rotation model)

  • No Cloudflare write-credential in the hub. The operator deletes the stale tunnel connector + rotates the tunnel/PBS token from their trusted environment (a documented procedure / future small operator CLI). The hub may optionally hold a read-only CF token to surface connector state.

Tests

  • re-enroll refused without recovery mode (403); recovery-mode arm is global-key-only; re-enroll rotates + revokes (old key → 401, new key → 200); directive served only in recovery mode + expires; clear disables re-enroll.

v0.10.0 — slice 10B: signed-op job completion (clear-job) (2026-06-10)

The hub half of slice 10B is small by design — the hub stores + serves the operator-signed blobs opaquely (it holds no signing key and can neither forge nor open them; the agent verifies + executes). 10B adds the missing completion path so a processed job leaves the queue.

Added

  • DELETE /api/v1/hosts/{host_id}/jobs/{job_id} (per-host key, self-scoped; the global key may clear any) — the agent calls it after executing OR terminally rejecting a job. Idempotent (clearing an absent job is a clean 200). Store: DeleteSignedJob.

Unchanged (already in 10A, reused by 10B)

  • POST /admin/hosts/{id}/jobs (operator enqueues the signed blob), GET /hosts/{id}/jobs (the agent fetches), and the has_signed_ops envelope flag. The signed blob stays opaque on the wire (a base64 {op_blob_b64, sig_armored} envelope the agent parses) — no jobs-wire golden change.

Tests

  • DELETE …/jobs/{id} is self-scoped (host A cannot clear host B's job → 403) and idempotent.

v0.9.0 — slice 10A: desired-state serving + signed-jobs queue (the "Down" channel) (2026-06-10)

The hub half of slice 10A: the hub now serves operator intent down to already-authenticated hosts. The control envelope (the host-report response) stops returning placeholder desired_generation:0 / has_signed_ops:false and carries the host's real generation + a signed-jobs flag — the cheap change-notification the agent (v0.15.0) acts on. The heavy desired-state moves only on a dedicated, self-scoped fetch.

Added

  • PUT /api/v1/admin/hosts/{host_id}/desired-state (global/operator key only) — sets a host's desired-state and atomically bumps desired_generation. The body is JSON the hub stores + serves opaquely (it validates only that it is well-formed JSON; the agent/CLI owns the schema). Unknown host → 404; malformed JSON → 400. Minimal admin path; rich editing UX is later.
  • GET /api/v1/hosts/{host_id}/desired-state (per-host key, self-scoped — a host reads only its own; the global key may read any) — returns {generation, desired_state}. The agent fetches it when the envelope's generation advances past its cache.
  • GET /api/v1/hosts/{host_id}/jobs (per-host key, self-scoped) — serves the host's pending opaque signed-op blobs (oldest first). The hub never forged, opened, or executes them (verify + run is slice 10B; this only serves the queue).
  • POST /api/v1/admin/hosts/{host_id}/jobs (global key only) — enqueues a pre-signed opaque job blob. The minimal operator path to seed the queue; the hub holds no signing key.
  • Store: a new signed_jobs table (per-host opaque blob queue); SetHostDesired (set + bump generation, atomic), EnqueueSignedJob / GetSignedJobs / CountSignedJobs. The hosts table's previously-inert desired_json / desired_generation columns are now live.

Changed

  • The host-report control envelope now reports the host's actual desired_generation and has_signed_ops (queue non-empty), both degrading safely to their old defaults on a store error (a heartbeat never fails on the control channel). poll_interval_seconds / blocked unchanged.

Tests

  • admin-set bumps the generation each write + the served state reflects the latest body; admin-set is global-key-only (per-host → 403, malformed → 400, unknown host → 404).
  • GET /desired-state is self-scoped (host A's key → host B → 403; global → any; no token → 401).
  • the envelope carries the current generation + has_signed_ops flips on enqueue; GET /jobs is self-scoped + serves the blobs oldest-first; admin enqueue is global-key-only.
  • cross-repo golden round-trip: testdata/desired-state.golden.json set → fetched back unchanged (the opaque pass-through), byte-identical with felhom-agent's copy.

(no version bump) — slice 9 cross-repo wire-contract: host.cpu_temp_c (2026-06-10)

Slice 9 adds a nullable cpu_temp_c field to the shared HostMetrics wire struct (the agent's new CPU/chassis-temperature collector). The agent's host-report carries it too, so the hub's cross-repo host-report golden (internal/api/testdata/host-report.golden.json) was updated to stay byte-identical with felhom-agent/internal/hub/testdata/host-report.golden.json (the duplicated-contract discipline; manual diff confirmed identical). No hub code change — the full report_json already persists the field verbatim, and the hub does not surface CPU temp on the operator dashboard yet (an optional later freebie). The golden-contract test (host_test.go) still passes (the host parse-struct ignores the extra key).

v0.8.0 — opaque PBS recovery-code escrow storage (slice 7, doc 03 §8a) (2026-06-10)

Hub half of slice-7 close-out: store the agent's opaque R-wrapped PBS-key escrow blob. The default posture is zero-knowledge — the hub holds ciphertext it cannot open (it has no recovery code; there is no decrypt path). Pairs with felhom-agent v0.9.0 (escrow creation). Consumption / restore-mode serving is slice 10.

Added

  • PUT /api/v1/hosts/{host_id}/escrow — authed with the per-host key (a host may only write its own escrow; the global operator key is also accepted). Body mirrors the agent's emit struct (blob_b64, key_fingerprint, posture, created_at). Stores the decoded opaque bytes verbatim; rotation is last-write-wins. No serving this slice.
  • host_escrow table (host_id PK, blob BLOB, fingerprint/posture/created_at). Store methods SaveHostEscrow / GetHostEscrow (HostEscrow). The hub never transforms or decrypts the blob.

Tests

  • Stores the opaque blob verbatim (round-trips byte-identical); rotation last-write-wins; rejects an absent/wrong key (401) and a host writing another host's escrow (403); bad/empty base64 → 400; the wire-contract key-set matches the agent's emit struct.

Security note

The hub stores ciphertext only — holding the blob does NOT let Felhom read customer data (separation principle, doc 03 §8a). The per-host-key gate scopes writes to the owning host.

v0.7.5 — restore-test "passed with warnings" visibility (2026-06-09)

Hub half of TASK — Restore-test must not false-fail on benign start warnings (Phase B). The agent (v0.7.0) now treats a guest-start advisory like the systemd-nesting warning as a PASS (verdict is liveness, not the start exitstatus) and carries the warning text on the wire. This makes that visible to the operator instead of indistinguishable from a clean pass.

Added

  • hostRestoreTest.warnings ([]string) + warnings_recognized (bool) mirror fields, matching the agent's hub.RestoreTest wire contract (omitempty; an absent warnings_recognizedfalse ⇒ treated as the louder unrecognized case — a missing flag can only over-notice).

Changed

  • Host-report ingest now surfaces a passed restore-test that carried warnings: [INFO] restore-test passed WITH WARNINGS (recognized) when every warning is the known-benign anchor, escalated to [WARN] … UNRECOGNIZED WARNINGS otherwise — as loud as a failed PBS verify, so a real restore warning can't hide behind a green pass. A FAILED restore-test still logs the existing [WARN] … FAILED.

Tests / contract

  • restore_tests[0] in the host-report golden gains warnings + warnings_recognized; the golden stays byte-identical with felhom-agent's copy (sha256-verified) and the bidirectional key-set contract test now round-trips the new keys through hostRestoreTest.

Not in this slice

  • No dashboard widget: the hub web layer renders only controller-report data — there is no host-domain dashboard surface yet (guests/storage/restore_tests/pbs_snapshots are log+persist only, same as the failed-PBS-verify signal). Distinct dashboard treatment lands when the host-domain dashboard does (slice 10). The operator signal this slice is the log line.

v0.7.4 — ingest agent pbs_snapshots (slice 6 Phase B) (2026-06-09)

The agent's slice-6 Phase B work populates the host-report's pbs_snapshots (the PBS offsite inventory + per-snapshot verify-state). This is the hub half: accept + persist them. Minimal — the rich offsite policy is hub-owned (slice 10); this mirrors what the agent reports.

Added

  • hostPBSSnapshot mirror struct in hostReportPayload (internal/api/handler.go) — field-for-field with the agent's hub.PBSSnapshot wire contract (namespace/backup_type/ backup_id/backup_time/size_bytes/owner/protected/encrypted/verify_state/verify_upid). Persisted via report_json (no new columns — the slice-5/6A precedent).
  • A FAILED PBS verify is logged prominently ([WARN] — the loudest offsite-DR signal, same treatment as a failed restore-test). The host-report info line now counts pbs-snapshots.
  • testdata/host-report.golden.json updated with a populated pbs_snapshots[0], kept byte-identical with felhom-agent's copy.
  • TestHostPBSSnapshot_GoldenContract — the hub half of the bidirectional key-set test.

Notes

  • Backward-compatible: an agent that omits/empties pbs_snapshots is accepted unchanged.

v0.7.3 — ingest agent backups + restore_tests (slice 6 Phase A) (2026-06-09)

The agent's slice-6 work populates the host-report's backups + restore_tests (the self-restore-test result). This is the hub half: accept + persist them. Minimal — the rich backup policy (schedule/retention/target selection) is hub-manifest-owned and lands at slice 10; this slice only mirrors what the agent reports.

Added

  • hostBackup / hostRestoreTest mirror structs in hostReportPayload (internal/api/handler.go) — field-for-field with the agent's hub.Backup / hub.RestoreTest wire contract. Persisted verbatim in report_json (no new columns — slice-5 precedent).
  • A FAILED restore-test is logged prominently ([WARN], the loudest DR signal there is); a failed backup is logged too. The host-report info line now counts backups + restore-tests.
  • testdata/host-report.golden.json updated with a populated backups[0] / restore_tests[0], kept byte-identical with felhom-agent's copy.
  • TestHostBackup_GoldenContract / TestHostRestoreTest_GoldenContract — the hub half of the bidirectional key-set test (round-trip the golden through the mirror, assert exact keys).

Notes

  • Backward-compatible: an agent that omits/empties these is accepted unchanged. The legacy controller report path is untouched (frozen until slice 10).

v0.7.2 — ingest agent storage_targets (slice 5 Phase A) (2026-06-09)

The agent's slice-5 work populates the host-report's storage_targets (previously empty). This is the hub half: accept + persist them. Minimal by design — the rich, authoritative storage manifest (desired class/role/policy/creds) is hub-owned and lands at slice 10; this slice only mirrors what the agent observes.

Added

  • hostReportPayload.StorageTargets (internal/api/handler.go) — a full mirror of the agent's hub.StorageTarget wire contract (name/type/durable_id/state/reachable/usage/ content/mount/class_hint/role/thin_pool/smart). The targets are persisted verbatim in the existing report_json row (no schema change); the handler counts them and logs a [WARN] when any are disconnected (the storage analog of host-down visibility).
  • testdata/host-report.golden.json — updated to carry two populated storage_targets (an lvmthin with thin_pool, a usb), kept byte-identical with felhom-agent's copy.
  • TestHostStorageTarget_GoldenContract — the hub half of the bidirectional key-set test: round-trips the golden's storage_targets[0] through the mirror struct and asserts the key set matches exactly (no missing/extra fields vs the agent). TestHostReport_GoldenContract also now asserts the targets are persisted + parse back.

Notes

  • Backward-compatible: an older agent that sends storage_targets: [] (or omits it) is accepted unchanged. The legacy controller report path is untouched (frozen until slice 10).

Repo docs — no hub version change (2026-06-08)

Changed

  • Reflowed felhom.eu/CLAUDE.md — removed hard mid-paragraph line wraps (prose, list items, blockquotes now single-line); tables untouched; rendered output unchanged.
  • Unified the REPORT/CHANGELOG convention: this repo's REPORT.md switches from append/cumulative to overwrite-latest (uniform with the sibling repos); CHANGELOG.md (this file) stays the cumulative log, newest on top. Updated REPORT.md's header note accordingly (existing sections retained as history). Added an explicit no-secrets rule. No hub code change → no version bump.

v0.7.1 (2026-06-08)

Changed

  • /host-report rejects oversize bodies explicitly with 413 (handler.go) instead of silently truncating at the 4 MiB LimitReader cap. Reads one byte past maxHostReportBytes and returns 413 Payload too large — a truncated-but-valid JSON could otherwise be accepted as a partial report (silently dropping guests from the mirror). The controller handleReport 1 MiB path is unchanged (frozen until slice-10 cutover).

Added

  • Cross-repo contract fixture hub/internal/api/testdata/host-report.golden.json (byte-identical with felhom-agent's copy) + TestHostReport_GoldenContract — POSTs the golden through the real handleHostReport and asserts 200 + denorm (guest_total/guest_running/cloudflared_status) + both guests upserted, proving hostReportPayload still extracts the contract from the real shape. Duplicated contract (no shared types module yet); revisit at slices 5/6.

v0.7.0 (2026-06-08)

Added — host-domain ingest (slice 3, additive; controller path untouched)

  • New tables hosts, guests, host_reports (store.go migrate(), idempotent). Full schema now, including columns inert until slice 10 (hosts.desired_json/desired_generation/dr_record_json, guests.api_key/desired_spec_json) so the cutover needs no ALTER. Nothing reads/writes the inert columns this slice.
  • POST /api/v1/host-report — the agent's heartbeat. Per-host Bearer auth; 4 MiB body; persists the full report + denormalized fields (cpu/mem/disk %, guest counts, cloudflared status); upserts each guest's reality columns (guest_id = "<host_id>/<vmid>", hub-derived); returns the control envelope {status, poll_interval_seconds:900, blocked, desired_generation:0, has_signed_ops:false} (blocked reflects the customer's status; the latter two are reserved/placeholder for slice 4).
  • Per-host key authcheckAuthHost (Bearer → host → customer), added alongside the unchanged checkAuthCustomer. Global key remains a bootstrap fallback.
  • POST /api/v1/admin/hostsPROVISIONAL global-key-only host mint (host_id + per-host api_key); the slice-3 bootstrap until enrollment (slices 78) replaces it.
  • Host dead-man's-switchmonitor.HostStalenessChecker over host_reports, emitting host_stale/host_down/host_recovered (30m/60m), attributed to the host's customer; registered in allowedEventTypes; wired in cmd/hub/main.go on the existing 60s ticker. A deliberate sibling of the controller StalenessChecker (both run until slice 10).
  • Store methods: GetHostByAPIKey, GetHost, ListHosts, UpsertHost, SaveHostReport, UpsertGuestFromReport (preserves inert columns on conflict), GetHostStaleness (skips never-reported hosts), GuestID. Prune now also prunes host_reports (same retention).
  • Tests (new, hermetic): store, auth (checkAuthHost), ingest (valid+envelope+denorm, host_id mismatch→403, unknown-host-under-global→400, blocked→true, oversize→400), admin mint (non-global→403, unknown customer→400, mint+round-trip), host staleness transitions.

Unchanged (explicit)

  • The controller path — /api/v1/report, reports, customer_configs, checkAuthCustomer, the existing staleness/deadline checkers — is untouched and still green. The old controller and the new agent report in parallel during slices 39; the schema/auth cutover is slice 10.

v0.6.2 (2026-02-26)

Added

  • Infra backup GFS retention — New infra_backup_versions table stores multiple backups per customer. GFS pruning keeps: all from last 24h, latest per day (7 days), latest per week (4 weeks), latest per month (3 months) — ~14 versions max per customer
  • GET /api/v1/infra-backup/{id}/versions — Returns metadata list of all retained backup versions (date, stack names, disk count) for a customer. Bearer auth.
  • Recovery version selectionGET /api/v1/recovery/{id}?version=ID fetches a specific backup version instead of latest. Response now includes backup_versions array with all available versions.
  • Dashboard backup history — Customer detail page "Infra Backup" card shows version count and collapsible history table (date, apps, disks)

Changed

  • SaveInfraBackup() — Now INSERTs a new row instead of upserting, preserving history. Automatically prunes old versions via GFS algorithm.
  • One-time migration — Existing data from infra_backups table is copied to infra_backup_versions on first startup

v0.6.1 (2026-02-25)

Added

  • Delete issues from app detail page — Known Issues table now has per-row checkboxes with "Delete Selected" and "Delete All Issues" buttons; keeps telemetry data (memory trends, etc.) intact
  • DELETE /apps/{appName}/delete-issues — New POST endpoint supporting action=selected (with issue_ids form values) and action=all

Fixed

  • Hub-side fingerprint hardeningfingerprintIssue() now strips ANSI escape codes, ISO/syslog timestamps, and lowercases before truncating to 100 chars. Prevents duplicate issue rows when messages differ only by embedded timestamps.

v0.6.0 (2026-02-25)

Added

  • Geo-restriction display (customer_unified.html) — New "Geo-korlátozás" section on customer detail pages showing: enabled/disabled status, allowed countries, per-app overrides, last sync time, and sync errors. Only visible when the controller reports geo_restriction data.
  • "Összes geo-korlátozás eltávolítása" button — One-click removal of all [felhom-geo] Cloudflare WAF rules. The Hub calls the Cloudflare API directly (bypasses potentially blocked tunnel), then retries notifying the controller in background (every 30s for up to 10 min) to disable geo in its settings.
  • Cloudflare unblock client (internal/cloudflare/unblock.go) — Minimal Cloudflare API client for deleting geo-restriction WAF rules. Resolves zone ID, finds the http_request_firewall_custom ruleset, and deletes rules with [felhom-geo] description prefix.
  • POST /customers/{id}/geo/disable route — CSRF-protected endpoint for the geo-disable action.

Removed

  • Legacy Monitoring UUIDs — Removed the "Monitoring UUIDs" section from the config form (config_form.html), UUID form-field handling from buildConfigJSON(), UUID import from handlePullConfig(), volatile key entries for monitoring.ping_uuids.*, and the commented-out ping_uuids section from controller.yaml.default. Monitoring is fully handled by the Hub event system since v0.3.0.

v0.5.0 (2026-02-25)

Added

  • Configuration page (GET /configuration) — New "Configuration" tab in the web UI with asset management controls. Displays asset file count, manifest generation timestamp, and a "Refresh Assets from Image" button.
  • Manual asset re-seed (POST /configuration, action=refresh_assets) — Re-reads the baked-in seed directory, compares SHA-256 checksums with PVC assets, and updates changed files. Rebuilds the manifest afterward. Controllers pick up changes on their next daily sync.
  • ReSeed() method (internal/assets/assets.go) — Public method for triggering asset re-seed + manifest rebuild from the web UI.

Changed

  • Asset seeding: seedIfEmpty()seedOrUpdate() (internal/assets/assets.go) — On startup the Hub now compares SHA-256 checksums between the image seed directory and the PVC, updating any changed files instead of only seeding into an empty directory. This means redeploying the Hub image with updated assets automatically propagates them without PVC deletion.
  • isAssetFile() expanded — Now also matches *-favicon.svg and *-favicon.ico patterns, allowing branding assets like felhom-favicon.svg in the manifest.
  • RebuildManifest() refactored — Internal logic extracted to rebuildManifestLocked() for reuse by ReSeed().
  • Web Server struct — Added assetsMgr field and SetAssetManager() method. Wired in main.go.
  • All templates translated to English — The "Alkalmazások" nav link and telemetry pages (apps.html, app_detail.html, customer_unified.html telemetry section) are now in English, consistent with the rest of the Hub UI.
  • Navigation updated — All templates now show four tabs: Dashboard, Customers, Apps, Configuration.

v0.4.1 (2026-02-23)

Added

  • Per-app telemetry reset (store/telemetry.go, web/apps.go) — New "Telemetria törlése" button on the app detail page that deletes all telemetry records and known issues for the selected app. Useful after major app updates when old data is no longer representative. Includes confirmation dialog and flash notification.
  • DeleteAppTelemetry() and DeleteAppIssues() store methods (store/telemetry.go) — Delete all telemetry/issue rows for a specific app_name.
  • POST /apps/{name}/reset-telemetry route (web/server.go) — CSRF-protected endpoint that triggers the reset and redirects back with flash message.

v0.4.0 (2026-02-23)

App Telemetry & Analytics Dashboard

Added

  • app_telemetry and app_log_issues SQLite tables (store/store.go) — store per-app resource metrics and deduplicated log issues reported by v0.28.0+ controllers.
  • internal/store/telemetry.go — New store methods: SaveAppTelemetry, GetFleetAppSummary (with P95 memory calculation), GetAppTelemetryHistory, GetAppCustomerBreakdown, GetCustomerAppSummary, GetAppIssues, GetRecentIssuesAllApps, PruneAppTelemetry, PruneStaleIssues. New types: AppTelemetryRecord, FleetAppSummary, AppTelemetryPoint, AppCustomerStats, CustomerAppSummary, AppIssue.
  • /api/v1/report handler update (api/handler.go) — After saving the standard report, parses the optional app_telemetry JSON field and persists it. Backward-compatible: old controllers (no app_telemetry key) are unaffected.
  • Fleet app list page (GET /apps) — Hungarian-language dashboard showing all deployed apps fleet-wide with deployment count, avg/P95 memory, catalog estimate/limit accuracy, error/warning badges. Sortable columns, 24h/7d/30d period selector.
  • Per-app detail page (GET /apps/{name}) — Memory trend Chart.js chart (avg + peak, with catalog limit line), per-customer breakdown table, known log issues table (severity, message, occurrence count, affected customers). Includes suggested mem_limit from P95×1.2 rounded to 32M.
  • Customer detail page telemetry section (customer_unified.html) — New "Alkalmazás telemetria" card with per-app memory (current/avg/peak) and log error/warning counts linking to /apps/{name}.
  • Chart.js (static/chart.min.js) — Embedded from controller build, served at /static/chart.min.js.
  • "Alkalmazások" nav link — Added to header navigation across all templates.
  • New CSS (style.css) — .badge, .badge-error, .badge-warn, .summary-cards, .summary-card, .chart-container, .period-selector, .period-btn, .accuracy-dot, .mem-ok/warn/danger, .data-table styles.
  • Telemetry pruning (cmd/hub/main.go) — pruneAll() now also prunes app_telemetry rows older than 90 days and stale log issues not seen in 30 days.

Changed

  • internal/web/apps.go (new file) — handleApps, handleAppDetail, parsePeriod, sortFleetSummary, aggregateHistoryForChart, parseLimitMB, memoryColor, accuracyClass, getCSRFToken helper functions.
  • internal/web/server.go — Added routes for /apps, /apps/{name}, /static/chart.min.js. Added memoryColor, accuracyClass, gt template functions.
  • internal/web/embed.go — Added //go:embed static/chart.min.js directive.

v0.3.7 (2026-02-21)

Asset management API

  • New internal/assets package: manages app assets (logos, screenshots) on Hub PVC (/data/assets/) with automatic seeding from baked-in image copy on first run.
  • Two new authenticated API endpoints for controllers to sync assets:
    • GET /api/v1/assets/manifest — returns JSON manifest with filenames + SHA-256 checksums
    • GET /api/v1/assets/file/{filename} — serves individual asset files
  • Dockerfile updated to COPY assets/ /usr/share/felhom/assets-seed/ for first-run seeding.
  • Build script syncs website assets (*-logo.{svg,png}, *-screenshot-*.webp) into Docker build context.

v0.3.6 (2026-02-21)

Human-friendly retrieval passwords

  • Retrieval passwords now use Hungarian word passphrases (e.g. áldás-plazmid-palánta-süvítve-pócgém) instead of 64-char hex strings.
  • Embedded 29K+ curated Hungarian word list (hungarian.txt) via go:embed; 5-word passphrases give ~74 bits of entropy.
  • New configgen.RandomPassphrase(wordCount) function; all 3 retrieval password generation sites updated.
  • API keys remain as hex (machine-to-machine, never typed by humans).

v0.3.5 (2026-02-21)

Recovery Endpoint & Customer Standing

  • New GET /api/v1/recovery/{customer_id} endpoint: returns both generated controller.yaml and infra backup in a single response for disaster recovery. Auth via X-Retrieval-Password header (same as config retrieval).
  • Report response now includes customer_blocked: true when customer status is "blocked" — allows controllers to detect standing and enter limited mode.

v0.3.4 (2026-02-20)

  • Rename version labels: "Current version" → "Controller version", "Latest version" → "Registry latest".

v0.3.3 (2026-02-20)

Bugfixes

  • Fix double "v" prefix in controller version display (showed "vv0.21.1" instead of "v0.21.1").
  • Skip deprecated monitoring.ping_uuids.* keys in config diff comparison (added to volatile keys).

v0.3.2 (2026-02-20)

Hub Version Display

  • Show Hub version in footer of all pages via hubVersion template function.
  • web.New() now accepts version parameter (4th arg) — set via ldflags at build time.

v0.3.1 (2026-02-20)

Config Diff Display + Pull Config

  • Value-based config comparison: Replaced broken SHA256 hash comparison with semantic YAML comparison. Both configs are parsed into maps, flattened to dot-notation keys, and compared by value. Ignores key ordering, whitespace, comments, and volatile fields (web.session_secret). Shows actual diff count on customer page ("⚠ Config mismatch — N differences").
  • Config diff endpoint (GET /customers/{id}/config-diff): Fetches live YAML from controller via new GET /api/config endpoint, generates Hub YAML via configgen.Generate(), returns JSON with per-key diffs (key, hub value, controller value, status). Sensitive values (tokens, passwords, secrets) are masked.
  • Pull Config (POST /customers/{id}/pull-config): Reverse of Push Config — imports controller's current config into the Hub. Extracts identity fields (name, domain, email) and override fields (infrastructure tokens, git credentials, monitoring UUIDs). Preserves existing APIKey and RetrievalPassword.
  • Diff display UI: "Show Diff" button on customer page expands a table showing all key-value differences with color-coded rows (yellow=changed, blue=hub-only, orange=controller-only).
  • Pull Config button: Added next to existing "Push Config" with confirmation dialog.

v0.3.0 (2026-02-20)

Hub Monitoring Takeover — Event System, Dead Man's Switch, Notifications

Replaces external Healthchecks.io with a Hub-native event system. The Hub becomes the single source of truth for all customer monitoring, event tracking, dead man's switch alerting, and notification delivery.

Phase 1 — Event System

  • events table in SQLite: stores all events with customer_id, event_type, severity, message, details_json, source, timestamp
  • Indexes: idx_events_customer_created (customer + time DESC), idx_events_type (type + time DESC)
  • Store methods: SaveEvent, GetRecentEvents, GetEventsByType, GetLatestEventByType, GetAllRecentEvents, CountEventsBySeverity, PruneEvents, GetActiveCustomerIDs
  • POST /api/v1/event endpoint: accepts structured events from controllers, validates event_type against 27 allowed types, validates severity (info/warning/error), stores in DB
  • Enhanced auth: checkAuthCustomer() validates per-customer API keys match the customer_id in payload; global key bypasses ownership check
  • Prune: events pruned alongside reports at 04:30 Budapest time

Phase 2 — Dead Man's Switch

  • Staleness checker (internal/monitor/staleness.go): runs every 60s, detects when controllers stop reporting
    • ok→stale (>30min): inserts node_stale warning event
    • any→down (>60min): inserts node_down error event
    • stale/down→ok: inserts node_recovered info event
    • Skips blocked customers, no false alerts on startup
  • Backup deadline checker (internal/monitor/deadline.go): runs daily at 05:00 Budapest
    • Detects missing backup_completed events since midnight → inserts expected_backup_missed error
    • Detects missing db_dump_completed events → inserts expected_dbdump_missed error
    • Grace: skips customers with node_down state
  • scheduleDaily() helper: goroutine that sleeps until target time (Europe/Budapest), runs function, loops
  • /healthz enhanced: returns 503 if SQLite Ping fails

Phase 3 — Notification System

  • Dispatcher (internal/notify/dispatcher.go): processes events and sends emails via Resend API
    • Operator channel: English emails to operator for warning/error events, 1h cooldown per customer:eventType
    • Customer channel: Hungarian emails per event_type, respects customer preferences (enabled_events, cooldown_hours), blocked customers skipped
    • Test bypass: test event type skips cooldown/preferences, sends directly to customer email
  • Email templates (internal/notify/templates.go): operator (concise English), customer (Hungarian per event type with complete message table)
  • Cooldown tracking: in-memory maps with per-customer:eventType granularity
  • customer_notifications table: added cooldown_hours column (default 6)
  • notification_log table: added channel column (operator/customer)
  • Wired into /api/v1/event handler and staleness/deadline checkers

Phase 4 — Hub UI

  • Events section on customer detail page: last 50 events, severity filter buttons (All/Errors/Warnings/Info), colored severity badges
  • Dashboard badges: error+warning count in last 24h per customer, clickable to customer events
  • Notification log: shows channel column (operator/customer) in customer detail page
  • Config form: Monitoring UUIDs section marked as "Legacy" with deprecation notice, collapsed by default

Phase 6 — Config Cleanup

  • controller.yaml.default: monitoring.ping_uuids section commented out (deprecated)
  • buildConfigJSON: only writes ping_uuids to config JSON if user explicitly provides UUID values (new configs get none)

v0.2.2 (2026-02-20)

Config Hash Comparison

  • Config sync status on unified customer page: compares SHA256 hash of controller's controller.yaml (from report payload) against Hub-generated YAML. Shows "In sync", "Config mismatch", or "Unknown" (controller needs v0.20.0+ to report hash).
  • Visible in the Controller Update section next to Push Config button.

v0.2.1 (2026-02-20)

Unified Customer Management

All customer views consolidated into a single page. New management features: blocked status, dashboard merge, config push, and auto-config creation.

New features

  • Unified customer page — /customers/{id}:

    • Single page showing both configuration info and live report data
    • Replaces separate /configs/{id} (config detail) and /customers/{id} (report detail) pages
    • Shows config management (credentials, setup commands, YAML preview) when config exists
    • Shows "Create Config" button for manual (report-only) customers
    • Old /configs/{id} URLs redirect to /customers/{id}
  • Dashboard shows pending customers:

    • Customers with config but no reports appear on dashboard with "PENDING" status
    • All metric columns show "—" for pending customers
  • Blocked/Banned status:

    • Customers can be blocked via button on detail page
    • Blocked customers hidden from Dashboard
    • Reports still accepted (prevents controller retry loops) but notifications suppressed
    • "BLOCKED" badge shown on Customers list and detail page
    • One-click unblock button
  • Config push to controller:

    • "Push Config" button on unified page (visible when controller URL known)
    • Generates YAML and POSTs to {controller_url}/api/config/apply
    • Note: requires controller v0.20.0+ with config apply endpoint
  • Auto-create config from report data:

    • "Create Config" button on manual customer pages
    • Pre-fills customer name from report, generates credentials
    • Redirects to edit form for additional fields

Changes

  • Customers list: all rows now link to /customers/{id} (unified page)
  • Config badges: new MANAGED/MANUAL/BLOCKED pill-style badges
  • customer_configs table: added status column (active/blocked)
  • Status functions handle "pending" and "blocked" status values

v0.2.0 (2026-02-20)

Customer Configuration Management

New "Configurations" section for pre-provisioning customer nodes. Operators can configure customer settings in the Hub web UI, then docker-setup.sh downloads a ready-made controller.yaml — reducing deployment to a customer ID and password.

New features

  • Web UI — /configs pages:

    • List all customer configurations in a table
    • Create new configuration: customer identity, infrastructure secrets (CF tunnel/API tokens), git sync credentials, monitoring UUIDs — organized in collapsible sections
    • Detail page: shows credentials (retrieval password, per-customer API key) with copy-to-clipboard, setup commands (docker-setup.sh and curl), live YAML preview
    • Edit and delete configurations
    • Navigation tabs (Dashboard / Configurations) on all pages
  • Config retrieval API — GET /api/v1/config/{customer_id}:

    • Authenticated via X-Retrieval-Password header (separate from Bearer token)
    • Generates complete controller.yaml by deep-merging template with customer overrides
    • Template sourced from controller.yaml.example (fetched from Gitea repo periodically)
    • Falls back to embedded default template if fetcher not configured
  • Per-customer API keys:

    • Each customer config gets its own API key (auto-generated, 64 hex chars)
    • Controllers can authenticate with per-customer key instead of the shared global key
    • Backward compatible — global report_api_key continues to work alongside per-customer keys
  • YAML generation (internal/configgen package):

    • Deep-merge of template + customer-specific overrides
    • Programmatic injection: customer identity, hub config, session secret
    • Shared by both API handler and web UI preview
  • Template fetcher (background goroutine):

    • Periodically fetches controller.yaml.example from Gitea (configurable interval)
    • Requires registry.username + registry.token in hub.yaml
    • Falls back to go:embed default template when not configured
  • Data layer:

    • New customer_configs SQLite table
    • 6 CRUD methods: Save, Get, List, Delete, GetByAPIKey, UpdateRetrievalPassword

Configuration

New registry section in hub.yaml:

registry:
  image: "gitea.dooplex.hu/admin/felhom-controller"
  username: ""               # Gitea credentials (for version checker + template fetcher)
  token: ""
  check_interval: "6h"
  template_interval: "1h"   # How often to refresh controller.yaml.example

Files added

  • internal/configgen/configgen.go — shared YAML generation package
  • internal/web/configs.go — web handlers for config CRUD
  • internal/web/templatefetcher.go — background template refresh
  • internal/web/controller.yaml.default — embedded fallback template
  • internal/web/templates/configs.html — config list page
  • internal/web/templates/config_form.html — create/edit form
  • internal/web/templates/config_detail.html — detail + credentials page

Files modified

  • internal/store/store.go — customer_configs table + CRUD methods
  • internal/api/handler.go — config retrieval endpoint, per-customer auth, ConfigTemplateProvider interface
  • internal/web/server.go/configs/* routes, SetTemplateFetcher()
  • internal/web/embed.go — embedded default template
  • internal/web/templates/dashboard.html — navigation bar
  • internal/web/templates/customer.html — navigation bar
  • internal/web/templates/style.css — form, nav, button, credential styles
  • cmd/hub/main.go — template fetcher wiring, TemplateInterval config
  • configs/hub.yaml.example — registry section

v0.1.8 (2026-02-16)

  • Controller update trigger: "Update" button on customer detail page calls controller's self-update endpoint
  • Registry version checker: background goroutine checks Gitea registry for latest controller image tag
  • Update available indicator on customer detail page

v0.1.7 (2026-02-15)

  • Infrastructure backup endpoints for disaster recovery (POST + GET /api/v1/infra-backup)

v0.1.6 (2026-02-14)

  • Handle disabled reporting status
  • Storage labels display
  • Date in history table

v0.1.5 (2026-02-13)

  • Notification preferences sync endpoint (POST /api/v1/preferences)
  • Notification display on customer detail page

v0.1.4 (2026-02-12)

  • Resend API key support for email notifications
  • Notification endpoint (POST /api/v1/notify)

v0.1.3 (2026-02-11)

  • Customer detail page: system info, storage bars, container table
  • 24h history graphs

v0.1.2 (2026-02-10)

  • Dashboard auto-refresh (60s cycle)
  • Status logic (green/yellow/red based on report age + health)

v0.1.1 (2026-02-09)

  • Basic dashboard with customer overview table
  • Report ingest API

v0.1.0 (2026-02-08)

  • Initial release: SQLite store, report API, basic web dashboard