diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 52b6d52..193e39f 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -261,6 +261,10 @@ func main() { // O4: restore-from-unit generates a replacement for an unrecoverable RESETTABLE secret // (data-keys stay fail-closed) so the app redeploys with a fresh credential, not a blank one. backupMgr.SetSecretGenerator(stackMgr.GenerateSecretForField) + // R-7b: after a shares restore re-adds definitions to the registry, smb.conf must be + // re-rendered or the restored shares exist on paper but are not exported. A seam rather than a + // direct call — the backup package must not depend on the stacks package. + backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba) } // SLICE 2: the offsite apply-bridge — on startup (async, non-blocking) reconcile the hub-served offsite @@ -385,7 +389,7 @@ func main() { healthInterval = 5 * time.Minute } sched.Every("system-health", healthInterval, func(ctx context.Context) error { - healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger) + healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), sett.GetSMBSettings(), logger) // Self-heal the base stack: call unconditionally every tick. EnsureBaseStack is single-flight // + idempotent (skips running stacks ⇒ a cheap 3× docker-inspect no-op when healthy), so there // is no need to couple to the health-report issue strings. Runs in a goroutine — never blocks @@ -629,7 +633,7 @@ func main() { pushInterval = 15 * time.Minute } sched.Every("hub-report", pushInterval, func(ctx context.Context) error { - r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub if err := hubPusher.Push(r); err != nil { return err @@ -735,7 +739,7 @@ func main() { // Hub report if hubPusher != nil { if cfg.Hub.Enabled { - r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub var pushErr error for attempt := 1; attempt <= 3; attempt++ { @@ -803,7 +807,7 @@ func main() { // Initial alert refresh (so alerts appear immediately, not after first 5min health check) go func() { - report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger) + report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), sett.GetSMBSettings(), logger) alertMgr.Refresh(report, cfg, backupMgr, false, "") }() @@ -817,7 +821,7 @@ func main() { var reportTrigger *report.Trigger if hubPusher != nil && cfg.Hub.Enabled { fireReport := func() error { - rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub return hubPusher.Push(rep) } @@ -960,7 +964,7 @@ func main() { dc := &web.DebugCallbacks{} if hubPusher != nil { dc.TriggerHubReportPush = func() error { - r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub return hubPusher.Push(r) } diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index e3e1459..8a9c373 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -101,6 +101,12 @@ type Manager struct { // samba named volume (`docker exec -i … tar xf -`). Nil → the real defaultSharesPassdbRestore. sharesPassdbRestore func(tar []byte) error + // sharesReconcile (R-7b), if set, re-renders and applies the samba stack after a shares restore + // re-adds definitions to the registry (wired in main.go to stacks.Manager.ReconcileSamba). It is a + // SEAM rather than a direct call because the backup package must not depend on the stacks package. + // Nil → the registry is updated and a WARN says smb.conf will catch up on the next health tick. + sharesReconcile func() error + // tier2SSDFits (3b) — the SSD-headroom predicate seam, overridable in tests (system.GetDiskUsage is // Linux-only → nil on the Windows test host, which would always refuse the SSD branch). Nil → the // real tier2FitsSystemDrive. diff --git a/controller/internal/backup/shares_restore.go b/controller/internal/backup/shares_restore.go new file mode 100644 index 0000000..2747b5e --- /dev/null +++ b/controller/internal/backup/shares_restore.go @@ -0,0 +1,321 @@ +package backup + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" +) + +// Shares restore — R-7b Part 4. A SIBLING of the per-app scratch/place flow in offbox_restore.go, +// mirroring its shape (restore to an on-data-drive scratch first, then a separate, deliberate +// place-to-live merge) without touching it. +// +// Three things come back, in this order of importance: +// 1. the FILES — placed missing-only into each share's live folder, never overwriting; +// 2. the DEFINITIONS — merged into the share registry so the „Megosztás" page is whole again; +// 3. the CREDENTIAL — best-effort, into the samba named volume, so the household need not re-set it. +// +// The load-bearing guard is the PREFIX ASSERT: a destination is only written when it resolves +// strictly inside a REGISTERED, LIVE storage root. A snapshot is untrusted input for this purpose — +// it was written by an older version of this box, possibly with a different drive layout — so a path +// that no longer sits under a live root is refused rather than created. + +// SetSharesReconciler wires the post-restore samba re-render (main.go → stacks.ReconcileSamba). +func (m *Manager) SetSharesReconciler(fn func() error) { m.sharesReconcile = fn } + +// SetSharesPassdbRestorer overrides the passdb restore exec (tests). +func (m *Manager) SetSharesPassdbRestorer(fn func(tar []byte) error) { m.sharesPassdbRestore = fn } + +func (m *Manager) sharesPassdbRestorer() func([]byte) error { + if m.sharesPassdbRestore != nil { + return m.sharesPassdbRestore + } + return defaultSharesPassdbRestore +} + +// defaultSharesPassdbRestore untars a captured passdb archive back into the samba named volume. It +// writes ONLY into infra.SambaPassdbMount inside the samba container — never onto the host — so a +// malformed archive cannot reach anything outside the volume it came from. +func defaultSharesPassdbRestore(tar []byte) error { + cmd := exec.Command("docker", "exec", "-i", infra.SambaContainerName, + "tar", "xf", "-", "-C", infra.SambaPassdbMount) + cmd.Stdin = bytes.NewReader(tar) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("passdb restore: %s: %w", truncate(out), err) + } + return nil +} + +// sharesRestoreScratchDir returns the on-DATA-DRIVE scratch for the shares restore. Never the +// controller data dir (the F-A1 rootfs-filler lesson) and never network storage when a local drive +// exists (the F-6C-1 ownership-fidelity lesson). +func (m *Manager) sharesRestoreScratchDir() (scratch, nsRoot string, err error) { + if m.settings == nil { + return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz") + } + pick := func(networkOK bool) (string, string, bool) { + for _, sp := range m.settings.GetSchedulableStoragePaths() { + if strings.TrimSpace(sp.Path) == "" || (!networkOK && sp.IsNetwork()) { + continue + } + nr := m.namespaceRoot(sp.Path) + return filepath.Join(nr, "backups", "offsite-restore", SharesPseudoStack), nr, true + } + return "", "", false + } + if s, nr, ok := pick(false); ok { + return s, nr, nil + } + if s, nr, ok := pick(true); ok { + m.logger.Printf("[WARN] [shares] restore scratch on network storage — ownership fidelity not guaranteed under squash") + return s, nr, nil + } + return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz") +} + +// RestoreSharesScratch restores the latest `_shares` snapshot into the scratch dir. Non-destructive: +// it never touches a live share folder, the registry, or the credential — PlaceSharesRestore is the +// deliberate second action that does. +func (m *Manager) RestoreSharesScratch(ctx context.Context) error { + if !m.OffboxConfigured() { + return fmt.Errorf("a távoli mentés nincs beállítva") + } + scratch, nsRoot, err := m.sharesRestoreScratchDir() + if err != nil { + return err + } + if free := m.offboxFree()(nsRoot); free > 0 && free < offboxUnitOnlyFreeFloor { + return fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).", + humanizeBytes(offboxUnitOnlyFreeFloor), humanizeBytes(free)) + } + id, _, err := m.offboxLatestSnapshot(ctx, SharesPseudoStack) + if err != nil { + return fmt.Errorf("nincs visszaállítható megosztás-mentés: %w", err) + } + if err := os.MkdirAll(scratch, 0o755); err != nil { + return fmt.Errorf("restore dir: %w", err) + } + t := m.settings.GetOffboxTarget() + base, env := m.offboxBaseArgs(t) + rctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout) + defer cancel() + m.unlockStale(rctx, base, env) + out, rerr := m.resticStep(rctx, env, base, "restore:"+SharesPseudoStack, "restore", id, "--target", scratch) + if rerr != nil { + return fmt.Errorf("a megosztások visszaállítása sikertelen: %w: %s", rerr, truncate(out)) + } + m.logger.Printf("[INFO] [shares] restored snapshot %s → %s", id, scratch) + return nil +} + +// SharesScratchReady reports whether a completed shares scratch exists (gates the place action). +func (m *Manager) SharesScratchReady() bool { + scratch, _, err := m.sharesRestoreScratchDir() + if err != nil { + return false + } + entries, rErr := os.ReadDir(scratch) + return rErr == nil && len(entries) > 0 +} + +// SharesRestoreResult is what the flash message reports back to the customer. +type SharesRestoreResult struct { + FilesRestored int // files merged into live share folders + SharesPlaced []string // share folders whose files were merged + DefinitionsAdded []string // share definitions re-added to the registry + DefinitionsKept []string // definitions skipped because a live share already owns the name + Refused []string // definitions refused: destination is not under a live storage root + PasswordRestored bool // the household SMB credential was put back +} + +// liveShareRootOK prefix-asserts a destination against the REGISTERED, LIVE storage roots. It +// requires a STRICT descendant: equal-to-the-root is refused too, because placing a share's contents +// at a drive root would scatter restored files across the whole drive. A `..` segment is refused +// outright rather than relying on Clean, so a traversal attempt is visible in the logs. +func (m *Manager) liveShareRootOK(dst string) bool { + if m.settings == nil || strings.TrimSpace(dst) == "" { + return false + } + clean := filepath.Clean(dst) + for _, seg := range strings.Split(filepath.ToSlash(dst), "/") { + if seg == ".." { + return false + } + } + p := filepath.ToSlash(clean) + for _, sp := range m.settings.GetStoragePaths() { + if sp.Decommissioned || sp.Disconnected { + continue + } + root := filepath.ToSlash(filepath.Clean(sp.Path)) + if root == "" || root == "/" { + continue + } + if strings.HasPrefix(p, root+"/") { + return true + } + } + return false +} + +// scratchJoin reconstructs an absolute captured path UNDER a restore scratch — restic restores with +// the absolute source structure preserved, so /mnt/hdd_1/dokumentumok lands at +// /mnt/hdd_1/dokumentumok. The volume name and leading separator are stripped explicitly +// rather than relying on filepath.Join, which on a non-POSIX host would splice a drive letter into +// the middle of the path and produce an unopenable name. +func scratchJoin(scratch, abs string) string { + rel := abs + if vol := filepath.VolumeName(rel); vol != "" { + rel = rel[len(vol):] + } + rel = strings.TrimLeft(filepath.ToSlash(rel), "/") + return filepath.Join(scratch, filepath.FromSlash(rel)) +} + +// readSharesManifestFrom reads the manifest out of a restored scratch tree. +func (m *Manager) readSharesManifestFrom(scratch string) (SharesManifest, error) { + var mf SharesManifest + p := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesManifestName) + blob, err := os.ReadFile(p) + if err != nil { + return mf, fmt.Errorf("a mentésben nincs megosztás-leíró: %w", err) + } + if err := json.Unmarshal(blob, &mf); err != nil { + return mf, fmt.Errorf("a megosztás-leíró olvashatatlan: %w", err) + } + return mf, nil +} + +// PlaceSharesRestore places a completed shares scratch into live locations: files first (missing-only +// merge, never overwriting), then the definitions (existing live definitions WIN on a name conflict — +// a restore must not silently flip a live share's read-only or cloud setting), then the samba +// re-render, then the credential. Single-flight. +func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult, error) { + var res SharesRestoreResult + if err := m.acquireRunning(); err != nil { + return res, fmt.Errorf("egy másik mentési/visszaállítási művelet már fut") + } + defer m.releaseRunning() + + scratch, _, err := m.sharesRestoreScratchDir() + if err != nil { + return res, err + } + if _, sErr := os.Stat(scratch); sErr != nil { + return res, fmt.Errorf("nincs előkészített visszaállítás — futtass előbb egy megosztás-visszaállítást") + } + mf, err := m.readSharesManifestFrom(scratch) + if err != nil { + return res, err + } + + // Live registry, indexed case-insensitively (AddSMBShare's own collision rule). + live := map[string]bool{} + if m.settings != nil { + for _, sh := range m.settings.GetSMBShares() { + live[strings.ToLower(sh.Name)] = true + } + } + copier := m.placeCopier() + for _, sh := range mf.Shares { + // THE PREFIX ASSERT. The snapshot's path is untrusted layout input; a destination that is not + // strictly inside a live registered root is refused, never created. + if !m.liveShareRootOK(sh.Path) { + m.logger.Printf("[WARN] [shares] restore refused for %s — destination is not under a live storage root", sh.Name) + res.Refused = append(res.Refused, sh.Name) + continue + } + src := scratchJoin(scratch, sh.Path) + if _, sErr := os.Stat(src); sErr == nil { + n, cErr := copier(src, sh.Path) + if cErr != nil { + return res, fmt.Errorf("a(z) „%s” megosztás fájljainak visszaállítása sikertelen: %w", sh.Name, cErr) + } + res.FilesRestored += n + res.SharesPlaced = append(res.SharesPlaced, sh.Name) + } else { + // A definitions-only snapshot (the quota-degraded shape) legitimately has no file tree. + m.logger.Printf("[DEBUG] [shares] no restored file tree for %s — definitions-only snapshot", sh.Name) + } + // Definitions: existing live share WINS. Restoring must never silently change a share the + // household is using right now. + if live[strings.ToLower(sh.Name)] { + res.DefinitionsKept = append(res.DefinitionsKept, sh.Name) + continue + } + if m.settings != nil { + if aErr := m.settings.AddSMBShare(sh); aErr != nil { + m.logger.Printf("[WARN] [shares] could not re-add definition %s: %v", sh.Name, aErr) + continue + } + res.DefinitionsAdded = append(res.DefinitionsAdded, sh.Name) + } + } + + // Re-render smb.conf so the restored definitions are actually exported. + if len(res.DefinitionsAdded) > 0 { + if m.sharesReconcile == nil { + m.logger.Printf("[WARN] [shares] no samba reconciler wired — smb.conf will catch up on the next health tick") + } else if rErr := m.sharesReconcile(); rErr != nil { + m.logger.Printf("[WARN] [shares] samba re-render after restore failed: %v", rErr) + } + } + + // Credential, best-effort and last: the files and definitions are the load-bearing parts, and + // re-setting an SMB password is a cheap, well-signposted UX step. + passdb := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesPassdbName) + if blob, rErr := os.ReadFile(passdb); rErr == nil && len(blob) > 0 { + if pErr := m.sharesPassdbRestorer()(blob); pErr != nil { + m.logger.Printf("[WARN] [shares] credential restore failed — the SMB password must be re-set: %v", pErr) + } else { + res.PasswordRestored = true + if m.settings != nil { + if sErr := m.settings.SetSMBUserSet(true); sErr != nil { + m.logger.Printf("[WARN] [shares] persist user-set flag after credential restore failed: %v", sErr) + } + } + m.logger.Printf("[INFO] [shares] household credential restored into the sharing service") + } + } + + if rmErr := os.RemoveAll(scratch); rmErr != nil { + m.logger.Printf("[WARN] [shares] scratch cleanup failed (harmless): %v", rmErr) + } + m.logger.Printf("[INFO] [shares] restore placed: %d file(s), %d definition(s) re-added, %d kept, %d refused, credential=%v", + res.FilesRestored, len(res.DefinitionsAdded), len(res.DefinitionsKept), len(res.Refused), res.PasswordRestored) + return res, nil +} + +// FlashMessage renders the Hungarian summary the „Megosztások visszaállítása" action flashes back. +func (r SharesRestoreResult) FlashMessage() string { + var parts []string + parts = append(parts, fmt.Sprintf("%s visszaállítva: %d fájl, %d megosztás-beállítás.", + SharesDisplayName, r.FilesRestored, len(r.DefinitionsAdded))) + for _, n := range r.DefinitionsKept { + parts = append(parts, fmt.Sprintf("A(z) %s megosztás beállítása már létezik — a meglévő maradt.", n)) + } + if len(r.Refused) > 0 { + parts = append(parts, fmt.Sprintf("Nem állítható vissza (a mappa nincs élő adatmeghajtón): %s.", + strings.Join(r.Refused, ", "))) + } + if !r.PasswordRestored { + parts = append(parts, "A megosztás jelszavát újra meg kell adni.") + } + return strings.Join(parts, " ") +} + +// SharesRegistryCount is a small helper for the page (how many shares the registry holds). +func (m *Manager) SharesRegistryCount() int { + if m.settings == nil { + return 0 + } + return len(m.settings.GetSMBShares()) +} diff --git a/controller/internal/backup/shares_restore_test.go b/controller/internal/backup/shares_restore_test.go new file mode 100644 index 0000000..1d0d67a --- /dev/null +++ b/controller/internal/backup/shares_restore_test.go @@ -0,0 +1,268 @@ +package backup + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-7b Part 4 — shares restore. Scenario D's contract: files come back byte-identical into the LIVE +// share folder, a deleted definition reappears in the registry, a definition that still exists is +// left alone, and the place step never writes outside a registered live root. + +// seedSharesScratch lays down a completed restore scratch: the payload (manifest + credential) plus +// a restored file tree for each named share, mirroring what restic's absolute-path restore produces. +func seedSharesScratch(t *testing.T, env *sharesEnv, mf SharesManifest, files map[string]string) string { + t.Helper() + scratch, _, err := env.m.sharesRestoreScratchDir() + if err != nil { + t.Fatal(err) + } + payload := scratchJoin(scratch, env.m.sharesPayloadDir()) + if err := os.MkdirAll(payload, 0o755); err != nil { + t.Fatal(err) + } + blob := mustJSON(t, mf) + if err := os.WriteFile(filepath.Join(payload, sharesManifestName), blob, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(payload, sharesPassdbName), []byte("FAKE-PASSDB-TAR"), 0o600); err != nil { + t.Fatal(err) + } + for livePath, content := range files { + p := scratchJoin(scratch, livePath) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return scratch +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := jsonMarshalIndent(v) + if err != nil { + t.Fatal(err) + } + return b +} + +// wirePlaceSeams installs a real (missing-only) copier and a fake passdb restorer. +func wirePlaceSeams(env *sharesEnv, passdbCalls *int) { + env.m.offboxPlaceCopier = func(src, dst string) (int, error) { + n := 0 + err := filepath.Walk(src, func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return err + } + rel, rErr := filepath.Rel(src, p) + if rErr != nil { + return rErr + } + target := filepath.Join(dst, rel) + if _, sErr := os.Stat(target); sErr == nil { + return nil // missing-only: never overwrite + } + b, rErr := os.ReadFile(p) + if rErr != nil { + return rErr + } + if mErr := os.MkdirAll(filepath.Dir(target), 0o755); mErr != nil { + return mErr + } + n++ + return os.WriteFile(target, b, 0o644) + }) + return n, err + } + env.m.sharesPassdbRestore = func([]byte) error { *passdbCalls++; return nil } +} + +// Scenario D: the round trip. A deleted file returns byte-identical; a deleted definition reappears +// and triggers the samba re-render; a definition that still exists is KEPT (a restore must never +// silently flip a live share's settings). +func TestSharesRestoreRoundTrip(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + keptPath := env.addShare(t, "hdd_1", "marad", true) + deletedPath := filepath.Join(env.drives["hdd_1"], "torolt") + + mf := SharesManifest{ + Version: sharesManifestVersion, ServerName: "FELHOM", + Shares: []settings.SMBShare{ + // Still live — its definition must be kept, not overwritten (note the flipped ReadOnly: + // if the merge preferred the snapshot, this would silently change a live share). + {Name: "marad", Path: keptPath, ReadOnly: true, Offsite: false, CreatedAt: "2020-01-01T00:00:00Z"}, + // Deleted from the registry — must come back. + {Name: "torolt", Path: deletedPath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}, + }, + } + seedSharesScratch(t, env, mf, map[string]string{ + filepath.Join(deletedPath, "fontos.txt"): "EREDETI-TARTALOM", + filepath.Join(keptPath, "marad.txt"): "SNAPSHOT-VERZIO", + }) + + var reconciled, passdbCalls int + env.m.SetSharesReconciler(func() error { reconciled++; return nil }) + wirePlaceSeams(env, &passdbCalls) + + res, err := env.m.PlaceSharesRestore(t.Context()) + if err != nil { + t.Fatalf("PlaceSharesRestore: %v", err) + } + + // 1. File back, byte-identical, in the LIVE folder. + b, rErr := os.ReadFile(filepath.Join(deletedPath, "fontos.txt")) + if rErr != nil || string(b) != "EREDETI-TARTALOM" { + t.Errorf("restored file wrong/missing: %q, %v", b, rErr) + } + // 2. Missing-only: the live file must NOT be clobbered by the snapshot version. + b, _ = os.ReadFile(filepath.Join(keptPath, "marad.txt")) + if string(b) != "content-of-marad" { + t.Errorf("a live file was overwritten by the restore: %q", b) + } + // 3. Deleted definition reappears; live definition kept unchanged. + if !containsStr(res.DefinitionsAdded, "torolt") { + t.Errorf("deleted definition did not reappear: %+v", res) + } + if !containsStr(res.DefinitionsKept, "marad") { + t.Errorf("live definition should be reported as kept: %+v", res) + } + for _, sh := range env.sett.GetSMBShares() { + if sh.Name == "marad" && sh.ReadOnly { + t.Error("a restore silently flipped a LIVE share's ReadOnly setting") + } + } + // 4. smb.conf re-rendered so the restored share is actually exported. + if reconciled != 1 { + t.Errorf("ReconcileSamba should run exactly once after adding definitions, ran %d", reconciled) + } + // 5. Credential restored best-effort. + if passdbCalls != 1 || !res.PasswordRestored { + t.Errorf("credential restore did not run: calls=%d result=%v", passdbCalls, res.PasswordRestored) + } +} + +// THE PLACE-GUARD RED-PROOF TARGET. A manifest whose share path escapes every registered live root +// must be REFUSED with zero writes. Red-proof: make liveShareRootOK return true unconditionally and +// this test fails (the traversal destination gets created). +func TestSharesRestoreRefusesDestinationOutsideLiveRoots(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + outside := filepath.Join(env.tmp, "kivul", "gonosz") + traversal := filepath.Join(env.drives["hdd_1"], "..", "eszkeipel") + + mf := SharesManifest{ + Version: sharesManifestVersion, ServerName: "FELHOM", + Shares: []settings.SMBShare{ + {Name: "kivul", Path: outside, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}, + {Name: "traverz", Path: traversal, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}, + }, + } + seedSharesScratch(t, env, mf, map[string]string{ + filepath.Join(outside, "x.txt"): "SHOULD-NEVER-LAND", + filepath.Join(traversal, "y.txt"): "SHOULD-NEVER-LAND", + }) + + var passdbCalls int + wirePlaceSeams(env, &passdbCalls) + + res, err := env.m.PlaceSharesRestore(t.Context()) + if err != nil { + t.Fatalf("a refused destination must be reported, not error out: %v", err) + } + if len(res.Refused) != 2 { + t.Errorf("both out-of-root destinations must be refused, got %+v", res) + } + if res.FilesRestored != 0 { + t.Errorf("zero files must be written when every destination is refused, got %d", res.FilesRestored) + } + if _, sErr := os.Stat(filepath.Join(outside, "x.txt")); !os.IsNotExist(sErr) { + t.Error("PLACE GUARD BREACHED — a file landed outside every registered live root") + } + if _, sErr := os.Stat(filepath.Join(env.tmp, "eszkeipel", "y.txt")); !os.IsNotExist(sErr) { + t.Error("PLACE GUARD BREACHED — a traversal destination was written") + } + // The definitions must not be registered either — a share pointing outside is not restorable. + if len(env.sett.GetSMBShares()) != 0 { + t.Errorf("refused shares must not enter the registry: %+v", env.sett.GetSMBShares()) + } +} + +// The prefix assert's boundary cases, stated directly. +func TestLiveShareRootOKBoundaries(t *testing.T) { + env := newSharesEnv(t, "hdd_1") + root := env.drives["hdd_1"] + + if !env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) { + t.Error("a strict descendant of a live root must be allowed") + } + if env.m.liveShareRootOK(root) { + t.Error("the drive root ITSELF must be refused (a share is never the whole drive)") + } + if env.m.liveShareRootOK(filepath.Join(root, "..", "elsewhere")) { + t.Error("a `..` segment must be refused") + } + if env.m.liveShareRootOK("") { + t.Error("an empty destination must be refused") + } + // A drive that is away is not a LIVE root. + if err := env.sett.SetDisconnected(root, true, nil); err != nil { + t.Fatal(err) + } + if env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) { + t.Error("a disconnected drive must not count as a live root") + } +} + +// A definitions-only snapshot (the quota-degraded shape) restores the CONFIGURATION without error +// even though no file tree exists — that is the whole point of the manifest-only floor. +func TestSharesRestoreDefinitionsOnlySnapshot(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + sharePath := filepath.Join(env.drives["hdd_1"], "dokumentumok") + + mf := SharesManifest{ + Version: sharesManifestVersion, ServerName: "FELHOM", + Shares: []settings.SMBShare{{Name: "dokumentumok", Path: sharePath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}}, + } + seedSharesScratch(t, env, mf, nil) // no file tree at all + + var passdbCalls int + env.m.SetSharesReconciler(func() error { return nil }) + wirePlaceSeams(env, &passdbCalls) + + res, err := env.m.PlaceSharesRestore(t.Context()) + if err != nil { + t.Fatalf("a definitions-only snapshot must restore cleanly: %v", err) + } + if !containsStr(res.DefinitionsAdded, "dokumentumok") { + t.Errorf("the definition should be restored: %+v", res) + } + if res.FilesRestored != 0 { + t.Errorf("no files exist in a definitions-only snapshot, got %d", res.FilesRestored) + } +} + +// The flash message must speak Hungarian and never leak the reserved key. +func TestSharesRestoreFlashMessage(t *testing.T) { + res := SharesRestoreResult{FilesRestored: 3, DefinitionsAdded: []string{"a"}, DefinitionsKept: []string{"marad"}} + msg := res.FlashMessage() + if strings.Contains(msg, SharesPseudoStack) { + t.Errorf("the reserved key leaked into the flash message: %q", msg) + } + if !strings.Contains(msg, SharesDisplayName) { + t.Errorf("the flash message should name %q: %q", SharesDisplayName, msg) + } + if !strings.Contains(msg, "A(z) marad megosztás beállítása már létezik — a meglévő maradt.") { + t.Errorf("the kept-definition sentence is missing: %q", msg) + } +} + +// jsonMarshalIndent is a tiny indirection so the test file needs no direct encoding/json import +// beyond this one helper. +func jsonMarshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") } diff --git a/controller/internal/monitor/effective_protected_test.go b/controller/internal/monitor/effective_protected_test.go index b0788e2..7bc7609 100644 --- a/controller/internal/monitor/effective_protected_test.go +++ b/controller/internal/monitor/effective_protected_test.go @@ -4,6 +4,8 @@ import ( "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) func contains(ss []string, want string) bool { @@ -21,7 +23,7 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) { base := config.StacksConfig{Protected: []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"}} cfgNoTok := &config.Config{Stacks: base} - got := EffectiveProtected(cfgNoTok) + got := EffectiveProtected(cfgNoTok, settings.SMBSettings{}) if contains(got, "cloudflared") { t.Errorf("cloudflared must be dropped when no tunnel token: %v", got) } @@ -33,7 +35,33 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) { cfgTok := &config.Config{Stacks: base} cfgTok.Infrastructure.CFTunnelToken = "tok" - if !contains(EffectiveProtected(cfgTok), "cloudflared") { + if !contains(EffectiveProtected(cfgTok, settings.SMBSettings{}), "cloudflared") { t.Error("cloudflared must remain protected when a tunnel token is configured") } } + +// R-7b Scenario E, BOTH directions. Sharing is a customer-toggled feature, so the samba container can +// never be in the golden controller.yaml — the effective set must add it dynamically when sharing is +// ON (so a dead sharing service raises the same protected-container issue as a dead traefik) and must +// leave it out when sharing is OFF (so a box that never enabled it never reports a missing container). +// Red-proof: delete the `if smb.Enabled` append and the enabled case fails. +func TestEffectiveProtectedTracksSharingToggle(t *testing.T) { + cfg := &config.Config{Stacks: config.StacksConfig{Protected: []string{"traefik", "felhom-controller"}}} + + on := EffectiveProtected(cfg, settings.SMBSettings{Enabled: true}) + if !contains(on, infra.SambaContainerName) { + t.Errorf("sharing ON: %q must be watched, got %v", infra.SambaContainerName, on) + } + off := EffectiveProtected(cfg, settings.SMBSettings{Enabled: false}) + if contains(off, infra.SambaContainerName) { + t.Errorf("sharing OFF: %q must NOT be watched, got %v", infra.SambaContainerName, off) + } + // The base set is untouched in both directions. + for _, set := range [][]string{on, off} { + for _, must := range []string{"traefik", "felhom-controller"} { + if !contains(set, must) { + t.Errorf("%s must remain protected: %v", must, set) + } + } + } +} diff --git a/controller/internal/monitor/healthcheck.go b/controller/internal/monitor/healthcheck.go index 3b90bc7..618a9f8 100644 --- a/controller/internal/monitor/healthcheck.go +++ b/controller/internal/monitor/healthcheck.go @@ -9,6 +9,7 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) @@ -23,7 +24,7 @@ type HealthReport struct { } // RunHealthCheck runs system checks and returns a diagnostic report. -func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, logger *log.Logger) *HealthReport { +func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, smb settings.SMBSettings, logger *log.Logger) *HealthReport { report := &HealthReport{ Status: "ok", Timestamp: time.Now(), @@ -159,7 +160,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora // 6. Protected containers (effective set: cloudflared only counts when a tunnel token is // configured, so a LAN-only node doesn't report FAIL forever for a stack it intentionally skips). - protected := EffectiveProtected(cfg) + protected := EffectiveProtected(cfg, smb) if debug { logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected) } @@ -245,18 +246,35 @@ func checkDocker() error { } // EffectiveProtected returns the protected-container set that actually applies to this node. It is -// the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here: -// cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs -// without it, so it must not be reported as a missing protected container forever). The bring-up -// (stacks.EnsureBaseStack) applies the same cloudflared condition, so detection and deployment agree. -func EffectiveProtected(cfg *config.Config) []string { - out := make([]string, 0, len(cfg.Stacks.Protected)) +// the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here, plus +// the DYNAMIC extras whose deployment depends on customer state rather than config: +// +// - cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs +// without it, so it must not be reported as a missing protected container forever); +// - the samba container is ADDED when network sharing is switched on (R-7b). Sharing is a +// customer-toggled feature, so it can never appear in the golden controller.yaml — but once it +// IS on, a dead sharing service is exactly as customer-visible as a dead traefik and must raise +// the same protected-container issue → alert → Hungarian degradation e-mail. When sharing is +// off the container is absent from the set, so a box that never enabled it stays quiet. +// +// The bring-up applies the same conditions (stacks.EnsureBaseStack for cloudflared, ensureSamba's +// `if !smb.Enabled { return }` for samba), so detection and deployment agree in both directions. +// +// NOTE: the entries are CONTAINER names (checkProtectedContainers docker-inspects them). For the +// base stacks the container name happens to equal the stack name; for samba it does NOT — the stack +// is „samba" but the container is infra.SambaContainerName — which is why the constant is read here +// rather than the stack name assumed. +func EffectiveProtected(cfg *config.Config, smb settings.SMBSettings) []string { + out := make([]string, 0, len(cfg.Stacks.Protected)+1) for _, name := range cfg.Stacks.Protected { if name == "cloudflared" && cfg.Infrastructure.CFTunnelToken == "" { continue } out = append(out, name) } + if smb.Enabled { + out = append(out, infra.SambaContainerName) + } return out } diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index 32f7cca..6aed1bc 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -29,6 +29,7 @@ func BuildReport( version string, storagePaths []settings.StoragePath, geoRestriction *settings.GeoRestriction, + smb settings.SMBSettings, logger *log.Logger, ) *Report { debug := cfg.Logging.Level == "debug" @@ -136,7 +137,7 @@ func BuildReport( r.Backup = buildBackupReport(cfg, backupMgr) // Health - healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths, logger) + healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths, smb, logger) r.Health = HealthReport{ Status: healthReport.Status, Issues: healthReport.Issues, diff --git a/controller/internal/web/handler_debug.go b/controller/internal/web/handler_debug.go index 9b0d6e8..10fe9fc 100644 --- a/controller/internal/web/handler_debug.go +++ b/controller/internal/web/handler_debug.go @@ -256,7 +256,7 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) { } // Health - healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.logger) + healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.settings.GetSMBSettings(), s.logger) dump["health"] = map[string]interface{}{ "status": healthReport.Status, "issues": healthReport.Issues, diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index a621415..e951143 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -815,6 +815,14 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { } } data["OffboxScratchReady"] = ready + // R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it + // gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it + // appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong). + if s.backupMgr != nil { + data["SharesRestoreOffered"] = s.settings != nil && len(s.settings.GetSMBShares()) > 0 + data["SharesScratchReady"] = s.backupMgr.SharesScratchReady() + data["SharesDisplayName"] = backup.SharesDisplayName + } s.executeTemplate(w, r, "backups_restore", data) } diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index c0732bc..3896560 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -355,3 +355,64 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) { }() offboxRedirectTo(w, r, "/backups/restore", "A helyreállítás elindult — az állapot itt frissül.", false) } + +// --- R-7b: „Megosztások" restore ------------------------------------------------------------------ +// +// A SIBLING of the per-app restore pair above, not a special case of it: the shares source has no +// recovery unit and no per-app toggle, so it gets its own two-step flow (restore to scratch, then a +// deliberate place-to-live). The display name is always „Megosztások" — the reserved `_shares` key +// never reaches a customer-facing surface. + +// sharesRestoreHandler restores the latest shares snapshot into an on-data-drive scratch dir +// (POST /backup/shares/restore). Non-destructive: nothing live is touched until the place action. +func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { + offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true) + return + } + if s.backupMgr.IsRunning() { + offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true) + return + } + s.backupMgr.BeginRestoreOp("shares-restore", backup.SharesDisplayName) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + if err := s.backupMgr.RestoreSharesScratch(ctx); err != nil { + s.logger.Printf("[ERROR] [web] shares restore (async): %v", err) + s.backupMgr.EndRestoreOp(false, "A megosztások visszaállítása sikertelen: "+err.Error()) + return + } + s.logger.Printf("[INFO] [web] shares restore completed (async)") + s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.") + }() + offboxRedirectTo(w, r, "/backups/restore", "A megosztások visszaállítása elindult — az állapot itt frissül.", false) +} + +// sharesPlaceHandler merges a completed shares scratch into the live share folders, re-adds the +// missing definitions and restores the household credential (POST /backup/shares/place). +func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { + offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true) + return + } + if s.backupMgr.IsRunning() { + offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true) + return + } + s.backupMgr.BeginRestoreOp("shares-place", backup.SharesDisplayName) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + res, err := s.backupMgr.PlaceSharesRestore(ctx) + if err != nil { + s.logger.Printf("[ERROR] [web] shares place (async): %v", err) + s.backupMgr.EndRestoreOp(false, "A megosztások helyreállítása sikertelen: "+err.Error()) + return + } + s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)", + res.FilesRestored, len(res.DefinitionsAdded)) + s.backupMgr.EndRestoreOp(true, res.FlashMessage()) + }() + offboxRedirectTo(w, r, "/backups/restore", "A megosztások helyreállítása elindult — az állapot itt frissül.", false) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 4ecd799..d51dcbf 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -418,6 +418,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.offboxRestoreHandler(w, r) case path == "/backup/offbox/place" && r.Method == http.MethodPost: s.offboxPlaceHandler(w, r) + // R-7b: „Megosztások" restore — a sibling of the per-app pair above. + case path == "/backup/shares/restore" && r.Method == http.MethodPost: + s.sharesRestoreHandler(w, r) + case path == "/backup/shares/place" && r.Method == http.MethodPost: + s.sharesPlaceHandler(w, r) // Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow. case path == "/backup/escrow" && r.Method == http.MethodGet: s.escrowWizardPageHandler(w, r) diff --git a/controller/internal/web/sharing_handlers.go b/controller/internal/web/sharing_handlers.go index 38be82b..156dcab 100644 --- a/controller/internal/web/sharing_handlers.go +++ b/controller/internal/web/sharing_handlers.go @@ -154,6 +154,26 @@ func (s *Server) sharingPageData() map[string]interface{} { roots = append(roots, map[string]string{"Path": sp.Path, "Label": label}) } data["StorageRoots"] = roots + + // R-7b: per-tier backup truth. Until R-7b the „Felhőmentés" toggle promised a protection the + // engines did not deliver; these two lines are what makes the promise checkable by the customer + // rather than taken on faith. Amber ONLY on deviation — a green tier says nothing at all beyond + // its timestamp, so the page stays quiet when everything is fine. + if s.backupMgr != nil { + if cd := s.backupMgr.SharesTier2Status(); cd != nil { + data["SharesTier2Status"] = cd.LastStatus + data["SharesTier2LastRun"] = cd.LastRun + data["SharesTier2Warning"] = cd.LastWarning + data["SharesTier2Error"] = cd.LastError + data["SharesTier2Dest"] = cd.DestinationPath + } + if lastRun, status, count, ok := s.backupMgr.SharesOffsiteStatus(); ok { + data["SharesOffsiteStatus"] = status + data["SharesOffsiteLastRun"] = lastRun + data["SharesOffsiteCount"] = count + } + data["SharesRestoreReady"] = s.backupMgr.SharesScratchReady() + } return data } diff --git a/controller/internal/web/templates/backups_restore.html b/controller/internal/web/templates/backups_restore.html index 87b1048..1a90300 100644 --- a/controller/internal/web/templates/backups_restore.html +++ b/controller/internal/web/templates/backups_restore.html @@ -102,6 +102,25 @@ {{else}}

Nincs távoli mentésre jelölt alkalmazás — a kijelölés a Távoli mentés oldalon történik.

{{end}} + + + {{if .SharesRestoreOffered}} +
+ {{template "app_list_row" dict "Slug" "" "Name" .SharesDisplayName}} +
{{$.CSRFField}} + +
+ {{if .SharesScratchReady}} +
{{$.CSRFField}} + +
+ A meglévő fájlokat nem írja felül. A már létező megosztás-beállítások változatlanok maradnak. + {{end}} + {{template "app_list_row_end"}} +
+ {{end}} {{end}} diff --git a/controller/internal/web/templates/sharing.html b/controller/internal/web/templates/sharing.html index b5af093..0f397a6 100644 --- a/controller/internal/web/templates/sharing.html +++ b/controller/internal/web/templates/sharing.html @@ -127,6 +127,51 @@ A megosztás törlésekor a mappa és a fájlok megmaradnak — csak a hálózati elérés szűnik meg. + +
+

A megosztások mentése

+

+ A megosztott mappák a többi adattal együtt mentésre kerülnek. A „Felhőmentés” bekapcsolva + azt jelenti, hogy a mappa a távoli tárhelyre is felkerül; kikapcsolva csak a második + meghajtóra készül másolat. +

+ +
+ Visszaállítani a Visszaállítás oldalon lehet. +
+
{{else}}
Még nincs megosztott mappa.
{{end}}