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