R-729 + R-545: the household can remove its own off-site target („Távoli mentési cél törlése")

POST /backup/offbox/clear (reveal-then-confirm) forgets the target and deletes ssh_key + known_hosts;
nothing on the target is touched. repo_password is kept whenever anything could depend on it (hub
sealed package — the R-241 rule; escrowed; a successful run; snapshots) and deleted only otherwise.
Refused for the Felhom tier (rclone-pinned), while the single-flight is held, and while an
abandonment countdown runs. i18n parity fixtures of backups_remote gain the additive block only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:24:28 +02:00
parent 6f1ba1fe43
commit 27b373b93c
23 changed files with 612 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
package backup
import (
"errors"
"fmt"
"os"
)
// CLEARING AN OFF-SITE TARGET — R-729 / R-545 (the household's „Távoli mentési cél törlése" press).
//
// Before this, a target once saved could be edited or switched off but never removed: the page
// refuses an empty address, and the only other route (`/backup/offbox/reset`) means "start a new
// remote backup, set the old history aside", and only for an ORPHANED store. A household that tried
// its own NAS and gave up kept the host, user, path, SSH key and known-host line for ever.
//
// WHAT THE PRESS DOES: forgets the target (settings) and deletes the transport secrets (`ssh_key`,
// `known_hosts`) from `data/offbox/`. It NEVER touches the repository on the target — not one remote
// command runs (R-729: "never the repository").
//
// WHAT IT DOES WITH THE REPOSITORY PASSWORD — the one secret that is not transport:
// it is KEPT whenever anything could depend on it, and deleted only when nothing can:
// - the hub holds a sealed recovery package for this box (the R-241 rule: a box without a password
// and with a held package refuses to mint, so deleting the key here would strand the next target
// in the „awaiting recovery key" holding state — and the history the package protects would lose
// its on-box key);
// - the escrow was confirmed (`escrowed`) — the hub's package seals exactly this key;
// - the target ever completed a run or holds snapshots — history exists on the target that only
// this key opens.
// Keeping it is harmless: a later target reuses it (WriteOffboxSecrets never re-mints over an
// existing file), and the escrow then matches by hash with nothing to redo.
//
// WHAT IT REFUSES (each leaves every file and setting untouched):
// - the hub-provisioned Felhom tier (Transport rclone-pinned) — that target is Felhom's to remove,
// not the page's; the hub would re-apply it anyway;
// - while a backup/restore/check holds the single-flight — a run reading the key file mid-delete;
// - while an abandonment countdown is running or awaiting the hub (R-241 / decision 74) — the
// countdown's state lives on the target record, and clearing it would strand the scheduled
// deletion of the set-aside history with nobody left to run or cancel it.
//
// Pinned by internal/backup/offbox_clear_r729_test.go.
// Refusal sentinels — the handler maps each to its own household sentence.
var (
ErrOffboxClearNoTarget = errors.New("offbox clear: no off-site target is configured")
ErrOffboxClearHubTier = errors.New("offbox clear: the Felhom-provided off-site tier cannot be removed from the box")
ErrOffboxClearBusy = errors.New("offbox clear: a backup operation is running")
ErrOffboxClearAbandonOn = errors.New("offbox clear: a set-aside history deletion is scheduled")
)
// OffboxClearResult says what the press did with the one secret it may keep.
type OffboxClearResult struct {
// KeptRepoPassword is true when the repository password stayed on disk (see the header for why).
KeptRepoPassword bool
}
// offboxRepoPasswordNeeded reports whether anything could still depend on the on-box repository
// password. Fail-safe direction: every input that MIGHT mean "something depends on it" keeps it.
func (m *Manager) offboxRepoPasswordNeeded(escrowState, lastSuccess string, snapshots int) bool {
return m.sealedPackageHeld() || escrowState == "escrowed" || lastSuccess != "" || snapshots > 0
}
// ClearOffboxTarget forgets the household's off-site target. See the file header.
func (m *Manager) ClearOffboxTarget() (OffboxClearResult, error) {
var res OffboxClearResult
t := m.settings.GetOffboxTarget()
if t == nil {
return res, ErrOffboxClearNoTarget
}
if t.Pinned() {
return res, ErrOffboxClearHubTier
}
if ab := m.AbandonStatus(); ab.Active || ab.PurgeRequested || ab.HubPending {
return res, ErrOffboxClearAbandonOn
}
// Hold the single-flight for the whole clear, so no run can start between the check and the delete.
if err := m.acquireRunning(); err != nil {
return res, ErrOffboxClearBusy
}
defer m.releaseRunning()
res.KeptRepoPassword = m.offboxRepoPasswordNeeded(t.EscrowState, t.LastSuccess, t.SnapshotCount)
// Files FIRST, settings LAST: a failure part-way leaves a target that is still listed (and no
// longer runnable — OffboxConfigured needs the key file), so the press can simply be repeated.
// The reverse order would leave secrets on disk with no target left to clear them through.
files := []string{m.offboxKeyPath(), m.offboxKnownHosts()}
if !res.KeptRepoPassword {
files = append(files, m.offboxPwPath())
}
for _, f := range files {
if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
return res, fmt.Errorf("offbox clear: remove %s: %w", f, err)
}
}
if err := m.settings.SetOffboxTarget(nil); err != nil {
return res, fmt.Errorf("offbox clear: save settings: %w", err)
}
m.logger.Printf("[INFO] [offbox] off-site target %s@%s:%s CLEARED by the household (transport secrets deleted; repository password kept=%v; nothing on the target was touched)",
t.User, t.Host, t.RepoPath, res.KeptRepoPassword)
return res, nil
}
@@ -0,0 +1,160 @@
package backup
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-729 / R-545 — the household can remove its own off-site target; the repository is never touched,
// and the repository password is kept whenever anything could depend on it. See offbox_clear.go.
func clearFixture(t *testing.T, hubHoldsPackage bool, tgt *settings.OffboxTarget) (*Manager, *settings.Settings, string) {
t.Helper()
m, sett, pwPath := mintGuardManager(t, false) // mint first, THEN set the package fact
if err := sett.SetOffboxTarget(tgt); err != nil {
t.Fatal(err)
}
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatal(err)
}
if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil {
t.Fatal(err)
}
// Any remote command would go through the runner — none may run.
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
t.Errorf("R-729: clearing a target ran restic %v — the repository must never be touched", args)
return nil, errors.New("forbidden")
})
m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
t.Errorf("R-729: clearing a target ran a remote command %q — the repository must never be touched", remoteCmd)
return nil, errors.New("forbidden")
})
return m, sett, filepath.Dir(pwPath)
}
func ownNAS() *settings.OffboxTarget {
return &settings.OffboxTarget{Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", EscrowState: "pending"}
}
// The headline: a never-used own-NAS target is forgotten and every secret it minted is gone.
func TestR729_Clear_ForgetsTargetAndDeletesSecrets(t *testing.T) {
m, sett, dir := clearFixture(t, false, ownNAS())
res, err := m.ClearOffboxTarget()
if err != nil {
t.Fatalf("clear: %v", err)
}
if sett.GetOffboxTarget() != nil {
t.Fatal("R-729: the target is still in settings after the clear")
}
for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} {
if exists(filepath.Join(dir, f)) {
t.Errorf("R-545: %s is still on disk after clearing a target nothing depends on", f)
}
}
if res.KeptRepoPassword {
t.Error("KeptRepoPassword=true for a target with no escrow, no history and no hub package")
}
// Idempotent refusal: nothing left to clear.
if _, err := m.ClearOffboxTarget(); !errors.Is(err, ErrOffboxClearNoTarget) {
t.Errorf("second clear: want ErrOffboxClearNoTarget, got %v", err)
}
}
// The password is the one secret that may protect history — kept whenever anything depends on it.
func TestR729_Clear_KeepsRepoPasswordWhenSomethingDependsOnIt(t *testing.T) {
cases := []struct {
name string
pkg bool
mutate func(*settings.OffboxTarget)
}{
{"hub holds a sealed package (R-241)", true, func(*settings.OffboxTarget) {}},
{"escrow confirmed", false, func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }},
{"a run succeeded once", false, func(o *settings.OffboxTarget) { o.LastSuccess = "2026-10-01T02:00:00Z" }},
{"snapshots exist", false, func(o *settings.OffboxTarget) { o.SnapshotCount = 3 }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
tgt := ownNAS()
c.mutate(tgt)
m, sett, dir := clearFixture(t, c.pkg, tgt)
res, err := m.ClearOffboxTarget()
if err != nil {
t.Fatalf("clear: %v", err)
}
if !exists(filepath.Join(dir, "repo_password")) {
t.Fatal("R-241: the repository password was deleted although something depends on it")
}
if !res.KeptRepoPassword {
t.Error("KeptRepoPassword=false although the key was kept")
}
if exists(filepath.Join(dir, "ssh_key")) || exists(filepath.Join(dir, "known_hosts")) {
t.Error("the transport secrets must still be deleted")
}
if sett.GetOffboxTarget() != nil {
t.Error("the target must still be forgotten")
}
})
}
}
// Refusals leave every file and the setting exactly as they were.
func TestR729_Clear_Refusals(t *testing.T) {
check := func(t *testing.T, m *Manager, sett *settings.Settings, dir string, want error) {
t.Helper()
if _, err := m.ClearOffboxTarget(); !errors.Is(err, want) {
t.Fatalf("want %v, got %v", want, err)
}
if sett.GetOffboxTarget() == nil {
t.Error("a refused clear removed the target")
}
for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} {
if !exists(filepath.Join(dir, f)) {
t.Errorf("a refused clear deleted %s", f)
}
}
}
t.Run("hub tier", func(t *testing.T) {
tgt := ownNAS()
tgt.Transport = settings.TransportRclonePinned
m, sett, dir := clearFixture(t, false, tgt)
check(t, m, sett, dir, ErrOffboxClearHubTier)
})
t.Run("run in flight", func(t *testing.T) {
m, sett, dir := clearFixture(t, false, ownNAS())
if err := m.acquireRunning(); err != nil {
t.Fatal(err)
}
defer m.releaseRunning()
check(t, m, sett, dir, ErrOffboxClearBusy)
})
t.Run("abandonment countdown", func(t *testing.T) {
tgt := ownNAS()
tgt.AbandonStartedAt = time.Now().UTC().Format(time.RFC3339)
tgt.AbandonAt = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
tgt.AbandonRepoPath = "/srv/repo.orphaned-1"
m, sett, dir := clearFixture(t, false, tgt)
check(t, m, sett, dir, ErrOffboxClearAbandonOn)
})
t.Run("hub-held deletion", func(t *testing.T) {
tgt := ownNAS()
tgt.AbandonHubDueAt = time.Now().Add(3 * 24 * time.Hour).UTC().Format(time.RFC3339)
m, sett, dir := clearFixture(t, false, tgt)
check(t, m, sett, dir, ErrOffboxClearAbandonOn)
})
}
// The single-flight is released after a clear — a later run is not wedged.
func TestR729_Clear_ReleasesSingleFlight(t *testing.T) {
m, _, _ := clearFixture(t, false, ownNAS())
if _, err := m.ClearOffboxTarget(); err != nil {
t.Fatal(err)
}
if m.IsRunning() {
t.Fatal("the clear left the single-flight held")
}
}