hub v0.51.0: DR-tier-by-default — per-customer dr_tier flag (default ON, legacy backfill from reality), cascade stages, WG-registration auto-provision hook, offsite-requires-DR guard (F-6 policy), host-page capability chips (inactive=neutral)

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
2026-07-12 20:37:00 +02:00
parent 007946faf4
commit 448a68237a
18 changed files with 824 additions and 87 deletions
+7 -4
View File
@@ -69,7 +69,7 @@
| `(*Server).configFormData` (v0.49.0) | hub/internal/web/configs.go (~L430) | `(r, isNew, cfg, overrides, errMsg) configFormView` | The ONE view-model builder for the customer config form (standalone chrome + the customer page Edit tab) | `overrides=nil` → parses the STORED cfg.ConfigJSON; pass the SUBMITTED map on the update validation-error re-render or typed values reset (red-proofed). | | `(*Server).configFormData` (v0.49.0) | hub/internal/web/configs.go (~L430) | `(r, isNew, cfg, overrides, errMsg) configFormView` | The ONE view-model builder for the customer config form (standalone chrome + the customer page Edit tab) | `overrides=nil` → parses the STORED cfg.ConfigJSON; pass the SUBMITTED map on the update validation-error re-render or typed values reset (red-proofed). |
| `config_form_body` sub-template (v0.49.0) | hub/internal/web/templates/config_form_body.html | `{{template "config_form_body" <configFormView>}}` | Rendering the config form on ANY surface (config_form.html chrome + customer Edit tab) | The floor/geo/danger cards on the Edit tab are SIBLINGS after `</form>` — never nest a form inside it (breaks the offsite/PBS formaction sub-buttons). Includes the F5 in-flight `<script>`. | | `config_form_body` sub-template (v0.49.0) | hub/internal/web/templates/config_form_body.html | `{{template "config_form_body" <configFormView>}}` | Rendering the config form on ANY surface (config_form.html chrome + customer Edit tab) | The floor/geo/danger cards on the Edit tab are SIBLINGS after `</form>` — never nest a form inside it (breaks the offsite/PBS formaction sub-buttons). Includes the F5 in-flight `<script>`. |
| `(*Store).CountHostArtifacts` / `DeleteHost` | hub/internal/store/store.go (~L1640/~L1690) | `(hostID) (HostArtifacts, error)` / `(hostID, deleteEscrow bool) error` | Host-delete impact preview + the ONE-transaction cascade | ONLINE gate lives in the handler, escrow gate in the store (`ErrHostEscrowPresent`, tx never starts). log_bundles die by `scope_id == host_id` ONLY (customer-scoped bundles survive). The wg_peers delete is INSIDE the tx — never split it out. | | `(*Store).CountHostArtifacts` / `DeleteHost` | hub/internal/store/store.go (~L1640/~L1690) | `(hostID) (HostArtifacts, error)` / `(hostID, deleteEscrow bool) error` | Host-delete impact preview + the ONE-transaction cascade | ONLINE gate lives in the handler, escrow gate in the store (`ErrHostEscrowPresent`, tx never starts). log_bundles die by `scope_id == host_id` ONLY (customer-scoped bundles survive). The wg_peers delete is INSIDE the tx — never split it out. |
| `(*Store).ListWGEndpoints` / `DeleteWGEndpoint` | hub/internal/store/wg.go (~L64/~L86) | `() ([]WGEndpoint, error)` / `(endpointID) error` | The /offsite endpoint-management surface | `GetWGEndpoint` (lowest id, LIMIT 1) stays THE allocation/sync endpoint — do not switch allocator/reconciler/desired-state to the list without the `wg_peers.endpoint_id` migration arc. Peers-in-subnet guards live in web/offsite.go. | | `(*Store).ListWGEndpoints` / `DeleteWGEndpoint` | hub/internal/store/wg.go (~L64/~L86) | `() ([]WGEndpoint, error)` / `(endpointID) error` | The /offsite endpoint-management surface | `GetWGEndpoint` (lowest id, LIMIT 1) stays THE allocation/sync endpoint — do not switch allocator/reconciler/desired-state to the list without the `wg_peers.endpoint_id` migration arc. Peers-in-subnet guards live in hub/internal/web/offsite.go. |
### Artifact manifest / Day-0 trust root ### Artifact manifest / Day-0 trust root
@@ -82,13 +82,16 @@
| `(*Handler).handleArtifactManifest` | hub/internal/api/handler.go (~L1550) | `GET /api/v1/artifacts/{id}` | Serving the vouched set to the bootstrap script | Auth mirrors handleConfigRetrieve exactly. Unset manifest = empty fields, not an error. | | `(*Handler).handleArtifactManifest` | hub/internal/api/handler.go (~L1550) | `GET /api/v1/artifacts/{id}` | Serving the vouched set to the bootstrap script | Auth mirrors handleConfigRetrieve exactly. Unset manifest = empty fields, not an error. |
| `normalizeFloorInput` / `normalizeSHA256` | hub/internal/web/configs.go (~L27 / ~L627) | `(raw string) (string, bool)` | Validating operator-typed versions / shas | Empty string is VALID (means "clear"). Reuse for any new version/sha form field. | | `normalizeFloorInput` / `normalizeSHA256` | hub/internal/web/configs.go (~L27 / ~L627) | `(raw string) (string, bool)` | Validating operator-typed versions / shas | Empty string is VALID (means "clear"). Reuse for any new version/sha form field. |
### PBS DR tier / tenantsync (hub/internal/tenantsync/, web/pbsdr.go, api/pbsdr.go) ### PBS DR tier / tenantsync (hub/internal/tenantsync/, hub/internal/web/pbsdr.go, hub/internal/api/pbsdr.go)
| Symbol | File | Short signature | Use for | Gotchas | | Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---| |---|---|---|---|---|
| `tenantsync.Client` (`Provision`/`Reissue`/`Fingerprint`) | hub/internal/tenantsync/client.go | `(ctx, customerID) (*Result, error)` | ep0 per-customer PBS tenancy over the pinned-SSH forced-command channel (the wgsync twin) | `Result.TokenSecret` is transient custody → `SaveHostPBSSecret` immediately, never log the struct. Error paths NEVER embed stdout (the secret channel) — do not "improve" diagnostics by quoting the response. `ErrTokenExists` is typed: provision refuses an existing token; re-issue is the explicit path. | | `tenantsync.Client` (`Provision`/`Reissue`/`Fingerprint`) | hub/internal/tenantsync/client.go | `(ctx, customerID) (*Result, error)` | ep0 per-customer PBS tenancy over the pinned-SSH forced-command channel (the wgsync twin) | `Result.TokenSecret` is transient custody → `SaveHostPBSSecret` immediately, never log the struct. Error paths NEVER embed stdout (the secret channel) — do not "improve" diagnostics by quoting the response. `ErrTokenExists` is typed: provision refuses an existing token; re-issue is the explicit path. |
| `(*Store).SaveHostPBSSecret` / `ConsumeHostPBSSecret` | hub/internal/store/pbsdr.go | `(hostID, value)` / `(hostID) (string, error)` | HOST-scoped consume-once secret (the one_time_secrets host twin) | Same-tx mark-consumed; re-save resets consumption (re-issue supersedes). The agent consumes via `POST /api/v1/hosts/{id}/pbs/consume-token` (api/pbsdr.go). | | `(*Store).SaveHostPBSSecret` / `ConsumeHostPBSSecret` | hub/internal/store/pbsdr.go | `(hostID, value)` / `(hostID) (string, error)` | HOST-scoped consume-once secret (the one_time_secrets host twin) | Same-tx mark-consumed; re-save resets consumption (re-issue supersedes). The agent consumes via `POST /api/v1/hosts/{id}/pbs/consume-token` (hub/internal/api/pbsdr.go). |
| `(*Server).applyPBSDR` + `mergePBSDR`/`readPBSDR` | hub/internal/web/pbsdr.go | `(ctx, r, cfg) error` | The config form's PBS DR section → HOST desired_json `pbs_dr` descriptor + generation bump | Descriptor lives in the host desired_json, NOT ConfigJSON (buildConfigJSON drops foreign keys on re-save). Fail-closed before SaveCustomerConfig; already-provisioned = success-no-op (red-proofed); disable keeps the ep0 tenancy. | | `(*Server).applyPBSDR` + `mergePBSDR`/`readPBSDR` | hub/internal/web/pbsdr.go | `(ctx, r, cfg) error` | The config form's DR-tier section → HOST desired_json `pbs_dr` descriptor + generation bump | Descriptor lives in the host desired_json, NOT ConfigJSON (buildConfigJSON drops foreign keys on re-save). v0.51.0: driven by `cfg.DRTier` (set from the form BEFORE applyOffsite/applyPBSDR); UNMET preconditions are honest waiting stages (save succeeds), REAL failures stay fail-closed; already-provisioned = success-no-op (red-proofed); disable keeps the ep0 tenancy. |
| `(*Server).pbsdrProvisionAtom` + `PBSDRAutoProvision` | hub/internal/web/pbsdr.go | `(ctx, customerID, host, storageID) (blocked string, err error)` / `(ctx, customerID)` | The shared fresh-provision cascade atom; the WG-registration hook target (api `SetWGRegisteredHook`, wired in hub/cmd/hub/main.go when tenantsync is on) | `blocked != ""` = waiting stage (never an error); the hook runs in a detached goroutine and must never fail registration. Scenario-A e2e test: TestPBSDR_AutoProvisionOnWGRegistration. |
| `cfg.DRTier` + offsite coupling | hub/internal/store/store.go (CustomerConfig), hub/internal/web/configs.go (applyOffsite guard) | bool | Per-customer DR-tier flag: new-customer default ON (handleConfigNewForm); offsite REFUSED without it (exact F-6 message) | One-time migration backfill initializes legacy rows from descriptor reality — never re-runs (opt-outs survive re-open; store test pins it). Form field `dr_tier` (formBool helper). |
| `parseHostCapabilities` + `capabilityView` | hub/internal/web/hosts.go | `(reportJSON) []capabilityView` | Host-page capability chips (ok/degraded/inactive) | `inactive` (agent v0.86.0) = badge-neutral, NEVER warn/error — disabled ≠ degraded; unknown future statuses fall to the degraded styling (surface, don't hide). `capabilitiesNeedDRMigration` keys the pre-v1.15.0 migration hint on pbsdr-* + "binary not found". |
### Config generation & secrets hygiene (hub/internal/configgen/) ### Config generation & secrets hygiene (hub/internal/configgen/)
+38
View File
@@ -1,5 +1,43 @@
# Felhom Hub — Changelog # Felhom Hub — Changelog
## v0.51.0 — DR-tier-by-default: per-customer flag + hands-free cascade + offsite coupling + capability chips (2026-07-12)
Hub half of the DR-tier-by-default batch (DRILL-day0-vm-2026-07-12; operator decisions 15:
capability BAKED on every install, activation is THIS flag, DR defaults ON for new customers,
identity-only escrow PARKED by policy, WG is base infrastructure). Companion: installer v1.15.0
+ agent v0.86.0 (capability `inactive` state).
- **Per-customer `dr_tier` flag** (customer_configs column + form checkbox in the renamed
"DR tier (PBS, ep0)" section, replacing the old `pbsdr_enabled` form field). NEW customers
default ON; legacy rows were initialized FROM REALITY by a one-time migration backfill
(host carries an enabled pbs_dr descriptor → ON, else OFF — never auto-cascade a legacy box;
backfill runs only on the ALTER that adds the column, so later operator opt-outs survive).
- **Cascade semantics** (scenario D): an UNMET precondition (no host / no WG peer / no
tenantsync) is no longer a save-blocking error — the flag stores the intent and the edit form
shows per-stage status (host enrolled → WG peer → descriptor provisioned → escrow present),
reusing the fail-closed guard wording. REAL provisioning failures stay fail-closed (tenantsync
error, token-exists → Re-issue).
- **Hands-free auto-provision** (scenario A): a host's FIRST WG peer registration fires
`PBSDRAutoProvision` (api `SetWGRegisteredHook`, wired when tenantsync is enabled) — a DR-ON
customer's descriptor provisions with ZERO operator steps and applies on the agent's next
desired-state tick. Detached goroutine; never delays/fails the registration response.
- **Offsite requires the DR tier** (scenario C, drill F-6 CLOSED BY POLICY): `applyOffsite`
refuses without the flag — exact message "Offsite backup requires the DR tier — enable it
first (the escrow ceremony depends on the PBS key)". No more provisioning into the
EscrowState-pending-forever dead end.
- **Capability chips on the host page** (NEW render surface): the agent's privileged-capability
self-check is now visible — ok (blue), degraded (warn / error when critical), and the agent
v0.86.0 `inactive` state as a NEUTRAL chip (disabled ≠ degraded). The pre-v1.15.0 pbsdr
"binary not found" signature surfaces the migration one-liner (never silently pretend).
`.badge-ok` finally defined in style.css (was referenced, fell back to bare `.badge`).
- Setup-tab installer copy: `hostInstallVersion` 1.12.0 → **1.15.0** (drill F-1), now gated
against the installer's SCRIPT_VERSION by `scripts/hostinstall_gates.py`.
- Tests + red-proofs (all four mutations proven red): coupling gate (guard removed → refused
case fails), flag default (default flipped → form test fails), backfill
(enabled:false ignored → disabled case fails), auto-provision (hook unhooked → scenario A
test fails); plus cascade-wait, chips render (inactive-neutral / degraded-stays / migration
hint), and the one-time-backfill-survives-reopen case.
## v0.50.0 — customer-claim password arc: code engine + email + ACK/config delivery (2026-07-12) ## v0.50.0 — customer-claim password arc: code engine + email + ACK/config delivery (2026-07-12)
Hub half of the customer-claim password gate (closes DRILL-day0-vm F-4/F-5; needs controller Hub half of the customer-claim password gate (closes DRILL-day0-vm F-4/F-5; needs controller
+4 -1
View File
@@ -393,7 +393,10 @@ func main() {
logger.Printf("[ERROR] PBS DR tenantsync disabled: %v", err) logger.Printf("[ERROR] PBS DR tenantsync disabled: %v", err)
} else { } else {
webServer.SetTenantSync(tsClient) webServer.SetTenantSync(tsClient)
logger.Printf("[INFO] PBS DR tenantsync enabled (endpoint %s, user %s)", wgAddr, wgUser) // v0.51.0 DR-tier cascade (scenario A): a host's first WG registration may be
// the descriptor's last unmet precondition — auto-provision hands-free.
apiHandler.SetWGRegisteredHook(webServer.PBSDRAutoProvision)
logger.Printf("[INFO] PBS DR tenantsync enabled (endpoint %s, user %s; WG-registration auto-provision hook armed)", wgAddr, wgUser)
} }
} else { } else {
logger.Printf("[INFO] PBS DR tenantsync disabled (key or endpoint not configured)") logger.Printf("[INFO] PBS DR tenantsync disabled (key or endpoint not configured)")
+13
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"bytes" "bytes"
"context"
"crypto/subtle" "crypto/subtle"
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
@@ -58,6 +59,13 @@ type Handler struct {
// claimEngine is the customer-claim code engine (v0.50.0). nil = claim arc disabled: no codes // claimEngine is the customer-claim code engine (v0.50.0). nil = claim arc disabled: no codes
// issued, no claim field in ACKs/configs — pre-arc behavior exactly. // issued, no claim field in ACKs/configs — pre-arc behavior exactly.
claimEngine *claim.Engine claimEngine *claim.Engine
// wgRegisteredHook (v0.51.0, DR-tier-by-default scenario A) fires after a host's FIRST WG
// peer registration — main.go wires it to the web server's PBSDRAutoProvision so a DR-ON
// customer's pbs_dr descriptor lands hands-free (WG registers → provision → the agent's next
// desired-state tick). nil = no cascade hook (pre-v0.51.0 behavior). Runs in a detached
// goroutine; must never delay or fail the registration response.
wgRegisteredHook func(ctx context.Context, customerID string)
} }
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used). // SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
@@ -65,6 +73,11 @@ func (h *Handler) SetClaimEngine(e *claim.Engine) {
h.claimEngine = e h.claimEngine = e
} }
// SetWGRegisteredHook wires the post-WG-registration cascade hook (v0.51.0; nil-safe).
func (h *Handler) SetWGRegisteredHook(f func(ctx context.Context, customerID string)) {
h.wgRegisteredHook = f
}
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can // SetLatestVersionProvider wires the registry version checker so the controller report ACK can
// advertise the latest available version (Phase 2). nil-safe (no latest_version field emitted). // advertise the latest available version (Phase 2). nil-safe (no latest_version field emitted).
func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) { func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) {
+7
View File
@@ -295,6 +295,13 @@ func (h *Handler) handleRegisterHostWG(w http.ResponseWriter, r *http.Request, p
return return
} }
syncStatus = h.syncAfterMutation(r.Context()) syncStatus = h.syncAfterMutation(r.Context())
// v0.51.0 DR-tier cascade (scenario A): a NEW tunnel peer may be the pbs_dr descriptor's
// last unmet precondition — fire the hook so a DR-ON customer provisions hands-free.
// Detached goroutine: registration must never wait on (or fail over) ep0 provisioning;
// the hook itself detaches+bounds its context and logs every outcome.
if h.wgRegisteredHook != nil && host.CustomerID != "" {
go h.wgRegisteredHook(context.WithoutCancel(r.Context()), host.CustomerID)
}
} }
h.logger.Printf("[INFO] wg registered: host=%s pubkey=%s ip=%s/32 changed=%v gen=%d sync=%s", h.logger.Printf("[INFO] wg registered: host=%s pubkey=%s ip=%s/32 changed=%v gen=%d sync=%s",
pathHostID, req.Pubkey, ip, changed, gen, syncStatus) pathHostID, req.Pubkey, ip, changed, gen, syncStatus)
+112
View File
@@ -0,0 +1,112 @@
package store
// v0.51.0 dr_tier one-time legacy backfill — "initialize from reality": on the migration that
// ADDS the column, a customer whose host already carries an ENABLED pbs_dr descriptor flips ON;
// everyone else stays OFF (never auto-cascade a legacy box). Simulated against a genuine
// pre-v0.51.0 database file (tables without dr_tier), then opened through store.New so the REAL
// migrate() path runs. Red-proof partner: make the backfill ignore `"enabled":false` vs true
// (set every pbs_dr customer ON) → the disabled-descriptor case fails.
import (
"database/sql"
"io"
"log"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestDRTierBackfill_InitializesFromReality(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
// 1. Build a PRE-v0.51.0 database: customer_configs + hosts WITHOUT dr_tier.
raw, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
mustExec := func(q string, args ...any) {
t.Helper()
if _, err := raw.Exec(q, args...); err != nil {
t.Fatalf("legacy seed: %v (%s)", err, q)
}
}
mustExec(`CREATE TABLE customer_configs (
customer_id TEXT PRIMARY KEY,
customer_name TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
retrieval_password TEXT NOT NULL,
api_key TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
)`)
mustExec(`CREATE TABLE hosts (
host_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
api_key TEXT NOT NULL,
agent_version TEXT NOT NULL DEFAULT '',
last_report_at DATETIME,
desired_json TEXT NOT NULL DEFAULT '{}',
desired_generation INTEGER NOT NULL DEFAULT 0,
dr_record_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
)`)
seedCustomer := func(id string) {
mustExec(`INSERT INTO customer_configs (customer_id, retrieval_password, api_key) VALUES (?, ?, ?)`,
id, "pw", "key-"+id)
}
seedCustomer("applied") // host carries an ENABLED descriptor → must flip ON
seedCustomer("disabled") // descriptor present but enabled:false → must stay OFF
seedCustomer("plain") // no descriptor at all → must stay OFF
seedCustomer("hostless") // no host row → must stay OFF
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
"applied-01", "applied", "h1",
`{"pbs_dr":{"enabled":true,"storage_id":"felhom-pbs","namespace":"applied"}}`)
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
"disabled-01", "disabled", "h2",
`{"pbs_dr":{"enabled":false,"storage_id":"felhom-pbs","namespace":"disabled"}}`)
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
"plain-01", "plain", "h3", `{}`)
if err := raw.Close(); err != nil {
t.Fatal(err)
}
// 2. Open through the real store — migrate() adds dr_tier and runs the one-time backfill.
st, err := New(dbPath, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New over the legacy db: %v", err)
}
defer st.Close()
want := map[string]bool{"applied": true, "disabled": false, "plain": false, "hostless": false}
for id, wantOn := range want {
cfg, err := st.GetCustomerConfig(id)
if err != nil || cfg == nil {
t.Fatalf("read %s: %v", id, err)
}
if cfg.DRTier != wantOn {
t.Errorf("customer %s: dr_tier=%v, want %v (initialize from reality)", id, cfg.DRTier, wantOn)
}
}
// 3. The backfill is ONE-TIME: a later flag change must survive a re-open (the ALTER now
// fails → no re-backfill stomping operator decisions).
cfg, _ := st.GetCustomerConfig("applied")
cfg.DRTier = false // operator opts the customer out
if err := st.SaveCustomerConfig(cfg); err != nil {
t.Fatal(err)
}
st.Close()
st2, err := New(dbPath, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
defer st2.Close()
cfg2, _ := st2.GetCustomerConfig("applied")
if cfg2.DRTier {
t.Fatal("re-open re-ran the backfill and stomped the operator's opt-out")
}
}
+72 -11
View File
@@ -142,6 +142,15 @@ func (s *Store) migrate() error {
// at 1, so an already-running box records that as its baseline on its next report without restarting. // at 1, so an already-running box records that as its baseline on its next report without restarting.
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1") s.db.Exec("ALTER TABLE customer_configs ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1")
// v0.51.0 (DR-tier-by-default): per-customer DR-tier flag. NEW customers default ON (the
// create handler sets it); the column default 0 is the LEGACY initialization — an existing
// customer is only flipped ON by the one-time backfill (initialize from reality: its host
// already carries an ENABLED pbs_dr descriptor; never auto-cascade a legacy box). The
// backfill runs exactly once — only when the ALTER actually added the column — and is
// DEFERRED to the end of migrate(): the hosts table it reads is created further down (a
// fresh DB reaches the ALTER before the CREATE).
_, drTierAlterErr := s.db.Exec("ALTER TABLE customer_configs ADD COLUMN dr_tier INTEGER NOT NULL DEFAULT 0")
// v0.15.0: hub_settings — a tiny key/value table for operator-set globals that must survive // v0.15.0: hub_settings — a tiny key/value table for operator-set globals that must survive
// restarts (currently only the global controller-version floor). The config/env DEFAULT_MIN_ // restarts (currently only the global controller-version floor). The config/env DEFAULT_MIN_
// CONTROLLER_VERSION is the FALLBACK; a row here (set via the operator UI) overrides it. // CONTROLLER_VERSION is the FALLBACK; a row here (set via the operator UI) overrides it.
@@ -546,6 +555,14 @@ func (s *Store) migrate() error {
return err return err
} }
// v0.51.0 dr_tier one-time legacy backfill — see the ALTER above; runs last so every table
// it touches (hosts, customer_configs) exists on a fresh DB too (where it finds nothing).
if drTierAlterErr == nil {
if err := s.backfillDRTierFromDescriptors(); err != nil {
return fmt.Errorf("dr_tier backfill: %w", err)
}
}
return nil return nil
} }
@@ -892,6 +909,43 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
// backfillDRTierFromDescriptors is the ONE-TIME legacy initialization for the v0.51.0 dr_tier
// column (called only when the ALTER just added it): a customer whose host already carries an
// ENABLED pbs_dr descriptor in desired_json gets dr_tier=1 — initialize from reality; everyone
// else stays 0 (never auto-cascade a legacy box). JSON is inspected in Go, not by LIKE, so field
// order/whitespace can't fool it.
func (s *Store) backfillDRTierFromDescriptors() error {
rows, err := s.db.Query(`SELECT customer_id, desired_json FROM hosts WHERE desired_json LIKE '%pbs_dr%'`)
if err != nil {
return err
}
defer rows.Close()
enabled := map[string]bool{}
for rows.Next() {
var customerID, desired string
if err := rows.Scan(&customerID, &desired); err != nil {
return err
}
var doc struct {
PBSDR *struct {
Enabled bool `json:"enabled"`
} `json:"pbs_dr"`
}
if json.Unmarshal([]byte(desired), &doc) == nil && doc.PBSDR != nil && doc.PBSDR.Enabled {
enabled[customerID] = true
}
}
if err := rows.Err(); err != nil {
return err
}
for customerID := range enabled {
if _, err := s.db.Exec(`UPDATE customer_configs SET dr_tier = 1 WHERE customer_id = ?`, customerID); err != nil {
return err
}
}
return nil
}
// CustomerConfig holds a pre-provisioned customer configuration. // CustomerConfig holds a pre-provisioned customer configuration.
type CustomerConfig struct { type CustomerConfig struct {
CustomerID string CustomerID string
@@ -908,8 +962,14 @@ type CustomerConfig struct {
// ConfigVersion is the monotonic config counter (bumped on every SaveCustomerConfig). The report // ConfigVersion is the monotonic config counter (bumped on every SaveCustomerConfig). The report
// ACK advertises it; the controller re-pulls + self-restarts when it changes. Never a YAML hash. // ACK advertises it; the controller re-pulls + self-restarts when it changes. Never a YAML hash.
ConfigVersion int ConfigVersion int
CreatedAt time.Time // DRTier (v0.51.0, DR-tier-by-default) is the per-customer DR-tier flag — the operator INTENT
UpdatedAt time.Time // the pbsdr cascade converges toward. Default ON for NEW customers (create handler); legacy
// rows were initialized from reality by the one-time backfill (enabled descriptor → ON).
// OFF = zero Felhom-side cost (no ep0 namespace is provisioned) and offsite provisioning is
// refused (the escrow ceremony depends on the PBS key — drill F-6, closed by policy).
DRTier bool
CreatedAt time.Time
UpdatedAt time.Time
} }
// SaveCustomerConfig creates or updates a customer configuration. Every save BUMPS config_version // SaveCustomerConfig creates or updates a customer configuration. Every save BUMPS config_version
@@ -921,8 +981,8 @@ type CustomerConfig struct {
func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error { func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO customer_configs (customer_id, customer_name, domain, email, INSERT INTO customer_configs (customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, min_controller_version, config_version, updated_at) retrieval_password, api_key, config_json, min_controller_version, dr_tier, config_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET ON CONFLICT(customer_id) DO UPDATE SET
customer_name = excluded.customer_name, customer_name = excluded.customer_name,
domain = excluded.domain, domain = excluded.domain,
@@ -931,10 +991,11 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
api_key = excluded.api_key, api_key = excluded.api_key,
config_json = excluded.config_json, config_json = excluded.config_json,
min_controller_version = excluded.min_controller_version, min_controller_version = excluded.min_controller_version,
dr_tier = excluded.dr_tier,
config_version = customer_configs.config_version + 1, config_version = customer_configs.config_version + 1,
updated_at = datetime('now')`, updated_at = datetime('now')`,
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email, cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion, cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion, cfg.DRTier,
) )
return err return err
} }
@@ -945,12 +1006,12 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
var createdAt, updatedAt string var createdAt, updatedAt string
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email, SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
FROM customer_configs WHERE customer_id = ?`, FROM customer_configs WHERE customer_id = ?`,
customerID, customerID,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
&cfg.ConfigVersion, &createdAt, &updatedAt) &cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -1000,7 +1061,7 @@ func (s *Store) ConsumeOneTimeSecret(customerID string) (string, error) {
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT customer_id, customer_name, domain, email, SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
FROM customer_configs ORDER BY customer_id`) FROM customer_configs ORDER BY customer_id`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1013,7 +1074,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
var createdAt, updatedAt string var createdAt, updatedAt string
if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
&cfg.ConfigVersion, &createdAt, &updatedAt); err != nil { &cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt); err != nil {
return nil, err return nil, err
} }
cfg.CreatedAt = parseSQLiteTime(createdAt) cfg.CreatedAt = parseSQLiteTime(createdAt)
@@ -1036,12 +1097,12 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error
var createdAt, updatedAt string var createdAt, updatedAt string
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email, SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
FROM customer_configs WHERE api_key = ?`, FROM customer_configs WHERE api_key = ?`,
apiKey, apiKey,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
&cfg.ConfigVersion, &createdAt, &updatedAt) &cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
+25 -3
View File
@@ -473,13 +473,15 @@ func (s *Server) configFormData(r *http.Request, isNew bool, cfg *store.Customer
ActiveNav: "configs", ActiveNav: "configs",
Error: errMsg, Error: errMsg,
CSRFField: s.csrfField(r), CSRFField: s.csrfField(r),
PBSDR: s.pbsDRViewFor(cfg.CustomerID), PBSDR: s.pbsDRViewFor(cfg.CustomerID, cfg.DRTier),
} }
} }
// handleConfigNewForm shows the form to create a new customer config. // handleConfigNewForm shows the form to create a new customer config. DRTier starts ON —
// DR-tier-by-default is the new-customer default (operator decision 2026-07-12 #2); opting out
// is the per-customer exception.
func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
s.renderConfigForm(w, r, true, &store.CustomerConfig{}, nil, "") s.renderConfigForm(w, r, true, &store.CustomerConfig{DRTier: true}, nil, "")
} }
// handleConfigCreate processes the form submission to create a new config. // handleConfigCreate processes the form submission to create a new config.
@@ -534,6 +536,9 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
RetrievalPassword: retrievalPassword, RetrievalPassword: retrievalPassword,
APIKey: apiKey, APIKey: apiKey,
ConfigJSON: configJSON, ConfigJSON: configJSON,
// v0.51.0: the DR-tier flag (the form checkbox defaults ON for new customers). Set
// BEFORE applyOffsite — offsite provisioning is refused without the DR tier (F-6).
DRTier: formBool(r, "dr_tier"),
} }
// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config. // Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
@@ -588,6 +593,9 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name")) cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name"))
cfg.Domain = strings.TrimSpace(r.FormValue("domain")) cfg.Domain = strings.TrimSpace(r.FormValue("domain"))
cfg.Email = strings.TrimSpace(r.FormValue("email")) cfg.Email = strings.TrimSpace(r.FormValue("email"))
// v0.51.0: the DR-tier flag — set BEFORE applyOffsite (offsite requires the tier) and
// applyPBSDR (which converges the descriptor toward it).
cfg.DRTier = formBool(r, "dr_tier")
// Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is // Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is
// the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is // the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is
@@ -1040,6 +1048,13 @@ func (s *Server) applyOffsite(ctx context.Context, r *http.Request, cfg *store.C
if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" { if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" {
return nil // not enabled → disabled by omission return nil // not enabled → disabled by omission
} }
// v0.51.0 (decision 3, drill F-6 closed by policy): offsite app backup REQUIRES the DR tier —
// fork-4 needs the escrow ceremony, and the ceremony hard-requires the PBS key K. Without the
// tier, provisioning would run straight into the F-6 dead end (EscrowState pending forever).
// The guard reads cfg.DRTier, which the handlers set from the form BEFORE calling here.
if !cfg.DRTier {
return fmt.Errorf("Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)")
}
if s.offsite == nil { if s.offsite == nil {
return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)") return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)")
} }
@@ -1110,6 +1125,13 @@ func buildConfigJSON(r *http.Request) string {
return string(data) return string(data)
} }
// formBool reads a checkbox-style form value ("on"/"true" → true; anything else, incl. absent,
// → false).
func formBool(r *http.Request, name string) bool {
v := r.FormValue(name)
return v == "on" || v == "true"
}
// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer. The Cloudflare // handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer. The Cloudflare
// WAF rules ARE the geo enforcement, so removing them disables geo-restriction. This is a hub→Cloudflare // WAF rules ARE the geo enforcement, so removing them disables geo-restriction. This is a hub→Cloudflare
// call (NOT into the box) and stays. The old secondary inbound notify to the controller // call (NOT into the box) and stays. The old secondary inbound notify to the controller
+1 -1
View File
@@ -70,7 +70,7 @@ func TestConfigUpdate_DebugSurvivesRebuild_OffsiteUntouched(t *testing.T) {
} }
// 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet. // 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet.
const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50" const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50"
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id) s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id)
if w.Code != http.StatusSeeOther { if w.Code != http.StatusSeeOther {
+3 -1
View File
@@ -67,7 +67,9 @@ func TestApplyOffsite_ClientDisconnectMidProvision(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, r := httptest.NewRequest(http.MethodPost,
"/configs/cust-web/edit?offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50", nil) "/configs/cust-web/edit?offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50", nil)
r = r.WithContext(reqCtx) r = r.WithContext(reqCtx)
cfg := &store.CustomerConfig{CustomerID: "cust-web", ConfigJSON: "{}"} // DRTier: offsite requires the DR tier since v0.51.0 (the F-6 coupling) — this test is about
// the disconnect atom, so the gate is satisfied.
cfg := &store.CustomerConfig{CustomerID: "cust-web", ConfigJSON: "{}", DRTier: true}
err := s.applyOffsite(r.Context(), r, cfg) err := s.applyOffsite(r.Context(), r, cfg)
+2 -1
View File
@@ -225,10 +225,11 @@ func TestCustomerActions_RedirectAnchors(t *testing.T) {
cfg := &store.CustomerConfig{ cfg := &store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}", RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}",
DRTier: true, // offsite requires the DR tier since v0.51.0 (the F-6 coupling)
} }
// Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze // Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze
// find exactly one labelled sub-account. // find exactly one labelled sub-account.
r := postForm("/configs/c1/edit", "offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50") r := postForm("/configs/c1/edit", "dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50")
if err := s.applyOffsite(r.Context(), r, cfg); err != nil { if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
+189
View File
@@ -0,0 +1,189 @@
package web
// DR-tier-by-default (v0.51.0) — the three §10 contracts of the batch spec:
// 1. coupling gate: offsite provisioning REQUIRES the DR tier (drill F-6 closed by policy) —
// exact refusal message; DR ON proceeds to the existing path.
// Red-proof partner: remove the guard in applyOffsite → the refused-case test FAILS.
// 2. flag default: the NEW-customer form renders the DR checkbox CHECKED (default ON);
// a legacy customer without an applied descriptor stays OFF (backfill covered store-side).
// Red-proof partner: flip handleConfigNewForm's DRTier default → the form test FAILS.
// 3. capability chips: the host page renders the agent's capability list; "inactive" gets the
// NEUTRAL badge (disabled ≠ degraded), degraded stays warn/error, and the pre-v1.15.0
// "binary not found" pbsdr signature surfaces the migration hint.
import (
"io"
"log"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
const offsiteRequiresDRMsg = "Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)"
// Scenario C — the coupling. DR OFF → offsite refused (exact message, Hetzner NEVER touched);
// DR ON → proceeds into the existing provisioning path.
func TestOffsiteRequiresDRTier(t *testing.T) {
newOffsiteServer := func(t *testing.T) (*Server, *store.Store, *hetznerapi.Fake) {
s, st := newTestServer(t)
fakeAPI := hetznerapi.NewFake()
s.SetOffsiteProvisioner(&offsite.Provisioner{
API: fakeAPI, Store: st, Scanner: webTestScanner{},
PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0),
})
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "cpl", CustomerName: "Coupling", Domain: "cpl.hu",
RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
}); err != nil {
t.Fatal(err)
}
return s, st, fakeAPI
}
post := func(s *Server, form url.Values) *httptest.ResponseRecorder {
form.Set("customer_name", "Coupling")
form.Set("domain", "cpl.hu")
req := httptest.NewRequest("POST", "/configs/cpl", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleConfigUpdate(rr, req, "cpl")
return rr
}
t.Run("DR OFF refuses with the exact message", func(t *testing.T) {
s, st, _ := newOffsiteServer(t)
rr := post(s, url.Values{"offsite_enabled": {"on"}, "offsite_type": {"shared"}, "offsite_quota_gb": {"50"}})
if rr.Code != 502 {
t.Fatalf("offsite with DR OFF = %d (%s), want 502 refusal", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), offsiteRequiresDRMsg) {
t.Fatalf("refusal message = %q, want the exact spec wording %q", rr.Body.String(), offsiteRequiresDRMsg)
}
// The F-6 dead end never starts: no descriptor, no one-time secret, nothing saved.
cfg, _ := st.GetCustomerConfig("cpl")
if strings.Contains(cfg.ConfigJSON, "offsite") {
t.Errorf("offsite descriptor written despite the refusal: %s", cfg.ConfigJSON)
}
if _, err := st.ConsumeOneTimeSecret("cpl"); err == nil {
t.Error("a one-time offsite secret exists despite the refusal")
}
})
t.Run("DR ON proceeds", func(t *testing.T) {
s, st, _ := newOffsiteServer(t)
rr := post(s, url.Values{"dr_tier": {"on"}, "offsite_enabled": {"on"}, "offsite_type": {"shared"}, "offsite_quota_gb": {"50"}})
if rr.Code != 303 {
t.Fatalf("offsite with DR ON = %d (%s), want 303", rr.Code, rr.Body.String())
}
cfg, _ := st.GetCustomerConfig("cpl")
if !strings.Contains(cfg.ConfigJSON, `"offsite"`) {
t.Fatalf("offsite descriptor missing after a DR-ON provision: %s", cfg.ConfigJSON)
}
})
}
// Flag default: the NEW-customer form starts with the DR-tier checkbox CHECKED (decision 2 —
// DR is the default; opting out is the exception). The stored default for a customer saved
// WITHOUT the checkbox stays false (legacy shape — asserted store-side with the backfill).
func TestDRTier_NewCustomerFormDefaultsOn(t *testing.T) {
s, _ := newTestServer(t)
req := httptest.NewRequest("GET", "/configs/new", nil)
rr := httptest.NewRecorder()
s.handleConfigNewForm(rr, req)
out := rr.Body.String()
if !strings.Contains(out, `name="dr_tier" checked`) {
t.Fatal("the new-customer form does not default the DR tier ON")
}
}
// The host page's capability section (v0.51.0): inactive = neutral badge, degraded = warn/error,
// and the pre-v1.15.0 pbsdr "binary not found" signature surfaces the migration hint. Also the
// honest-reporting edge (§8): degraded is NEVER re-labeled by the hub.
func TestHostDetail_CapabilityChips(t *testing.T) {
reportFor := func(status, reason string) string {
return `{"host":{"cpu_percent":1},"capabilities":[
{"name":"pbsdr-create","feature":"PBS DR storage-entry create","critical":false,"status":"` + status + `","reason":"` + reason + `"},
{"name":"drive-bind","feature":"drive attach","critical":true,"status":"ok"}
]}`
}
seed := func(t *testing.T, report string) (*Server, *store.Host) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "capc", RetrievalPassword: "pw", APIKey: "k",
}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "capc-01", CustomerID: "capc", APIKey: "h"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostReport("capc-01", "capc", []byte(report), store.HostReportDenorm{}); err != nil {
t.Fatal(err)
}
host, err := st.GetHost("capc-01")
if err != nil || host == nil {
t.Fatal("seed host read")
}
return s, host
}
render := func(t *testing.T, s *Server, host *store.Host) string {
data := s.hostDetailData(host, httptest.NewRequest("GET", "/hosts/capc-01", nil))
var sb strings.Builder
if err := s.templates.ExecuteTemplate(&sb, "host_detail_body", data); err != nil {
t.Fatalf("render: %v", err)
}
return sb.String()
}
t.Run("inactive renders the neutral chip, not red", func(t *testing.T) {
s, host := seed(t, reportFor("inactive", "disabled by configuration"))
out := render(t, s, host)
if !strings.Contains(out, `badge badge-neutral">inactive`) {
t.Fatalf("inactive chip not neutral:\n%s", section(out, "Capabilities"))
}
if strings.Contains(out, `badge-error">inactive`) || strings.Contains(out, `badge-warn">inactive`) {
t.Error("inactive rendered as an alarm state — disabled must not look broken")
}
if strings.Contains(out, "predates the uniform DR plumbing") {
t.Error("migration hint shown for a healthy inactive box")
}
})
t.Run("degraded binary-not-found stays degraded + migration hint", func(t *testing.T) {
s, host := seed(t, reportFor("degraded", "binary not found"))
out := render(t, s, host)
if !strings.Contains(out, `badge badge-warn">degraded`) {
t.Fatalf("pbsdr degraded chip missing/re-labeled:\n%s", section(out, "Capabilities"))
}
if !strings.Contains(out, "predates the uniform DR plumbing") {
t.Error("migration hint missing for the pre-v1.15.0 signature (never silently pretend)")
}
})
t.Run("ok renders ok; critical is marked", func(t *testing.T) {
s, host := seed(t, reportFor("ok", ""))
out := render(t, s, host)
if !strings.Contains(out, `badge badge-ok">ok`) {
t.Fatalf("ok chip missing:\n%s", section(out, "Capabilities"))
}
if !strings.Contains(out, `badge badge-neutral">critical`) {
t.Error("critical marker missing on drive-bind")
}
})
}
// section extracts a readable slice of rendered HTML around a heading for failure messages.
func section(html, heading string) string {
i := strings.Index(html, heading)
if i < 0 {
return "(heading not found)"
}
end := i + 1500
if end > len(html) {
end = len(html)
}
return html[i:end]
}
+84
View File
@@ -107,6 +107,70 @@ func parseHostVitals(reportJSON string) hostVitals {
return v return v
} }
// capabilityView is one privileged-capability chip on the host detail page (v0.51.0 — the agent
// has reported these since v0.44.0; the hub now renders them). Class maps the agent's status to
// a badge: ok → badge-ok, degraded → badge-fail (critical) / badge-warn, inactive → badge-neutral
// (disabled ≠ degraded — the DR-tier-by-default rule; agent v0.86.0 emits "inactive").
type capabilityView struct {
Name string
Feature string
Status string
Reason string
Critical bool
Class string
}
// parseHostCapabilities extracts the capabilities array from a host-report body. Missing or
// malformed → nil (the "waiting for first report" path — the section hides).
func parseHostCapabilities(reportJSON string) []capabilityView {
if reportJSON == "" {
return nil
}
var body struct {
Capabilities []struct {
Name string `json:"name"`
Feature string `json:"feature"`
Critical bool `json:"critical"`
Status string `json:"status"`
Reason string `json:"reason"`
} `json:"capabilities"`
}
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
return nil
}
out := make([]capabilityView, 0, len(body.Capabilities))
for _, c := range body.Capabilities {
v := capabilityView{Name: c.Name, Feature: c.Feature, Status: c.Status, Reason: c.Reason, Critical: c.Critical}
switch c.Status {
case "ok":
v.Class = "badge-ok"
case "inactive":
v.Class = "badge-neutral"
default: // degraded (or an unknown future status — surface it, never hide it)
if c.Critical {
v.Class = "badge-error"
} else {
v.Class = "badge-warn"
}
}
out = append(out, v)
}
return out
}
// capabilitiesNeedDRMigration reports whether any pbsdr-* capability is degraded with the
// pre-v1.15.0 signature ("binary not found") — the box predates the uniform DR plumbing. The
// host page then surfaces the migration one-liner instead of silently pretending (§8 of the
// DR-by-default spec).
func capabilitiesNeedDRMigration(caps []capabilityView) bool {
for _, c := range caps {
if strings.HasPrefix(c.Name, "pbsdr-") && c.Status == "degraded" && c.Reason == "binary not found" {
return true
}
}
return false
}
// storageTargetView is the rich per-drive row the host-detail Storage Targets table renders: // storageTargetView is the rich per-drive row the host-detail Storage Targets table renders:
// fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's // fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's
// storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view // storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view
@@ -296,6 +360,24 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
vitals := parseHostVitals(reportJSON) vitals := parseHostVitals(reportJSON)
storageTargets := parseHostStorageTargets(reportJSON) storageTargets := parseHostStorageTargets(reportJSON)
sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name }) sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name })
// v0.51.0: capability chips — non-ok first (what the operator needs to see), then by name.
capabilities := parseHostCapabilities(reportJSON)
sort.SliceStable(capabilities, func(i, j int) bool {
rank := func(s string) int {
switch s {
case "degraded":
return 0
case "inactive":
return 1
default:
return 2
}
}
if a, b := rank(capabilities[i].Status), rank(capabilities[j].Status); a != b {
return a < b
}
return capabilities[i].Name < capabilities[j].Name
})
// DR / backup presence — booleans only, never the opaque blobs. // DR / backup presence — booleans only, never the opaque blobs.
drBundle, _ := s.store.GetHostDRBundle(host.HostID) drBundle, _ := s.store.GetHostDRBundle(host.HostID)
@@ -320,6 +402,8 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
"GuestRunning": guestRunning, "GuestRunning": guestRunning,
"GuestTotal": len(guests), "GuestTotal": len(guests),
"StorageTargets": storageTargets, "StorageTargets": storageTargets,
"Capabilities": capabilities,
"NeedsDRMigration": capabilitiesNeedDRMigration(capabilities),
"DRPresent": drBundle != nil, "DRPresent": drBundle != nil,
"EscrowPresent": escrow != nil, "EscrowPresent": escrow != nil,
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL). // v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
+103 -33
View File
@@ -91,11 +91,18 @@ func mergePBSDR(desiredJSON string, d *pbsDRDescriptor) (string, error) {
return string(out), nil return string(out), nil
} }
// applyPBSDR handles the config form's PBS DR section on create/update. Called BEFORE // applyPBSDR handles the config form's DR-tier section on create/update. Called BEFORE
// SaveCustomerConfig (fail-closed: an error must abort the whole save). The descriptor lives in // SaveCustomerConfig (fail-closed on REAL provisioning failures: those abort the whole save).
// the HOST's desired_json — ConfigJSON never carries pbs_dr (single source of truth). // The descriptor lives in the HOST's desired_json — ConfigJSON never carries pbs_dr.
//
// v0.51.0 (DR-tier-by-default): the switch is cfg.DRTier (the stored per-customer flag, set from
// the form by the handler) — and an UNMET PRECONDITION is no longer an error. New customers
// default the flag ON before any host exists; the cascade converges later: host enrolls → WG
// peer registers (the api hook auto-provisions, scenario A) → descriptor applies on the agent's
// next tick. The form save stores the intent and reports the honest waiting stage; only an
// actual provisioning FAILURE (tenantsync error, token mismatch…) still fails the save.
func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error { func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
enabled := r.FormValue("pbsdr_enabled") == "on" || r.FormValue("pbsdr_enabled") == "true" enabled := cfg.DRTier
storageID := strings.TrimSpace(r.FormValue("pbsdr_storage_id")) storageID := strings.TrimSpace(r.FormValue("pbsdr_storage_id"))
if storageID == "" { if storageID == "" {
storageID = defaultPBSStorageID storageID = defaultPBSStorageID
@@ -106,10 +113,10 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
return fmt.Errorf("pbsdr: host lookup: %w", err) return fmt.Errorf("pbsdr: host lookup: %w", err)
} }
if host == nil { if host == nil {
if !enabled { if enabled {
return nil // nothing enrolled, nothing enabled — nothing to do s.logger.Printf("[INFO] pbsdr: DR tier ON for %s, no host enrolled yet — the descriptor applies once the cascade is ready (host → WG peer → apply)", cfg.CustomerID)
} }
return fmt.Errorf("no host enrolled for customer %s yet — the PBS DR tier needs the enrolled host", cfg.CustomerID) return nil // the flag is stored; nothing host-side to converge yet
} }
cur := readPBSDR(host.DesiredJSON) cur := readPBSDR(host.DesiredJSON)
@@ -156,20 +163,38 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
return nil return nil
} }
// Fresh provision. Fail-closed preconditions first. // Fresh provision — the shared atom (also fired by the WG-registration hook, scenario A).
// An unmet precondition is an honest waiting stage, never a save-blocking error.
blocked, err := s.pbsdrProvisionAtom(ctx, cfg.CustomerID, host, storageID)
if err != nil {
return err
}
if blocked != "" {
s.logger.Printf("[INFO] pbsdr: DR tier ON for %s — waiting: %s", cfg.CustomerID, blocked)
}
return nil
}
// pbsdrProvisionAtom runs the fresh-provision cascade step for a host with NO provisioned
// descriptor yet: preconditions → tenantsync provision → consume-once secret → descriptor merge
// + generation bump. Returns (blockedReason, err): a non-empty blockedReason is an HONEST
// waiting stage (tunnel peer absent, endpoint unset, tenantsync unconfigured — the cascade
// retries at its next opportunity); err is a REAL provisioning failure and must stay loud
// (fail-closed on the form path, error-logged on the hook path).
func (s *Server) pbsdrProvisionAtom(ctx context.Context, customerID string, host *store.Host, storageID string) (string, error) {
if s.tenantsync == nil { if s.tenantsync == nil {
return fmt.Errorf("PBS DR provisioning is not configured on this hub (no tenantsync key)") return "PBS DR provisioning is not configured on this hub (no tenantsync key)", nil
} }
if _, err := s.store.GetWGPeerForHost(host.HostID); err == sql.ErrNoRows { if _, err := s.store.GetWGPeerForHost(host.HostID); err == sql.ErrNoRows {
return fmt.Errorf("host %s has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier", host.HostID) return fmt.Sprintf("host %s has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier", host.HostID), nil
} else if err != nil { } else if err != nil {
return fmt.Errorf("pbsdr: wg peer lookup: %w", err) return "", fmt.Errorf("pbsdr: wg peer lookup: %w", err)
} }
ep, err := s.store.GetWGEndpoint() ep, err := s.store.GetWGEndpoint()
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return fmt.Errorf("wg endpoint not configured — register ep0 before the PBS DR tier") return "wg endpoint not configured — register ep0 before the PBS DR tier", nil
} else if err != nil { } else if err != nil {
return fmt.Errorf("pbsdr: wg endpoint read: %w", err) return "", fmt.Errorf("pbsdr: wg endpoint read: %w", err)
} }
// Detach from the request context (the applyOffsite F1 precedent): once provisioning starts, // Detach from the request context (the applyOffsite F1 precedent): once provisioning starts,
@@ -178,19 +203,19 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
// manual re-issue). The absolute timeout still bounds a hung SSH exec. // manual re-issue). The absolute timeout still bounds a hung SSH exec.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel() defer cancel()
res, err := s.tenantsync.Provision(ctx, cfg.CustomerID) res, err := s.tenantsync.Provision(ctx, customerID)
if errors.Is(err, tenantsync.ErrTokenExists) { if errors.Is(err, tenantsync.ErrTokenExists) {
// ep0 has a token but the hub has no descriptor — state mismatch (lost hub state or a // ep0 has a token but the hub has no descriptor — state mismatch (lost hub state or a
// half-torn earlier attempt). Never silently re-key: the operator decides via Re-issue. // half-torn earlier attempt). Never silently re-key: the operator decides via Re-issue.
return fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", cfg.CustomerID) return "", fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", customerID)
} }
if err != nil { if err != nil {
return err return "", err
} }
// The atom: secret first (consume-once custody), then descriptor+bump (the agent's signal). // The atom: secret first (consume-once custody), then descriptor+bump (the agent's signal).
if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil { if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil {
return fmt.Errorf("pbsdr: store one-time token secret: %w", err) return "", fmt.Errorf("pbsdr: store one-time token secret: %w", err)
} }
desc := &pbsDRDescriptor{ desc := &pbsDRDescriptor{
Enabled: true, Enabled: true,
@@ -203,15 +228,48 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
} }
merged, err := mergePBSDR(host.DesiredJSON, desc) merged, err := mergePBSDR(host.DesiredJSON, desc)
if err != nil { if err != nil {
return err return "", err
} }
gen, err := s.store.SetHostDesired(host.HostID, []byte(merged)) gen, err := s.store.SetHostDesired(host.HostID, []byte(merged))
if err != nil { if err != nil {
return fmt.Errorf("pbsdr: desired-state write: %w", err) return "", fmt.Errorf("pbsdr: desired-state write: %w", err)
} }
s.logger.Printf("[INFO] pbsdr provisioned for %s (host %s, ns %s, token_id %s, gen %d; secret stored consume-once, withheld from logs)", s.logger.Printf("[INFO] pbsdr provisioned for %s (host %s, ns %s, token_id %s, gen %d; secret stored consume-once, withheld from logs)",
cfg.CustomerID, host.HostID, res.Namespace, res.TokenID, gen) customerID, host.HostID, res.Namespace, res.TokenID, gen)
return nil return "", nil
}
// PBSDRAutoProvision is the WG-registration hook (v0.51.0, scenario A): the api handler calls it
// (via the main.go closure) right after a host's first WG peer registration. If the customer's
// DR-tier flag is ON and no descriptor is provisioned yet, it runs the provisioning atom — the
// descriptor then applies on the agent's next desired-state tick with ZERO operator steps.
// Never fails the caller: outcomes are logged (an error here is retried by the next config save).
func (s *Server) PBSDRAutoProvision(ctx context.Context, customerID string) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil || cfg == nil || !cfg.DRTier {
return
}
host, err := s.store.GetHostByCustomer(customerID)
if err != nil || host == nil {
return
}
if cur := readPBSDR(host.DesiredJSON); cur != nil && cur.Namespace != "" {
return // already provisioned — nothing to converge here
}
storageID := defaultPBSStorageID
if cur := readPBSDR(host.DesiredJSON); cur != nil && cur.StorageID != "" {
storageID = cur.StorageID
}
blocked, err := s.pbsdrProvisionAtom(ctx, customerID, host, storageID)
if err != nil {
s.logger.Printf("[ERROR] pbsdr auto-provision for %s (WG-registration hook): %v — save the customer config to retry", customerID, err)
return
}
if blocked != "" {
s.logger.Printf("[INFO] pbsdr auto-provision for %s still waiting: %s", customerID, blocked)
return
}
s.logger.Printf("[INFO] pbsdr auto-provisioned for %s on WG registration (hands-free cascade)", customerID)
} }
// handlePBSDRReissue explicitly re-keys the customer's ep0 PBS token (the offsite F4 precedent): // handlePBSDRReissue explicitly re-keys the customer's ep0 PBS token (the offsite F4 precedent):
@@ -269,22 +327,28 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther) http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther)
} }
// pbsDRView is the config form's render model for the PBS DR section. // pbsDRView is the config form's render model for the DR-tier section, including the v0.51.0
// cascade stages (host → WG peer → descriptor → escrow) — one flag, ordered rollout, honest
// intermediate states (scenario D).
type pbsDRView struct { type pbsDRView struct {
Supported bool // tenantsync configured on this hub Supported bool // tenantsync configured on this hub
NoHost bool // no enrolled host for the customer (enable would fail-closed) NoHost bool // no enrolled host for the customer (cascade stage 1 waiting)
HostID string HostID string
Enabled bool DRTier bool // the stored per-customer flag (operator INTENT)
StorageID string // current or the default Enabled bool // descriptor enabled (host-side reality)
Provisioned bool StorageID string // current or the default
Namespace string Provisioned bool
TokenID string Namespace string
TokenID string
WGPeer bool // cascade stage 2: the host has registered its tunnel peer
EscrowPresent bool // cascade stage 4: the ceremony ran (blob in custody)
} }
// pbsDRViewFor loads the section state for the form. Read-only; every error degrades to a // pbsDRViewFor loads the section state for the form. Read-only; every error degrades to a
// zero-ish view (the section still renders). // zero-ish view (the section still renders). drTier is the customer's stored flag (the caller
func (s *Server) pbsDRViewFor(customerID string) pbsDRView { // has the config; "" customerID = the create form).
v := pbsDRView{Supported: s.tenantsync != nil, StorageID: defaultPBSStorageID} func (s *Server) pbsDRViewFor(customerID string, drTier bool) pbsDRView {
v := pbsDRView{Supported: s.tenantsync != nil, StorageID: defaultPBSStorageID, DRTier: drTier}
if customerID == "" { if customerID == "" {
v.NoHost = true v.NoHost = true
return v return v
@@ -295,6 +359,12 @@ func (s *Server) pbsDRViewFor(customerID string) pbsDRView {
return v return v
} }
v.HostID = host.HostID v.HostID = host.HostID
if _, err := s.store.GetWGPeerForHost(host.HostID); err == nil {
v.WGPeer = true
}
if escrow, err := s.store.GetHostEscrow(host.HostID); err == nil && escrow != nil {
v.EscrowPresent = true
}
if d := readPBSDR(host.DesiredJSON); d != nil { if d := readPBSDR(host.DesiredJSON); d != nil {
v.Enabled = d.Enabled v.Enabled = d.Enabled
if d.StorageID != "" { if d.StorageID != "" {
+98 -24
View File
@@ -118,7 +118,7 @@ func TestPBSDR_ProvisionHappyPath(t *testing.T) {
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"} fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
s, st, logBuf := newPBSDRServer(t, fake) s, st, logBuf := newPBSDRServer(t, fake)
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}, "pbsdr_storage_id": {"felhom-pbs"}}) rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}, "pbsdr_storage_id": {"felhom-pbs"}})
if rr.Code != 303 { if rr.Code != 303 {
t.Fatalf("save = %d (%s), want 303", rr.Code, rr.Body.String()) t.Fatalf("save = %d (%s), want 303", rr.Code, rr.Body.String())
} }
@@ -161,7 +161,7 @@ func TestPBSDR_ResaveIsNoOp(t *testing.T) {
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"} fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
s, st, _ := newPBSDRServer(t, fake) s, st, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != nil { if _, err := st.ConsumeHostPBSSecret("peti-01"); err != nil {
t.Fatalf("first secret consume: %v", err) t.Fatalf("first secret consume: %v", err)
} }
@@ -169,7 +169,7 @@ func TestPBSDR_ResaveIsNoOp(t *testing.T) {
// Idempotent re-save: NO re-key, NO second secret row, NO spurious generation bump. // Idempotent re-save: NO re-key, NO second secret row, NO spurious generation bump.
// (Red-proof: dropping the already-provisioned short-circuit in applyPBSDR re-runs // (Red-proof: dropping the already-provisioned short-circuit in applyPBSDR re-runs
// Provision → calls=2 + a fresh consumable secret → FAIL.) // Provision → calls=2 + a fresh consumable secret → FAIL.)
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 303 { if rr.Code != 303 {
t.Fatalf("re-save = %d, want 303 (already-provisioned is success-no-op)", rr.Code) t.Fatalf("re-save = %d, want 303 (already-provisioned is success-no-op)", rr.Code)
} }
@@ -184,31 +184,43 @@ func TestPBSDR_ResaveIsNoOp(t *testing.T) {
} }
} }
// v0.51.0 (DR-tier-by-default): an UNMET PRECONDITION is an honest waiting stage, not a
// save-blocking error — the flag is stored and the cascade converges later. A REAL provisioning
// failure stays fail-closed. Red-proof partner for the coupling: drop the WG-peer stage check in
// pbsdrProvisionAtom → "no WG peer waits" fails (provision would be reached).
func TestPBSDR_FailClosed(t *testing.T) { func TestPBSDR_FailClosed(t *testing.T) {
t.Run("no WG peer", func(t *testing.T) { t.Run("no WG peer waits (stage, not error)", func(t *testing.T) {
fake := &fakeTenancy{secret: "S"} fake := &fakeTenancy{secret: "S"}
s, st, _ := newPBSDRServer(t, fake) s, st, logBuf := newPBSDRServer(t, fake)
// Kill the precondition: the host has never registered a WG key. // Kill the precondition: the host has never registered a WG key.
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil { if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
t.Fatalf("remove seed peer: %v", err) t.Fatalf("remove seed peer: %v", err)
} }
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 502 { if rr.Code != 303 {
t.Fatalf("save without WG peer = %d, want 502", rr.Code) t.Fatalf("save without WG peer = %d (%s), want 303 — the flag stores, the cascade waits", rr.Code, rr.Body.String())
} }
if fake.provisionCalls != 0 { if fake.provisionCalls != 0 {
t.Errorf("provision reached despite missing peer (%d calls)", fake.provisionCalls) t.Errorf("provision reached despite missing peer (%d calls)", fake.provisionCalls)
} }
h, _ := st.GetHost("peti-01") h, _ := st.GetHost("peti-01")
if readPBSDR(h.DesiredJSON) != nil || h.DesiredGeneration != 0 { if readPBSDR(h.DesiredJSON) != nil || h.DesiredGeneration != 0 {
t.Errorf("fail-closed violated: desc=%v gen=%d", readPBSDR(h.DesiredJSON), h.DesiredGeneration) t.Errorf("descriptor written while waiting: desc=%v gen=%d", readPBSDR(h.DesiredJSON), h.DesiredGeneration)
}
// The flag itself IS saved (the intent survives the wait).
cfg, _ := st.GetCustomerConfig("peti")
if !cfg.DRTier {
t.Error("DR flag not stored while the cascade waits")
}
if !strings.Contains(logBuf.String(), "has not reported a WG key yet") {
t.Error("the waiting stage (guard wording) was not logged")
} }
}) })
t.Run("tenantsync error", func(t *testing.T) { t.Run("tenantsync error", func(t *testing.T) {
fake := &fakeTenancy{err: errors.New("ssh boom")} fake := &fakeTenancy{err: errors.New("ssh boom")}
s, st, _ := newPBSDRServer(t, fake) s, st, _ := newPBSDRServer(t, fake)
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 502 { if rr.Code != 502 {
t.Fatalf("save with tenantsync error = %d, want 502", rr.Code) t.Fatalf("save with tenantsync error = %d, want 502", rr.Code)
} }
@@ -229,7 +241,7 @@ func TestPBSDR_FailClosed(t *testing.T) {
t.Run("token_exists points at re-issue", func(t *testing.T) { t.Run("token_exists points at re-issue", func(t *testing.T) {
fake := &fakeTenancy{err: tenantsync.ErrTokenExists} fake := &fakeTenancy{err: tenantsync.ErrTokenExists}
s, st, _ := newPBSDRServer(t, fake) s, st, _ := newPBSDRServer(t, fake)
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue") { if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue") {
t.Fatalf("token_exists = %d (%s), want 502 mentioning Re-issue", rr.Code, rr.Body.String()) t.Fatalf("token_exists = %d (%s), want 502 mentioning Re-issue", rr.Code, rr.Body.String())
} }
@@ -239,11 +251,11 @@ func TestPBSDR_FailClosed(t *testing.T) {
_ = st _ = st
}) })
t.Run("not configured", func(t *testing.T) { t.Run("not configured waits (stage, not error)", func(t *testing.T) {
s, st, _ := newPBSDRServer(t, nil) // no tenantsync s, st, _ := newPBSDRServer(t, nil) // no tenantsync
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 502 { if rr.Code != 303 {
t.Fatalf("save without tenantsync = %d, want 502", rr.Code) t.Fatalf("save without tenantsync = %d, want 303 — the flag stores, provisioning waits", rr.Code)
} }
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 { if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
t.Errorf("state written without a provisioner: desc=%+v gen=%d", desc, gen) t.Errorf("state written without a provisioner: desc=%+v gen=%d", desc, gen)
@@ -251,10 +263,67 @@ func TestPBSDR_FailClosed(t *testing.T) {
}) })
} }
// Scenario A (hands-free cascade): flag ON while the WG peer is missing → the save waits; the
// host's WG registration then fires PBSDRAutoProvision and the descriptor lands with ZERO
// operator steps. Red-proof partner: unhook the atom from PBSDRAutoProvision (early return) →
// this fails at "descriptor after WG registration".
func TestPBSDR_AutoProvisionOnWGRegistration(t *testing.T) {
fake := &fakeTenancy{secret: "AUTO-SECRET"}
s, st, logBuf := newPBSDRServer(t, fake)
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
t.Fatalf("remove seed peer: %v", err)
}
// 1. Flag ON, peer missing → stored intent, no descriptor.
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if desc, _, _ := hostState(t, st); desc != nil {
t.Fatalf("descriptor exists before the WG peer: %+v", desc)
}
// 2. The agent registers its WG key (what the api handler does) → the hook fires.
if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil {
t.Fatalf("register peer: %v", err)
}
s.PBSDRAutoProvision(context.Background(), "peti")
desc, desiredJSON, _ := hostState(t, st)
if desc == nil || !desc.Enabled || desc.Namespace != "peti" {
t.Fatalf("descriptor after WG registration = %+v (json %s), want provisioned", desc, desiredJSON)
}
if fake.provisionCalls != 1 {
t.Errorf("provision calls = %d, want 1", fake.provisionCalls)
}
if got, err := st.ConsumeHostPBSSecret("peti-01"); err != nil || got != "AUTO-SECRET" {
t.Fatalf("consume-once secret after auto-provision = (%q, %v)", got, err)
}
if strings.Contains(logBuf.String(), "AUTO-SECRET") {
t.Error("secret leaked into the hub log")
}
// 3. Idempotent: a second hook firing (re-registration) must not re-key.
s.PBSDRAutoProvision(context.Background(), "peti")
if fake.provisionCalls != 1 {
t.Errorf("second hook firing re-provisioned (%d calls)", fake.provisionCalls)
}
// 4. Flag OFF → the hook never provisions (scenario B: off is off).
fake2 := &fakeTenancy{secret: "S2"}
s2, st2, _ := newPBSDRServer(t, fake2)
cfg, _ := st2.GetCustomerConfig("peti")
cfg.DRTier = false
if err := st2.SaveCustomerConfig(cfg); err != nil {
t.Fatalf("save flag-off config: %v", err)
}
s2.PBSDRAutoProvision(context.Background(), "peti")
if fake2.provisionCalls != 0 {
t.Errorf("hook provisioned with the DR flag OFF (%d calls)", fake2.provisionCalls)
}
}
func TestPBSDR_DisableKeepsTenancy(t *testing.T) { func TestPBSDR_DisableKeepsTenancy(t *testing.T) {
fake := &fakeTenancy{secret: "S"} fake := &fakeTenancy{secret: "S"}
s, st, _ := newPBSDRServer(t, fake) s, st, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) postUpdate(t, s, url.Values{"dr_tier": {"on"}})
// Unchecked box → descriptor enabled:false, coords kept, ONE bump; no endpoint mutation. // Unchecked box → descriptor enabled:false, coords kept, ONE bump; no endpoint mutation.
rr := postUpdate(t, s, url.Values{}) rr := postUpdate(t, s, url.Values{})
@@ -281,10 +350,10 @@ func TestPBSDR_DisableKeepsTenancy(t *testing.T) {
func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) { func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) {
fake := &fakeTenancy{secret: "S"} fake := &fakeTenancy{secret: "S"}
s, st, _ := newPBSDRServer(t, fake) s, st, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) postUpdate(t, s, url.Values{"dr_tier": {"on"}})
st.ConsumeHostPBSSecret("peti-01") // spend the provision secret st.ConsumeHostPBSSecret("peti-01") // spend the provision secret
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}, "pbsdr_storage_id": {"felhom-offsite"}}) rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}, "pbsdr_storage_id": {"felhom-offsite"}})
if rr.Code != 303 { if rr.Code != 303 {
t.Fatalf("storage-id change = %d, want 303", rr.Code) t.Fatalf("storage-id change = %d, want 303", rr.Code)
} }
@@ -306,7 +375,7 @@ func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) {
func TestPBSDR_Reissue(t *testing.T) { func TestPBSDR_Reissue(t *testing.T) {
fake := &fakeTenancy{secret: "OLD-SECRET"} fake := &fakeTenancy{secret: "OLD-SECRET"}
s, st, logBuf := newPBSDRServer(t, fake) s, st, logBuf := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) postUpdate(t, s, url.Values{"dr_tier": {"on"}})
st.ConsumeHostPBSSecret("peti-01") // agent already consumed; the dead-end scenario st.ConsumeHostPBSSecret("peti-01") // agent already consumed; the dead-end scenario
fake.secret = "FRESH-SECRET" fake.secret = "FRESH-SECRET"
@@ -353,7 +422,7 @@ func TestPBSDR_ReissueRequiresProvisionedState(t *testing.T) {
func TestPBSDR_FormRendersState(t *testing.T) { func TestPBSDR_FormRendersState(t *testing.T) {
fake := &fakeTenancy{secret: "S"} fake := &fakeTenancy{secret: "S"}
s, _, _ := newPBSDRServer(t, fake) s, _, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) postUpdate(t, s, url.Values{"dr_tier": {"on"}})
// v0.48.0 edit-a: the standalone GET edit page is a redirect now — the form renders embedded // v0.48.0 edit-a: the standalone GET edit page is a redirect now — the form renders embedded
// in the customer page's Edit tab (the same config_form_body sub-template), so assert there. // in the customer page's Edit tab (the same config_form_body sub-template), so assert there.
@@ -361,11 +430,16 @@ func TestPBSDR_FormRendersState(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, req, "peti") s.handleCustomerUnified(rr, req, "peti")
out := rr.Body.String() out := rr.Body.String()
if !strings.Contains(out, `name="pbsdr_enabled" checked`) { if !strings.Contains(out, `name="dr_tier" checked`) {
t.Error("enabled checkbox not checked after provisioning") t.Error("DR-tier checkbox not checked after provisioning")
} }
if !strings.Contains(out, "Provisioned: namespace peti, token felhom@pbs!peti") { if !strings.Contains(out, "descriptor provisioned (namespace peti, token felhom@pbs!peti)") {
t.Error("provisioned-state line missing") t.Error("provisioned cascade stage line missing")
}
// The cascade (v0.51.0 scenario D): all four stages render as done on a fully-applied box.
if strings.Count(out, `class="badge badge-neutral">waiting</span>`) > 1 {
// escrow may still be waiting in this harness; every other stage must be done
t.Errorf("more than the escrow stage still waiting:\n%s", out[strings.Index(out, "DR tier (PBS, ep0)"):][:1200])
} }
if !strings.Contains(out, "pbsdr-reissue") { if !strings.Contains(out, "pbsdr-reissue") {
t.Error("re-issue button missing") t.Error("re-issue button missing")
@@ -140,16 +140,15 @@
{{end}}{{end}}{{end}} {{end}}{{end}}{{end}}
</details> </details>
<details class="card" {{if .PBSDR.Enabled}}open{{end}}> <details class="card" {{if .PBSDR.DRTier}}open{{end}}>
<summary><h2 style="display:inline">PBS DR tier (ep0)</h2></summary> <summary><h2 style="display:inline">DR tier (PBS, ep0)</h2></summary>
<div class="form-grid" style="margin-top: 1rem;"> <div class="form-grid" style="margin-top: 1rem;">
<div class="form-group"> <div class="form-group">
<label><input type="checkbox" name="pbsdr_enabled" {{if .PBSDR.Enabled}}checked{{end}}> <label><input type="checkbox" name="dr_tier" {{if .PBSDR.DRTier}}checked{{end}}>
Enable PBS DR (provisions the ep0 namespace + token on save; the host agent applies the storage entry)</label> DR tier — offsite disaster-recovery via PBS (the DEFAULT for new customers; disabling is the exception and removes the Felhom-side cost entirely)</label>
<small class="form-hint">One flag, ordered rollout: the ep0 namespace + token provision automatically once the host's WG tunnel peer registers; the host agent applies the storage entry on its next tick. Offsite app backup requires this tier (the escrow ceremony depends on the PBS key).</small>
{{if not .PBSDR.Supported}} {{if not .PBSDR.Supported}}
<small class="form-hint">Not configured on this hub (no tenantsync key) — enabling will fail until it is.</small> <small class="form-hint">Not configured on this hub (no tenantsync key) — the flag is stored, but provisioning waits until it is.</small>
{{else if .PBSDR.NoHost}}
<small class="form-hint">No host enrolled for this customer yet — enabling fails until the agent enrolls and reports its WG key.</small>
{{end}} {{end}}
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -159,8 +158,21 @@
<small class="form-hint">The storage-entry id the agent creates on the box. Default felhom-pbs; the demo host's adopted manual entry is felhom-offsite.</small> <small class="form-hint">The storage-entry id the agent creates on the box. Default felhom-pbs; the demo host's adopted manual entry is felhom-offsite.</small>
</div> </div>
</div> </div>
{{if .PBSDR.DRTier}}
<!-- v0.51.0 cascade status (scenario D): per-stage reality vs the stored intent,
reusing the fail-closed guard wording. Neutral states, never red — waiting is
honest, not broken. -->
<ul class="form-hint" style="margin-top:.5rem; list-style:none; padding-left:0; display:grid; gap:.25rem">
<li>{{if not .PBSDR.NoHost}}<span class="badge badge-ok">done</span> host enrolled ({{.PBSDR.HostID}}){{else}}<span class="badge badge-neutral">waiting</span> no host enrolled yet — the Day-0 install enrolls it{{end}}</li>
<li>{{if .PBSDR.WGPeer}}<span class="badge badge-ok">done</span> WG tunnel peer registered{{else}}<span class="badge badge-neutral">waiting</span> the host has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier (registration is hands-free once the agent starts){{end}}</li>
<li>{{if .PBSDR.Provisioned}}<span class="badge badge-ok">done</span> descriptor provisioned (namespace {{.PBSDR.Namespace}}, token {{.PBSDR.TokenID}}){{else}}<span class="badge badge-neutral">waiting</span> provisions automatically when the WG peer registers{{end}}</li>
<li>{{if .PBSDR.EscrowPresent}}<span class="badge badge-ok">done</span> key escrow present (ceremony done){{else}}<span class="badge badge-neutral">waiting</span> ceremony possible once the descriptor is applied on the box (run it with the customer — R-moment){{end}}</li>
</ul>
{{end}}
{{if and .PBSDR.Provisioned (not .PBSDR.DRTier)}}
<p class="form-hint" style="margin-top:.5rem">Tier OFF with a provisioned namespace: runs stop and the box reports the DR capabilities inactive; the ep0 namespace + data are KEPT (teardown of an ever-on tier is a deliberate manual operator action).</p>
{{end}}
{{if .PBSDR.Provisioned}} {{if .PBSDR.Provisioned}}
<p class="form-hint" style="margin-top:.5rem">Provisioned: namespace {{.PBSDR.Namespace}}, token {{.PBSDR.TokenID}} (host {{.PBSDR.HostID}}) — the token secret is delivered to the host agent once (never shown here).</p>
<!-- The offsite F4 precedent: explicit operator re-key for a consumed-secret dead-end. <!-- The offsite F4 precedent: explicit operator re-key for a consumed-secret dead-end.
Rides the parent form via formaction; _csrf submits with it. --> Rides the parent form via formaction; _csrf submits with it. -->
<button type="submit" class="btn btn-outline" style="margin-top:.5rem" <button type="submit" class="btn btn-outline" style="margin-top:.5rem"
@@ -153,6 +153,46 @@
{{end}} {{end}}
</section> </section>
<!-- Capabilities (v0.51.0): the agent's privileged-capability self-check, rendered.
inactive = disabled by configuration (neutral, DR tier off) — NOT degraded. -->
<section class="card" style="padding: 0; overflow: hidden;">
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Capabilities</h2>
{{if .NeedsDRMigration}}
<p class="hint" style="padding: 0 1.25rem; color: var(--text-muted); font-size: 0.85rem;">
This box predates the uniform DR plumbing (host-install &lt; v1.15.0) — the pbsdr
wrapper is missing. Migration one-liner set (as root on the host): fetch
<code>configs/felhom-pbs-apply</code> from the agent repo to
<code>/usr/local/sbin/felhom-pbs-apply</code> (0755), <code>apt-get install -y age</code>,
set <code>wg_tunnel.enabled: true</code> in agent.json, restart felhom-agent —
see documentation/runbooks/day0-install.md (retrofit).
</p>
{{end}}
{{if .Capabilities}}
<table class="data-table">
<thead>
<tr>
<th>Capability</th>
<th>Status</th>
<th>Feature / reason</th>
</tr>
</thead>
<tbody>
{{range .Capabilities}}
<tr>
<td>{{.Name}}{{if .Critical}} <span class="badge badge-neutral">critical</span>{{end}}</td>
<td><span class="badge {{.Class}}">{{.Status}}</span></td>
<td>{{.Feature}}{{if .Reason}} — {{.Reason}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No capability report yet (waiting for the first host report).</p>
</div>
{{end}}
</section>
<!-- Diagnostics: component log bundles (v0.46.0) --> <!-- Diagnostics: component log bundles (v0.46.0) -->
<section class="card"> <section class="card">
<h2>Diagnostics — Log Bundles</h2> <h2>Diagnostics — Log Bundles</h2>
+6
View File
@@ -763,6 +763,12 @@ code {
background: var(--bg-2); background: var(--bg-2);
color: var(--text-3); color: var(--text-3);
} }
/* v0.51.0: badge-ok was referenced by the DR-recipe rows but never defined (fell back to the
bare .badge). Blue = healthy, per the status-badge-ok convention. */
.badge-ok {
background: rgba(0, 131, 216, 0.15);
color: var(--blue-bright);
}
/* Summary cards row */ /* Summary cards row */
.summary-cards { .summary-cards {