v0.106.0: offsite provisioning SLICE 2 — controller apply-bridge
On startup reconcile the hub-served offsite: descriptor into a key-only offbox target. internal/offsiteapply.Bridge: verify-pin box host key vs host_fingerprint (NO blind TOFU) → consume the one-time password (single-use, never logged) → sshpass ssh-copy-id -s -f install + verify → configure offbox → EscrowState=pending (fork-4 via Manager.ApplyOffsiteTarget) → persist a descriptor-hash marker LAST. Idempotent + fail-safe. Seams faked in tests; both red-proofs run+reverted. Dockerfile + sshpass. NOT yet live-applied (supervised end-to-end next runbook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -1,68 +1,51 @@
|
||||
# REPORT — controller v0.105.0: fork-4 offsite password custody (hand-off + atomicity + DR inject + coord)
|
||||
# REPORT — controller v0.106.0: offsite provisioning SLICE 2 (apply-bridge)
|
||||
|
||||
**Date:** 2026-07-09 · **Class:** implementation (controller). **Baseline:** `main` @ `bde43f3` (v0.104.0)
|
||||
→ `0b09a79` (v0.105.0). Pairs with agent v0.77.0. Design validated by the custody spike (`febdc56`).
|
||||
**Date:** 2026-07-09 · **Class:** implementation (controller) + risky. **Baseline:** `main` @ `fa9362f`
|
||||
(v0.105.0) → v0.106.0. Pairs with **hub v0.38.0** (adds `HostFingerprint` to the offsite descriptor).
|
||||
|
||||
## What shipped
|
||||
- **Hand-off** — `agentapi.Client.StageEscrowSecret` pushes the repo password to the agent's
|
||||
`POST /escrow/stage-secret` (authenticated pinned channel, value never logged). Enable flow
|
||||
(`offboxConfigHandler`) reads the 0600 password via `Manager.PushOffboxPasswordForEscrow` (the handler
|
||||
never sees the value) and sets `EscrowState="pending"`; a stage-push failure is surfaced, not marked escrowed.
|
||||
- **Atomicity gate** — `Manager.OffboxRunnable()`/`offboxEscrowed()`; `RunOffboxBackup` (→ the daily
|
||||
scheduler and the run handler) **refuses to run until `EscrowState=="escrowed"`** — no un-recoverable
|
||||
offsite ciphertext can exist. `OffboxConfigured()` unchanged (config/UI still work). New additive
|
||||
`settings.OffboxTarget.EscrowState` (`""|"pending"|"escrowed"`, preserved across config edits).
|
||||
- **Confirm + DR inject** — `POST /backup/offbox/confirm-escrow` → escrowed (operator, after the ceremony);
|
||||
`POST /backup/offbox/inject-password` → `Manager.InjectOffboxPassword` pre-places a recovered 64-hex
|
||||
password 0600 (tmp+rename), refusing to clobber without `force`; a subsequent `WriteOffboxSecrets` uses it.
|
||||
`/backups` shows a pending-escrow notice + "Letét megerősítése" button.
|
||||
- **DR recipe** — `DRRecipeAppHalf.OffsiteRestic *DRResticCoord {host,user,port,repo_path}` (coordinates
|
||||
ONLY — password escrowed, SFTP key regenerable), populated from `Manager.OffboxCoord()`.
|
||||
On startup the controller reconciles the hub-served `offsite:` descriptor into a working key-only offbox
|
||||
target — the controller half of hub-driven offsite provisioning.
|
||||
- `internal/config.OffsiteConfig` — the `offsite:` section (mirrors the hub descriptor incl. `host_fingerprint`).
|
||||
- `internal/offsiteapply.Bridge.Reconcile` — **verify-pin (no blind TOFU)** → generate keypair → **consume
|
||||
the one-time password** (single-use, never logged) → install pubkey (`sshpass -e ssh-copy-id -p 23 -s -f`)
|
||||
+ verify → configure offbox → `EscrowState="pending"` → persist the descriptor-hash marker LAST.
|
||||
**Idempotent** (marker prevents re-consuming a spent password) and **fail-safe** (any step fails → nothing
|
||||
persisted, retried next cycle; consumed-but-failed install logs a loud "password is spent — reset on the hub").
|
||||
- `internal/backup.Manager.ApplyOffsiteTarget` — reuses the fork-4 enable primitives (best-effort escrow stage).
|
||||
- `cmd/controller` — wires the real seams + runs `Reconcile` async at startup. `Dockerfile` + `sshpass`.
|
||||
|
||||
## Files changed (commit `0b09a79`)
|
||||
`internal/agentapi/client.go`, `internal/settings/settings.go`, `internal/backup/offbox.go`,
|
||||
`internal/backup/offbox_test.go`, `internal/web/offbox_handlers.go`, `internal/web/offbox_escrow_test.go`
|
||||
(new), `internal/web/server.go`, `internal/web/templates/backups.html`, `internal/report/dr_recipe.go`,
|
||||
`internal/report/dr_recipe_test.go`, `internal/report/builder.go`, `CHANGELOG.md`, `CONTEXT.md`,
|
||||
`controller/README.md`.
|
||||
## Files changed
|
||||
`internal/config/config.go`, `internal/offsiteapply/{offsiteapply.go,seams.go,offsiteapply_test.go}` (new),
|
||||
`internal/backup/offbox.go`, `cmd/controller/main.go`, `Dockerfile`, `CHANGELOG.md`.
|
||||
|
||||
## Tests + companion red-proofs
|
||||
Green gate `go build ./... && go vet ./... && go test ./...` — **ALL-GREEN**. UI gates (`template_id_gate`,
|
||||
`emoji_gate`) pass. Offbox suite extended (harness defaults to `EscrowState="escrowed"` so behavioral run
|
||||
tests exercise the run path). New:
|
||||
- `TestOffbox_PendingEscrowBlocksRun` (A) — a toggled app with a present unit is NOT backed up while pending
|
||||
(0 `restic backup` calls); `OffboxConfigured` true, `OffboxRunnable` false.
|
||||
**Companion red-proof (run + reverted):** disabling the gate (`if false && …`) → the run backed up while
|
||||
pending → the test FAILED (`NO offsite backup may run while escrow is pending, got […/backups/primary/app1]`).
|
||||
Proves the atomicity gate is load-bearing.
|
||||
- `TestOffbox_ConfirmEscrowEnablesRun` (B) — pending blocks; after `EscrowState="escrowed"` the run proceeds (1 backup).
|
||||
- `TestOffbox_InjectPasswordPrePlaced` (C) — an injected recovered password survives `WriteOffboxSecrets`;
|
||||
refuse-clobber without force; force overwrites; invalid rejected.
|
||||
**Companion `TestOffbox_NoInjectGeneratesDifferentPassword`:** without inject, `WriteOffboxSecrets`
|
||||
generates a DIFFERENT 64-hex password (which could never open the existing repo) — proves inject is load-bearing.
|
||||
- `TestOffbox_CoordForDR` — `OffboxCoord` returns the coords; ok=false when unconfigured.
|
||||
- `report.TestDRResticCoord_NoSecrets` (E) — the serialized `OffsiteRestic` coord clears the secret-name
|
||||
regex and emits host/user/port/repo_path with no password/key.
|
||||
- web: `TestOffboxWeb_RunGatedUntilConfirm` (run refused with the escrow-wait flash while pending; confirm →
|
||||
escrowed + runnable), `TestOffboxWeb_InjectPassword` (endpoint pre-places 0600; invalid → error flash).
|
||||
Green gate `go build ./... && go vet ./... && go test ./...` — **ALL-GREEN** (both repos).
|
||||
- `TestBridge_AppliesEndToEnd` — consume→verify-pin→install→configure→marker; asserts the enabler got the
|
||||
pinned known_hosts + the private key, and **the one-time password never appears in a log line**.
|
||||
- `TestBridge_HostKeyMismatchRefuses` — a scanned FP ≠ descriptor FP → refuse (no consume/install/configure/marker).
|
||||
**Red-proof run:** dropped the verify (`if false`) → the mismatch proceeded to install a wrong key → test
|
||||
FAILED ("mismatch must refuse, got <nil>"). Reverted. No-TOFU is load-bearing.
|
||||
- `TestBridge_IdempotentNoReconsume` — marker matches → `Consume` panics if called → clean no-op.
|
||||
- `TestBridge_InstallFailIsFailSafe` — install errors → marker NOT persisted, offbox NOT configured, loud
|
||||
"password is spent" log. **Red-proof run:** persisted the marker before the install → a failed apply looked
|
||||
done → test FAILED ("marker must NOT be persisted on a failed apply"). Reverted. Marker-after-success is load-bearing.
|
||||
- Hub `internal/offsite`: descriptor carries `HostFingerprint` from a faked scanner; a scan failure fails-closed.
|
||||
|
||||
## Deploy verification (guest 9201)
|
||||
Built + pushed `gitea.dooplex.hu/admin/felhom-controller:0.105.0` (digest `sha256:413d46a9…c0`), deployed
|
||||
via the bootstrap mechanism. `docker ps`: `:0.105.0 Up (healthy)`. Startup clean (settings loaded, scheduler
|
||||
registered, logscanner errors=0). **Atomicity gate live:** the standing demo offbox has `escrow_state` unset
|
||||
→ it is now correctly **gated** (`/backups` renders the "…letétbe helyezésére vár" notice + "Letét
|
||||
megerősítése" button; scheduled/manual runs skip). Per the STOP boundary, NO ceremony/confirm/gated run was
|
||||
performed on the demo.
|
||||
## Deploy verification
|
||||
(To be filled by the deploy step: controller `:0.106.0 Up (healthy)` on guest 9201 + clean startup logs;
|
||||
offsite is disabled in the demo config, so the bridge no-ops — the deploy proves the image ships sshpass +
|
||||
the bridge wiring, not a live apply.)
|
||||
|
||||
## NOT yet live-validated
|
||||
The supervised escrow ceremony end-to-end (enable → stage → `--selftest=escrow-create` with a real R →
|
||||
confirm → gated run against the box) is the **operator-run** follow-up — pending. Unit tests are this task's proof.
|
||||
## NOT yet live-applied
|
||||
The supervised end-to-end (hub provisions on the new pool box `u629488`/project `15282031` → controller
|
||||
consumes + installs its key + configures the offbox → `EscrowState="pending"`) is the **next runbook**, gated
|
||||
on the hub being wired with the new scoped `HETZNER_TOKEN` + `HETZNER_POOL_BOX_ID`. Unit tests (faked seams)
|
||||
are this slice's proof. Untested-live: the real `sshpass`/`ssh-copy-id -s -f` install + the x/crypto/ssh
|
||||
host-key scan against a live box (both proven in the API spike; re-confirmed in the supervised run).
|
||||
|
||||
## Observations (noticed, not acted on)
|
||||
- The standing demo offbox (configured pre-fork-4) is now gated `pending` — this is the intended fail-safe,
|
||||
but it means the demo's daily offsite run will skip until an operator runs the ceremony + confirm. Flagged
|
||||
for the supervised follow-up.
|
||||
- `confirm-escrow` currently trusts the operator (pilot). The provisioning task should replace it with a
|
||||
hub-verified auto-confirm (the operator-forgets/operator-lies footgun) — noted in code + CONTEXT.
|
||||
- Pre-existing Tier-2 `TestBackupTier2Restore_DoubleClickRefused` flake (documented in v0.104.0 REPORT) is
|
||||
unchanged/untouched.
|
||||
## Observations
|
||||
- The bridge runs once at startup; the config-refresh self-restart re-runs it after a descriptor change (no
|
||||
separate post-refresh hook needed — the restart is the trigger).
|
||||
- The escrow stage-push is best-effort (agent-down leaves the offbox configured+pending, re-stage later) —
|
||||
the offbox run-gate still holds until the operator confirms escrow (fork-4).
|
||||
|
||||
Reference in New Issue
Block a user