80e6ad8c47
gates / gates (push) Successful in 26s
R-650: internal/dockerexec — every docker exec routed through it; under go test a real docker is refused (opt-in FELHOM_TEST_REAL_DOCKER=1; a stub under the temp dir is allowed). api/stacks/web tests run under a silent stub (TestMain). TestR650_NoBareDockerExec pins it repo-wide. R-640: a dump without its engine's completion marker is refused before the first mutation (unit + off-site restore) and again before any load. R-499: the Tier-2 page's system-disk sentence has four true branches. R-518: the backup button states the measured ~8 min stop. R-626: measured on 9202, not reproduced. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
334 lines
14 KiB
Go
334 lines
14 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
"os"
|
|
"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 := dockerexec.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 "", "", util.MsgError("err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz")
|
|
}
|
|
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 "", "", util.MsgError("err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz")
|
|
}
|
|
|
|
// 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 {
|
|
// R-411 — FOUND BY THE R-408 WALK, not by the report that prompted it. This is the SAME shape as
|
|
// the off-site scratch restore: it runs `unlockStale` (a delete verb against `locks/`) and then
|
|
// `resticStep`, whose `unlock --remove-all` escalation is licensed only by every caller holding
|
|
// this flag. Its sibling `PlaceSharesRestore` below has always taken it; this one never did.
|
|
//
|
|
// The handler's `restoreOpBlocked()` + `BeginRestoreOp` set the DISPLAY flag (`opRunning`), not
|
|
// this one, so nothing nests and `acquireRunning` is safe to take here.
|
|
if err := m.acquireRunning(); err != nil {
|
|
return err
|
|
}
|
|
defer m.releaseRunning()
|
|
if !m.OffboxConfigured() {
|
|
return util.MsgError("err.backup.a_tavoli_mentes_nincs_beallitva")
|
|
}
|
|
scratch, nsRoot, err := m.sharesRestoreScratchDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if free := m.offboxFree()(nsRoot); free > 0 && free < offboxUnitOnlyFreeFloor {
|
|
return util.MsgError("err.backup.nincs_eleg_szabad_hely_a_visszaallitashoz",
|
|
humanizeBytes(offboxUnitOnlyFreeFloor), humanizeBytes(free))
|
|
}
|
|
id, _, err := m.offboxLatestSnapshot(ctx, SharesPseudoStack)
|
|
if err != nil {
|
|
return util.MsgError("err.backup.nincs_visszaallithato_megosztas_mentes", 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 util.MsgError("err.backup.a_megosztasok_visszaallitasa_sikertelen", 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
|
|
// <scratch>/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, util.MsgError("err.backup.a_mentesben_nincs_megosztas_leiro", err)
|
|
}
|
|
if err := json.Unmarshal(blob, &mf); err != nil {
|
|
return mf, util.MsgError("err.backup.a_megosztas_leiro_olvashatatlan", 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, util.MsgError("err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar")
|
|
}
|
|
defer m.releaseRunning()
|
|
|
|
scratch, _, err := m.sharesRestoreScratchDir()
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if _, sErr := os.Stat(scratch); sErr != nil {
|
|
return res, util.MsgError("err.backup.nincs_elokeszitett_visszaallitas_futtass_elobb_egy")
|
|
}
|
|
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, util.MsgError("err.backup.a_z_megosztas_fajljainak_visszaallitasa_sikertelen", 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())
|
|
}
|