v0.105.0: fork-4 offsite password custody — hand-off + atomicity gate + DR inject + coord

Pairs with agent v0.77.0. StageEscrowSecret pushes the repo password to the
agent (POST /escrow/stage-secret) at offsite-enable → EscrowState="pending".
Atomicity gate: RunOffboxBackup (scheduler + handler) refuses until
EscrowState="escrowed" (operator POST /backup/offbox/confirm-escrow after the
escrow ceremony) — no un-recoverable offsite ciphertext can exist. DR:
POST /backup/offbox/inject-password pre-places a recovered 64-hex password 0600
(honored by WriteOffboxSecrets' IsNotExist guard; refuses clobber without
force). DR recipe gains non-secret offsite_restic coords (DRResticCoord); SFTP
key regenerated at DR, not escrowed. New settings.OffboxTarget.EscrowState.
Tests + atomicity & inject companion red-proofs green; UI gates pass. NOT yet
live-validated (supervised ceremony).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 15:13:59 +02:00
parent bde43f3a74
commit 0b09a799cb
14 changed files with 498 additions and 5 deletions
+28
View File
@@ -1,5 +1,33 @@
## Changelog
### v0.105.0 — fork-4: offsite password custody hand-off + atomicity gate + DR inject + DR coord (2026-07-09)
Pairs with agent v0.77.0 to make the restic-offsite repo password recoverable at DR (rides the customer-R
escrow) and forbids an un-escrowed offsite copy from existing. Validated design: custody spike `febdc56`.
- **Hand-off** (`internal/agentapi/client.go`): `StageEscrowSecret` pushes the repo password to the agent's
`POST /escrow/stage-secret` over the authenticated pinned local-API channel (value never logged). The
enable flow (`internal/web/offbox_handlers.go`) reads the 0600 password via a new
`Manager.PushOffboxPasswordForEscrow` (the handler never sees the value) and marks `EscrowState="pending"`.
- **Atomicity gate** (`internal/backup/offbox.go`): `OffboxRunnable()`/`offboxEscrowed()``RunOffboxBackup`
(and thus the daily scheduler + the run handler) **refuses to run until `EscrowState=="escrowed"`**, so no
un-recoverable offsite ciphertext can exist. `OffboxConfigured()` is unchanged (config/UI still work).
New `settings.OffboxTarget.EscrowState` (`""|"pending"|"escrowed"`, additive, preserved across edits).
- **Confirm + DR inject** (`internal/web`): `POST /backup/offbox/confirm-escrow` (operator, after the escrow
ceremony) → escrowed; `POST /backup/offbox/inject-password` (DR) → `Manager.InjectOffboxPassword`
pre-places a recovered 64-hex password 0600 (tmp+rename), refusing to clobber without `force` — a
subsequent `WriteOffboxSecrets` then uses it (the pre-place seam). `/backups` shows a pending-escrow
notice + confirm button.
- **DR recipe** (`internal/report/dr_recipe.go`): `DRRecipeAppHalf.OffsiteRestic *DRResticCoord`
{host,user,port,repo_path} — coordinates ONLY (the password is escrowed, the SFTP key is regenerable);
populated from `Manager.OffboxCoord()`; clears the `_NoSecrets` regex.
- Tests: atomicity (pending blocks run; confirm enables) **+ companion red-proof** (gate disabled → runs
while pending → FAIL); DR inject pre-place honored + refuse-clobber + companion (no-inject generates a
DIFFERENT password); `OffboxCoord`; agent stage endpoint (0600 + non-secret ack + cross-guest 403 +
value-not-in-log); `DRResticCoord` no-secrets. Web: run-gate + confirm + inject endpoints.
- **NOT yet live-validated** — the supervised escrow ceremony (enable→stage→escrow-create→confirm→gated run)
is the operator-run follow-up.
### v0.104.0 — off-box unit discovery (durable, deployment-independent) + no-silent-success (2026-07-09)
Fixes the off-box mis-resolution + silent-success landmine surfaced by the Storage-Box spike and pinned by
+12 -1
View File
@@ -7,7 +7,18 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-07-09 (v0.104.0 — off-box unit discovery + no-silent-success)
Last updated: 2026-07-09 (v0.105.0 — fork-4 offsite password custody + atomicity gate + DR inject)
> **2026-07-09 — v0.105.0: fork-4 offsite password custody (pairs with agent v0.77.0).** The restic-offsite
> repo password now rides the **customer-R escrow** (age-under-R in the agent `IdentityBundle`; custody spike
> `febdc56`). Enable → controller pushes the password (`StageEscrowSecret` → agent `POST /escrow/stage-secret`)
> → `EscrowState="pending"`. **Atomicity gate:** no offsite RUN until `EscrowState="escrowed"` (operator
> `POST /backup/offbox/confirm-escrow` after the escrow ceremony) — so no un-recoverable offsite ciphertext
> exists. **DR:** `POST /backup/offbox/inject-password` pre-places the recovered password (honored by
> `WriteOffboxSecrets`). DR recipe gains non-secret `offsite_restic` coords (`DRResticCoord`); the SFTP key is
> regenerated at DR (not escrowed). Atomicity + inject companion red-proofs green. **NOT yet live-validated**
> — the supervised escrow ceremony (enable→stage→escrow-create→confirm→gated run) is operator-run; NEXT =
> hub-verified auto-confirm + customer-self-serve enable (provisioning task). Deployed to 9201; see REPORT.
> **2026-07-09 — v0.104.0: off-box discovery over inference + no-silent-success.** The Storage-Box spike
> found offbox reporting `ok`/0 snapshots while backing up nothing; DIAG pinned it: offbox resolved each
+16 -2
View File
@@ -716,8 +716,22 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
> anywhere) is a **hard error** → `LastStatus="error"` + operator alert (was a misleading `ok`/0 snapshots).
> A *partial* run (some units missing) stays `ok` but sets a Hungarian **`LastWarning`** naming the skipped
> apps, shown on `/backups`.
> - **Secrets** (SSH key + auto-gen repo password) are **0600 files in the data dir** — never logged/committed;
> they ride DR via the PBS whole-CT snapshot of the rootfs (the recovery-unit/dr-recipe stay secret-free).
> - **Secrets** (SSH key + auto-gen repo password) are **0600 files in the data dir** — never logged/committed.
> - **Password custody + atomicity (v0.105.0, fork-4; pairs with agent v0.77.0).** The repo password is the
> irreplaceable DATA key for the offsite tier, so it rides the **customer-recovery-code (R) escrow**
> (age-under-R, in the agent's `IdentityBundle` — custody spike validated a recovered password opens the
> real repo). **Atomicity gate:** enabling offsite pushes the password to the agent (`StageEscrowSecret` →
> `POST /escrow/stage-secret`) and marks `EscrowState="pending"`; **no offsite backup runs until the escrow
> is confirmed** (`OffboxRunnable()`), so an un-recoverable offsite copy can never exist.
> **Operator ceremony (pilot, supervised):** enable offsite (→ pending) → run
> `felhom-agent --selftest=escrow-create --identity-bundle <assembled> --upload` (the bundle now carries
> the staged restic password, auto-injected, then wiped) → hand the customer the fresh **R** (once;
> supersedes any prior code) → `POST /backup/offbox/confirm-escrow` (or the "Letét megerősítése" button).
> **DR:** recover R → the escrow yields the password → `POST /backup/offbox/inject-password {password}`
> pre-places it 0600 → configure offbox → restore. The SFTP access key is **regenerated** at DR (a fresh
> sub-account key), NOT escrowed; the DR recipe carries only the non-secret `offsite_restic`
> coordinates (`DRResticCoord`). *(A hub-verified auto-confirm + customer-self-serve enable + fresh-R UX
> are the provisioning task, deferred.)*
> - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh
> option-injection) or carry metacharacters/traversal; `OffboxConfigured` fails closed on an invalid
> target. Image: `restic` + `openssh-client` (re-added; restic's sftp backend shells out to `ssh`).
+11
View File
@@ -463,6 +463,17 @@ type EjectResult struct {
// EjectDisk safe-unmounts a host mount (data preserved) and returns the dependent guests.
// Status-aware POST (campaign F2 evidence gap): the agent's refusal body carries the reason
// (e.g. "…eject refused (role: system)") — surface it instead of a bare "HTTP 403".
// StageEscrowSecret pushes the offsite restic repo password to the agent (fork-4), which stages it
// transiently for the escrow-create ceremony to wrap under the customer recovery code R. The value is
// sent over the authenticated pinned local-API channel; the CALLER must never log it.
func (c *Client) StageEscrowSecret(ctx context.Context, resticRepoPassword string) error {
env, status, err := c.postWithStatus(ctx, "/escrow/stage-secret", map[string]string{"restic_repo_password": resticRepoPassword})
if err != nil {
return err
}
return refusalError("/escrow/stage-secret", status, env)
}
func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, error) {
var out EjectResult
env, status, err := c.postWithStatus(ctx, "/disks/eject", map[string]string{"where": where})
+73
View File
@@ -161,6 +161,73 @@ func (m *Manager) OffboxConfigured() bool {
return true
}
// offboxRepoPwPattern matches a valid restic repo password (generateOffboxPassword = 32 rand bytes → 64 hex).
var offboxRepoPwPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// PushOffboxPasswordForEscrow reads the 0600 repo password and hands it to `stage` (the agent push), so
// the web/handler caller never sees the value — used by the enable flow to escrow-stage the offsite key.
func (m *Manager) PushOffboxPasswordForEscrow(ctx context.Context, stage func(ctx context.Context, pw string) error) error {
pw, err := os.ReadFile(m.offboxPwPath())
if err != nil {
return fmt.Errorf("read offbox password: %w", err)
}
return stage(ctx, strings.TrimSpace(string(pw)))
}
// InjectOffboxPassword pre-places a RECOVERED repo password at offboxPwPath (fork-4 DR seam) so a
// subsequent WriteOffboxSecrets uses it instead of generating a new one. Refuses to clobber an existing
// password unless force. Written 0600 via tmp+rename. The value is NEVER logged.
func (m *Manager) InjectOffboxPassword(pw string, force bool) error {
pw = strings.TrimSpace(pw)
if !offboxRepoPwPattern.MatchString(pw) {
return fmt.Errorf("invalid repo password (expected 64 hex characters)")
}
if _, err := os.Stat(m.offboxPwPath()); err == nil && !force {
return fmt.Errorf("a repo password already exists (pass force to overwrite)")
}
if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil {
return fmt.Errorf("offbox dir: %w", err)
}
tmp := m.offboxPwPath() + ".tmp"
if err := os.WriteFile(tmp, []byte(pw), 0o600); err != nil {
return fmt.Errorf("write injected password: %w", err)
}
if err := os.Rename(tmp, m.offboxPwPath()); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("place injected password: %w", err)
}
return nil
}
// offboxEscrowed reports whether the offsite repo password is confirmed escrowed under R (fork-4).
func (m *Manager) offboxEscrowed() bool {
t := m.settings.GetOffboxTarget()
return t != nil && t.EscrowState == "escrowed"
}
// OffboxRunnable reports whether an off-box RUN may proceed: configured AND escrowed. Config/UI still work
// when not runnable — only actual backup writes are gated (the atomicity guarantee). For the run handler.
func (m *Manager) OffboxRunnable() bool { return m.OffboxConfigured() && m.offboxEscrowed() }
// OffboxCoord returns the non-secret offsite repo coordinates for the DR recipe (fork-4). ok=false when no
// offbox target is configured. NEVER returns the repo password or the SSH key (those are escrowed/regenerable).
func (m *Manager) OffboxCoord() (host, user string, port int, repoPath string, ok bool) {
t := m.settings.GetOffboxTarget()
if t == nil || t.Host == "" || t.User == "" || t.RepoPath == "" {
return "", "", 0, "", false
}
return t.Host, t.User, t.Port, t.RepoPath, true
}
// OffboxEscrowState returns the current escrow state ("" | "pending" | "escrowed") for the UI/handlers.
func (m *Manager) OffboxEscrowState() string {
t := m.settings.GetOffboxTarget()
if t == nil {
return ""
}
return t.EscrowState
}
// offboxBaseArgs builds the restic global args (repo + sftp.args carrying the ConnectTimeout, key, pinned
// known_hosts, port) and the env (RESTIC_PASSWORD_FILE). The ConnectTimeout is MANDATORY (fail-fast).
func (m *Manager) offboxBaseArgs(t *settings.OffboxTarget) ([]string, []string) {
@@ -212,6 +279,12 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
if !m.OffboxConfigured() {
return fmt.Errorf("off-box backup not configured")
}
// fork-4 atomicity gate: no offsite RUN until the repo password is confirmed escrowed under R, so no
// un-recoverable offsite ciphertext can exist. Not an error (config/UI still work) — a skip.
if !m.offboxEscrowed() {
m.logger.Printf("[INFO] [offbox] skipped — pending key escrow (no offsite run until the repo password is escrowed under R)")
return nil
}
if m.migrationActive() {
m.logger.Printf("[INFO] [offbox] skipped — migration in progress")
return nil
+130
View File
@@ -32,6 +32,7 @@ func newOffboxManager(t *testing.T) (*Manager, *settings.Settings) {
m := NewManager(cfg, sett, logger)
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
EscrowState: "escrowed", // fork-4: default the harness to escrowed so behavioral run tests exercise the run path
}); err != nil {
t.Fatal(err)
}
@@ -498,6 +499,135 @@ func TestOffbox_NoAppsToggledIsCleanOK(t *testing.T) {
}
}
// --- fork-4: atomicity gate + DR inject + coord ---
// setPending overrides the harness's escrowed default to pending.
func setEscrowState(t *testing.T, sett *settings.Settings, state string) {
t.Helper()
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = state }); err != nil {
t.Fatal(err)
}
}
// Scenario A — a toggled app with a present unit is NOT backed up while escrow is pending (atomicity).
func TestOffbox_PendingEscrowBlocksRun(t *testing.T) {
m, sett := newOffboxManager(t)
setEscrowState(t, sett, "pending")
usb := t.TempDir()
addSchedulablePath(t, sett, usb)
writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z")
_ = sett.SetAppOffbox("app1", true)
rr := &recordingOffboxRunner{}
m.SetOffboxRunner(rr.run)
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("pending escrow must be a clean skip, got %v", err)
}
if len(rr.backupSrc) != 0 {
t.Fatalf("NO offsite backup may run while escrow is pending, got %v", rr.backupSrc)
}
if !m.OffboxConfigured() {
t.Fatal("config must still be valid while pending (only RUNS are gated)")
}
if m.OffboxRunnable() {
t.Fatal("OffboxRunnable must be false while pending")
}
}
// Scenario B — confirming escrow flips to escrowed and the run then proceeds.
func TestOffbox_ConfirmEscrowEnablesRun(t *testing.T) {
m, sett := newOffboxManager(t)
setEscrowState(t, sett, "pending")
usb := t.TempDir()
addSchedulablePath(t, sett, usb)
writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z")
_ = sett.SetAppOffbox("app1", true)
rr := &recordingOffboxRunner{}
m.SetOffboxRunner(rr.run)
if err := m.RunOffboxBackup(context.Background()); err != nil || len(rr.backupSrc) != 0 {
t.Fatalf("must be blocked while pending (err=%v src=%v)", err, rr.backupSrc)
}
setEscrowState(t, sett, "escrowed")
if !m.OffboxRunnable() {
t.Fatal("must be runnable after confirm")
}
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run after confirm: %v", err)
}
if len(rr.backupSrc) != 1 {
t.Fatalf("must back up after confirm, got %v", rr.backupSrc)
}
}
// Scenario C — a pre-placed (DR-injected) recovered password is honored, not regenerated.
func TestOffbox_InjectPasswordPrePlaced(t *testing.T) {
m, _ := newOffboxManager(t)
_ = os.Remove(m.offboxPwPath()) // simulate a fresh controller (no password yet)
const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if err := m.InjectOffboxPassword(recovered, false); err != nil {
t.Fatalf("inject: %v", err)
}
if err := m.WriteOffboxSecrets("newkey", "nas.local ssh-ed25519 NEWKEY"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(m.offboxPwPath())
if string(got) != recovered {
t.Fatalf("injected password was overwritten (len now %d) — the existing repo would be unopenable", len(got))
}
// refuse to clobber an existing password without force
if err := m.InjectOffboxPassword("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", false); err == nil {
t.Fatal("inject must refuse to clobber an existing password without force")
}
// force overwrites
const forced = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
if err := m.InjectOffboxPassword(forced, true); err != nil {
t.Fatalf("force inject: %v", err)
}
if got2, _ := os.ReadFile(m.offboxPwPath()); string(got2) != forced {
t.Fatal("force inject must overwrite")
}
// invalid (non-hex / wrong length) rejected
if err := m.InjectOffboxPassword("not-a-valid-hex-password", false); err == nil {
t.Fatal("an invalid repo password must be rejected")
}
}
// Companion to C — WITHOUT inject, WriteOffboxSecrets generates a DIFFERENT password (so the recovered
// one is load-bearing: a fresh gen could never open the existing offsite repo).
func TestOffbox_NoInjectGeneratesDifferentPassword(t *testing.T) {
m, _ := newOffboxManager(t)
_ = os.Remove(m.offboxPwPath())
const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if err := m.WriteOffboxSecrets("k", "kh"); err != nil { // no inject → generates fresh
t.Fatal(err)
}
raw, rerr := os.ReadFile(m.offboxPwPath())
if rerr != nil {
t.Fatal(rerr)
}
got := strings.TrimSpace(string(raw))
if got == recovered {
t.Fatal("the freshly generated password coincided with the recovered one (impossible with 256-bit entropy)")
}
if len(got) != 64 {
t.Fatalf("generated repo password must be 64 hex, got %d", len(got))
}
}
// Scenario E (backup half) — OffboxCoord returns the non-secret coordinates, ok=false when unconfigured.
func TestOffbox_CoordForDR(t *testing.T) {
m, sett := newOffboxManager(t)
host, user, port, repo, ok := m.OffboxCoord()
if !ok || host != "nas.local" || user != "felhom" || port != 22 || repo != "/srv/repo" {
t.Fatalf("coord = %s/%s/%d/%s ok=%v", host, user, port, repo, ok)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{}); err != nil { // empty target
t.Fatal(err)
}
if _, _, _, _, ok := m.OffboxCoord(); ok {
t.Fatal("an unconfigured target must yield ok=false")
}
}
func runtimeIsUnix() bool { return os.PathSeparator == '/' }
func contains(ss []string, want string) bool {
+6
View File
@@ -165,6 +165,12 @@ func BuildReport(
// Allowlist-only (the boundary): NO env/secret fields. The hub assembles it with the agent half.
r.DRRecipe = BuildDRRecipeAppHalf(cfg.Customer.ID, cfg.Customer.Name, cfg.Customer.Domain,
stackMgr.GetStacks(), readComposeFile)
// fork-4: attach the non-secret offsite restic repo coordinates so DR knows where to recover from.
if backupMgr != nil {
if host, user, port, repoPath, ok := backupMgr.OffboxCoord(); ok {
r.DRRecipe.OffsiteRestic = &DRResticCoord{Host: host, User: user, Port: port, RepoPath: repoPath}
}
}
if debug && logger != nil {
logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d",
+13
View File
@@ -36,6 +36,19 @@ type DRRecipeAppHalf struct {
RecipeVersion int `json:"recipe_version"`
Customer DRCustomer `json:"customer"`
Apps []AppRecipe `json:"apps"`
// OffsiteRestic (fork-4) is the non-secret location of the offsite restic repo, so DR knows WHERE to
// recover from. nil when offsite is not configured. Coordinates ONLY — see DRResticCoord.
OffsiteRestic *DRResticCoord `json:"offsite_restic,omitempty"`
}
// DRResticCoord is the offsite restic repo's non-secret coordinates. The repo PASSWORD rides the R-escrow
// (IdentityBundle.ResticRepoPassword); the SFTP access key is regenerated at DR (a fresh sub-account key) —
// so NEITHER appears here. All field names deliberately clear the _NoSecrets regex (no password/key/token).
type DRResticCoord struct {
Host string `json:"host"`
User string `json:"user"`
Port int `json:"port"`
RepoPath string `json:"repo_path"`
}
// DRCustomer is the customer identity — public identifiers only.
@@ -99,6 +99,36 @@ func TestBuildAppRecipe_AllowlistIsLoadBearing(t *testing.T) {
}
}
// Scenario E (fork-4) — the OffsiteRestic DR coord carries coordinates ONLY (no password/key), clears the
// secret-name regex, and its fields are emitted. Extends the _NoSecrets boundary to the new field.
func TestDRResticCoord_NoSecrets(t *testing.T) {
half := &DRRecipeAppHalf{
RecipeVersion: DRRecipeVersion,
Customer: DRCustomer{ID: "cust", Display: "Cust", Domain: "demo-felhom.eu"},
Apps: []AppRecipe{},
OffsiteRestic: &DRResticCoord{
Host: "u629193-sub1.your-storagebox.de", User: "u629193-sub1", Port: 23, RepoPath: "/home/felhom-demo-repo",
},
}
b, err := json.Marshal(half)
if err != nil {
t.Fatal(err)
}
// (1) no credential-shaped KEY name survived (the DRResticCoord field names must clear the regex).
assertNoSecretKeys(t, b)
out := string(b)
// (2) positive: the coordinate fields ARE emitted (not a vacuous pass).
for _, want := range []string{`"offsite_restic"`, `"host":"u629193-sub1.your-storagebox.de"`, `"user":"u629193-sub1"`, `"port":23`, `"repo_path":"/home/felhom-demo-repo"`} {
if !strings.Contains(out, want) {
t.Errorf("DR coord missing %s in %s", want, out)
}
}
// (3) the password/key field names must never appear.
if strings.Contains(out, "repo_password") || strings.Contains(out, "ssh_key") || strings.Contains(out, "password") {
t.Fatalf("DR coord must carry NO password/key: %s", out)
}
}
func TestAppStorageBindings(t *testing.T) {
got := appStorageBindings(rommCompose, "/mnt/felhom-drives/felhom-flash")
want := map[string]StorageBinding{
+5
View File
@@ -129,6 +129,11 @@ type OffboxTarget struct {
// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
LastWarning string `json:"last_warning,omitempty"`
// EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending"
// |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run
// proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable
// offsite ciphertext can exist. It is NOT a secret (a state label); the password never lives here.
EscrowState string `json:"escrow_state,omitempty"`
}
// CrossDriveBackup configures per-app backup to a secondary drive.
@@ -0,0 +1,96 @@
package web
import (
"io"
"log"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
func newOffboxWebServer(t *testing.T) (*Server, *settings.Settings, *backup.Manager) {
t.Helper()
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
m := backup.NewManager(cfg, sett, lg)
return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg}, sett, m
}
// The run handler refuses while escrow is pending, and confirm-escrow flips to escrowed + runnable.
func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) {
s, sett, m := newOffboxWebServer(t)
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
EscrowState: "pending",
}); err != nil {
t.Fatal(err)
}
if !m.OffboxConfigured() {
t.Fatal("target should be configured")
}
// run while pending → refused with the escrow-wait flash, no run launched
w := httptest.NewRecorder()
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(loc, "let%C3%A9t") {
t.Fatalf("pending run must redirect with the escrow-wait flash, got %d %q", w.Code, loc)
}
if m.OffboxRunnable() {
t.Fatal("must not be runnable while pending")
}
// confirm-escrow → escrowed + runnable
w2 := httptest.NewRecorder()
s.offboxConfirmEscrowHandler(w2, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
if w2.Code != 302 {
t.Fatalf("confirm: got %d", w2.Code)
}
if got := sett.GetOffboxTarget().EscrowState; got != "escrowed" {
t.Fatalf("confirm must set EscrowState=escrowed, got %q", got)
}
if !m.OffboxRunnable() {
t.Fatal("must be runnable after confirm")
}
}
// The inject endpoint pre-places a recovered password (DR seam).
func TestOffboxWeb_InjectPassword(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
pwPath := filepath.Join(s.cfg.Paths.DataDir, "offbox", "repo_password")
const pw = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
form := url.Values{"password": {pw}}
r := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxInjectPasswordHandler(w, r)
if w.Code != 302 {
t.Fatalf("inject: got %d", w.Code)
}
got, err := os.ReadFile(pwPath)
if err != nil || string(got) != pw {
t.Fatalf("injected password not placed 0600 at offboxPwPath: err=%v", err)
}
// an invalid password is refused (error flash)
bad := url.Values{"password": {"nope"}}
rb := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(bad.Encode()))
rb.Header.Set("Content-Type", "application/x-www-form-urlencoded")
wb := httptest.NewRecorder()
s.offboxInjectPasswordHandler(wb, rb)
if loc := wb.Header().Get("Location"); !strings.Contains(loc, "flash_error") {
t.Fatalf("invalid password must produce an error flash, got %q", loc)
}
}
+65 -2
View File
@@ -81,13 +81,71 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
tgt.LastRun, tgt.LastStatus, tgt.LastError = prev.LastRun, prev.LastStatus, prev.LastError
tgt.LastDuration, tgt.RepoSizeHuman, tgt.SnapshotCount = prev.LastDuration, prev.RepoSizeHuman, prev.SnapshotCount
tgt.LastWarning = prev.LastWarning
tgt.EscrowState = prev.EscrowState
}
// fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks
// it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already
// escrowed target keeps it escrowed (WriteOffboxSecrets leaves the password unchanged). A stage-push
// failure does NOT mark escrowed; it is surfaced (the run gate still protects data).
stageErr := ""
if tgt.Enabled {
if tgt.EscrowState != "escrowed" {
tgt.EscrowState = "pending"
}
if client, cerr := s.agentClient(); cerr != nil {
stageErr = " — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra."
s.logger.Printf("[WARN] [web] offbox escrow stage: agent client: %v", cerr)
} else if err := s.backupMgr.PushOffboxPasswordForEscrow(r.Context(), client.StageEscrowSecret); err != nil {
stageErr = " — a kulcs letéti előkészítése nem sikerült; próbáld újra."
s.logger.Printf("[WARN] [web] offbox escrow stage: %v", err) // err carries no secret
}
}
if err := s.settings.SetOffboxTarget(tgt); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
return
}
s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v)", user, host, repoPath, port, tgt.Enabled)
offboxRedirect(w, r, "A NAS mentési cél elmentve.", false)
s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState)
offboxRedirect(w, r, "A NAS mentési cél elmentve."+stageErr, stageErr != "")
}
// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4). The operator
// calls this after a successful escrow-create ceremony; offsite runs stay gated until then. (The
// provisioning task should replace this with a hub-verified auto-confirm to remove the operator-forgets/
// operator-lies footgun.)
func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A NAS mentési cél nincs beállítva.", true)
return
}
if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
return
}
s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled")
offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a NAS-mentés mostantól futhat.", false)
}
// offboxInjectPasswordHandler pre-places a RECOVERED repo password at the offbox password path (fork-4 DR
// seam) so a subsequent configure uses it and the existing offsite repo opens. Operator/DR only; the value
// is never logged. Body: {password, force?}.
func (s *Server) offboxInjectPasswordHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
return
}
_ = r.ParseForm()
pw := r.FormValue("password")
force := r.FormValue("force") == "on" || r.FormValue("force") == "true"
if strings.TrimSpace(pw) == "" {
offboxRedirect(w, r, "A repo jelszó kötelező.", true)
return
}
if err := s.backupMgr.InjectOffboxPassword(pw, force); err != nil {
offboxRedirect(w, r, "A jelszó beállítása sikertelen: "+err.Error(), true)
return
}
s.logger.Printf("[INFO] [web] off-box repo password injected (DR pre-place, force=%v)", force)
offboxRedirect(w, r, "A helyreállított repo jelszó beállítva.", false)
}
// offboxToggleHandler flips an app's off-box inclusion.
@@ -112,6 +170,11 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirect(w, r, "A NAS mentési cél nincs beállítva.", true)
return
}
// fork-4 atomicity: refuse the run until the repo password is escrowed under R.
if !s.backupMgr.OffboxRunnable() {
offboxRedirect(w, r, "A NAS-mentés a kulcs letétbe helyezésére vár.", true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
defer cancel()
+5
View File
@@ -311,6 +311,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.offboxRunHandler(w, r)
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
s.offboxRestoreHandler(w, r)
// fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password.
case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost:
s.offboxConfirmEscrowHandler(w, r)
case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost:
s.offboxInjectPasswordHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/export")
@@ -142,6 +142,14 @@
</div>
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
{{if .Offbox.LastWarning}}<p class="form-hint" style="color:var(--warn)">{{.Offbox.LastWarning}}</p>{{end}}
{{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
<p class="form-hint" style="color:var(--warn);margin:0 0 .5rem">A NAS-mentés a kulcs letétbe helyezésére vár — a mentés addig nem fut (így nem keletkezik visszaállíthatatlan másolat). Futtasd a letéti szertartást, majd erősítsd meg.</p>
<form method="POST" action="/backup/offbox/confirm-escrow" style="display:inline">{{.CSRFField}}
<button type="submit" class="btn btn-sm">Letét megerősítése</button>
</form>
</div>
{{end}}
{{if .OffboxConfigured}}
<div class="schedule-actions" style="margin-top:1rem">
<form method="POST" action="/backup/offbox/run" style="display:inline">{{.CSRFField}}