v0.142.0: offsite repo continuity — orphaned-repo guard (A) + run-status auto-refresh (C)
- Part A: classify restic cat-config failure (wrong-password=orphaned vs no-repo vs other); ORPHANED state + Hungarian card + offbox_repo_orphaned/reset events (once, not nightly); reset = move-aside (never delete) + init, unclaimed auto / claimed confirm. Red-proofs TestOffbox_OrphanDetection_* + ConfirmedReset. - Part C: GET /backup/offbox/status + poll on backups_remote → flips Fut→Rendben/Hiba without manual reload.
This commit is contained in:
@@ -755,6 +755,8 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
awaiting, timedOut := offboxCeremonyWaitState(s.settings.GetOffboxTarget())
|
||||
data["OffboxCeremonyAwaiting"] = awaiting
|
||||
data["OffboxCeremonyTimedOut"] = timedOut
|
||||
// v0.142.0 offsite-repo continuity: the orphan card + the auto-refresh (Part C) trigger.
|
||||
data["OffboxOrphaned"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned()
|
||||
s.executeTemplate(w, r, "backups_remote", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
@@ -70,6 +71,54 @@ func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Part C — the status endpoint (poll source) reports the current run status/snapshots as JSON.
|
||||
func TestOffboxStatusHandler(t *testing.T) {
|
||||
s, sett, _ := newOffboxWebServer(t)
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
|
||||
LastStatus: "running", SnapshotCount: 7,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.offboxStatusHandler(w, httptest.NewRequest("GET", "/backup/offbox/status", nil))
|
||||
var d map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &d); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if d["status"] != "running" {
|
||||
t.Fatalf("status = %v, want running", d["status"])
|
||||
}
|
||||
if d["snapshots"].(float64) != 7 {
|
||||
t.Fatalf("snapshots = %v, want 7", d["snapshots"])
|
||||
}
|
||||
if d["orphaned"] != false {
|
||||
t.Fatalf("orphaned = %v, want false", d["orphaned"])
|
||||
}
|
||||
}
|
||||
|
||||
// Part A edge — an ORPHANED repo routes "Távoli mentés most" to the card, never attempting the write.
|
||||
func TestOffboxRun_RefusedWhenOrphaned(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: "escrowed", RepoState: "orphaned",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
|
||||
if w.Code != 302 {
|
||||
t.Fatalf("orphaned run must redirect, got %d", w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); !strings.Contains(loc, "el%C3%A1rvult") {
|
||||
t.Fatalf("orphaned run must redirect to the orphan-card flash, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E — the confirm flip wipes the agent-staged secret; a wipe failure is logged loudly but does
|
||||
// NOT fail the confirm (the state flip is the primary effect).
|
||||
func TestOffboxWeb_ConfirmWipesStagedSecret(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -209,6 +210,12 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
offboxRedirect(w, r, "A távoli mentés a kulcs letétbe helyezésére vár.", true)
|
||||
return
|
||||
}
|
||||
// Offsite-repo continuity (v0.142.0): an ORPHANED repo can't be written — route the customer to the
|
||||
// orphan card's explanation/reset instead of attempting a doomed write.
|
||||
if s.backupMgr.OffboxOrphaned() {
|
||||
offboxRedirect(w, r, "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", true)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
|
||||
defer cancel()
|
||||
@@ -219,6 +226,50 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
offboxRedirect(w, r, "A távoli mentés elindult (a futás után az állapot frissül).", false)
|
||||
}
|
||||
|
||||
// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
|
||||
// code-recoverable) history aside — never delete — and init a fresh repo. Refuses unless orphaned AND
|
||||
// explicitly confirmed (confirm=1, set by the reveal-then-confirm block on the orphan card).
|
||||
func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
||||
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
|
||||
return
|
||||
}
|
||||
if !s.backupMgr.OffboxOrphaned() {
|
||||
offboxRedirect(w, r, "Az offsite tároló nincs elárvult állapotban.", true)
|
||||
return
|
||||
}
|
||||
if r.FormValue("confirm") != "1" {
|
||||
offboxRedirect(w, r, "A visszaállításhoz megerősítés szükséges.", true)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.backupMgr.ResetOrphanedRepo(ctx); err != nil {
|
||||
s.logger.Printf("[WARN] [web] offbox orphaned-repo reset failed: %v", err)
|
||||
}
|
||||
}()
|
||||
offboxRedirect(w, r, "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).", false)
|
||||
}
|
||||
|
||||
// offboxStatusHandler (Part C) is the poll source for the remote-backup run status — the page polls it
|
||||
// after "Távoli mentés most" and flips to the terminal state without a manual reload. Session-auth'd.
|
||||
func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
t := s.settings.GetOffboxTarget()
|
||||
resp := map[string]any{"status": "", "snapshots": 0, "orphaned": false}
|
||||
if t != nil {
|
||||
resp["status"] = t.LastStatus
|
||||
resp["snapshots"] = t.SnapshotCount
|
||||
resp["last_run"] = t.LastRun
|
||||
resp["last_duration"] = t.LastDuration
|
||||
resp["repo_size_human"] = t.RepoSizeHuman
|
||||
resp["last_error"] = t.LastError
|
||||
resp["orphaned"] = t.RepoState == "orphaned"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// offboxRestoreHandler restores an app's off-box data to an on-data-drive scratch dir (§7, F-A1;
|
||||
// non-destructive — does NOT overwrite live data). mode=unit (default) restores the recovery unit
|
||||
// only; mode=full is size-gated and two-step (first POST computes the size + headroom and redirects
|
||||
|
||||
@@ -392,6 +392,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.offboxToggleHandler(w, r)
|
||||
case path == "/backup/offbox/run" && r.Method == http.MethodPost:
|
||||
s.offboxRunHandler(w, r)
|
||||
case path == "/backup/offbox/reset" && r.Method == http.MethodPost:
|
||||
s.offboxResetHandler(w, r)
|
||||
case path == "/backup/offbox/status" && r.Method == http.MethodGet:
|
||||
s.offboxStatusHandler(w, r)
|
||||
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
|
||||
s.offboxRestoreHandler(w, r)
|
||||
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{{if .Offbox}}
|
||||
<div class="stats-grid backup-page-cards">
|
||||
<div class="stat-card {{if eq .Offbox.LastStatus "error"}}stat-warn{{end}}">
|
||||
<div class="stat-value" style="font-size:1.15rem">{{if eq .Offbox.LastStatus "ok"}}✓ Rendben{{else if eq .Offbox.LastStatus "error"}}✗ Hiba{{else if eq .Offbox.LastStatus "running"}}Fut…{{else}}–{{end}}</div>
|
||||
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">{{if eq .Offbox.LastStatus "ok"}}✓ Rendben{{else if eq .Offbox.LastStatus "error"}}✗ Hiba{{else if eq .Offbox.LastStatus "running"}}Fut…{{else}}–{{end}}</div>
|
||||
<div class="stat-label">Utolsó távoli mentés{{if .Offbox.LastRun}}<br><span class="relative-time">{{timeAgoStr .Offbox.LastRun}}</span>{{end}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -53,6 +53,23 @@
|
||||
{{/* Part E: display pick — a stale zero-toggle warning is replaced once the selection
|
||||
changed (neutral color: the replacement is reassurance, not a deviation). */}}
|
||||
{{if .OffboxWarningDisplay}}<p class="form-hint"{{if eq .OffboxWarningDisplay .Offbox.LastWarning}} style="color:var(--warn)"{{end}}>{{.OffboxWarningDisplay}}</p>{{end}}
|
||||
{{/* v0.142.0 offsite-repo continuity: the ORPHANED card replaces the raw restic error banner. The
|
||||
remote holds backups written under a previous, no-longer-available key (reinstall shape). A
|
||||
reset moves the old history aside (never deletes) and starts a fresh repo under the current key. */}}
|
||||
{{if eq .Offbox.RepoState "orphaned"}}
|
||||
<div class="card" style="border-left:3px solid var(--crit,#e5484d);margin:.75rem 0;padding:.75rem 1rem" id="offbox-orphan-card">
|
||||
<p style="margin:0 0 .35rem;font-weight:600">A távoli tároló másik kulccsal készült mentéseket tartalmaz</p>
|
||||
<p class="form-hint" style="margin:0 0 .5rem">A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek — a hozzájuk tartozó helyreállítási kóddal később visszaállíthatók lehetnek.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="orphan-reveal" onclick="var c=document.getElementById('orphan-confirm');c.style.display='block';this.style.display='none'">Új távoli mentés indítása…</button>
|
||||
<div id="orphan-confirm" style="display:none;margin-top:.6rem">
|
||||
<p class="form-hint" style="margin:0 0 .5rem">A régi előzmény <strong>félretéve marad</strong> (nem törlődik), és a hozzá tartozó helyreállítási kóddal később visszaállítható lehet. Egy üres, új tároló jön létre a mostani kulccsal, és a következő mentés ide készül.</p>
|
||||
<form method="POST" action="/backup/offbox/reset" style="display:inline">{{.CSRFField}}
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Megerősítés — új távoli mentés indítása</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{/* Escrow ceremony card (v0.127.0): the customer-driveable wizard replaced the manual-confirm
|
||||
button (that deprecated endpoint stays for legacy blobs; its button is gone). States:
|
||||
awaiting (v0.138.0: ceremony done, hub confirm pending) → info; awaiting timed out → warn +
|
||||
@@ -154,5 +171,25 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
/* Part C (v0.142.0): while a remote backup is in flight the status card shows "Fut…" — poll the run
|
||||
status and reload once it reaches a terminal state, so the customer sees Rendben/Hiba + fresh
|
||||
numbers WITHOUT a manual reload. Polls only when a run is running; stops (page reload) at terminal. */
|
||||
(function(){
|
||||
var v=document.getElementById('offbox-status-value');
|
||||
if(!v || v.textContent.indexOf('Fut')<0) return; // not running → nothing to poll
|
||||
var timer=setInterval(function(){
|
||||
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d && d.status==='running') return; // still running → keep polling
|
||||
clearInterval(timer);
|
||||
location.reload(); // terminal → re-render (fresh numbers, warnings, or the orphan card)
|
||||
})
|
||||
.catch(function(){ /* transient — keep polling */ });
|
||||
}, 3000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user