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
@@ -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}}