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:
@@ -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
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user