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()) }