diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 9e7e268..e3e1459 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -92,6 +92,15 @@ type Manager struct { // (`-a --delete`, contents-of-src semantics). tier2Mirror func(src, dst string) error + // sharesPassdbCapture (R-7b) — the samba passdb capture seam (a `docker exec … tar cf -`), + // overridable so the shares payload builder is unit-testable without docker. Nil → the real + // defaultSharesPassdbCapture. Best-effort by contract: an error yields a manifest-only payload. + sharesPassdbCapture func() ([]byte, error) + + // sharesPassdbRestore (R-7b) — the mirror seam for putting a captured passdb archive BACK into the + // samba named volume (`docker exec -i … tar xf -`). Nil → the real defaultSharesPassdbRestore. + sharesPassdbRestore func(tar []byte) 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_payload.go b/controller/internal/backup/shares_payload.go new file mode 100644 index 0000000..4a0cff5 --- /dev/null +++ b/controller/internal/backup/shares_payload.go @@ -0,0 +1,265 @@ +package backup + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// SMB share backup — R-7b, Model B′ (Viktor's ruling 2026-07-18). Share data enters the live backup +// runs through a SIBLING shares source: additive job/leg code that reuses the proven primitives +// (tier-2 mirror seam, restic wrappers, soft-quota gate, status recording) while leaving every +// per-app engine path BYTE-IDENTICAL. This file holds the piece both tiers share: the PAYLOAD — the +// share DEFINITIONS plus the SMB password hash — so a DR rebuild restores the files, the share +// configuration, and the household credential, not just the bytes on disk. +// +// Why a payload at all: the share folders are plain customer directories; copying them protects the +// data but loses everything that made them shares. Without the manifest a restore leaves the customer +// with their files back and an empty „Megosztás" page. + +const ( + // SharesPseudoStack is the RESERVED key the shares source occupies wherever the per-app engines + // key by stack name: the restic tag, the tier-2 dest root (backups/secondary/_shares/…), and the + // CrossDriveBackup status record. It is NEVER shown to the customer — every UI/notification + // boundary maps it through SharesDisplayName (see DisplayStackName). + // + // RESERVED-NAME VERIFICATION (R-7b): the claim that slice-1 validation already excludes a leading + // underscore is FALSE for shares — settings.nbNameRe starts with [A-Za-z0-9_], so „_shares" was an + // accepted share name. settings.ValidateSMBShareName now rejects the underscore-prefixed namespace + // outright. Stack names come from the git-synced catalog (not customer input) and so cannot + // realistically produce „_shares", but RunAllTier2 and RunOffboxBackup skip such a stack with a + // loud WARN as defense in depth rather than let it clobber the shares tree. + SharesPseudoStack = "_shares" + // SharesDisplayName is the Hungarian customer-facing name for the shares source. + SharesDisplayName = "Megosztások" + + // sharesManifestName is the share-definition document inside the payload. + sharesManifestName = "_shares-manifest.json" + // sharesPassdbName is the SECRET-BEARING samba passdb archive inside the payload. It rides the + // restic repo (encrypted at rest) and the tier-2 staging on the customer's own drives; its bytes + // and its name never reach a log line at INFO, a report, or a committed file. + sharesPassdbName = "passdb.tar" + // sharesPassdbMember is the subtree inside infra.SambaPassdbMount that holds the credential. + sharesPassdbMember = "private" + + // sharesManifestVersion pins the payload document shape. + sharesManifestVersion = 1 +) + +// DisplayStackName maps an engine-internal stack key to the name a customer may see. Today the only +// mapping is the reserved shares pseudo-stack; every other key is its own display name. THIS is the +// single boundary that keeps „_shares" out of the Hungarian UI, alerts and e-mails. +func DisplayStackName(key string) string { + if key == SharesPseudoStack { + return SharesDisplayName + } + return key +} + +// SharesManifest is the restorable share-definition document. It is deliberately timestamp-free at +// the document level so the rendered JSON is BYTE-DETERMINISTIC for an unchanged registry — the +// tier-2 mirror then has nothing to rewrite on a no-op run. Per-share CreatedAt is preserved. +type SharesManifest struct { + Version int `json:"version"` + ServerName string `json:"server_name"` + Shares []settings.SMBShare `json:"shares"` +} + +// SetSharesPassdbCapturer overrides the samba passdb capture (tests inject a fake so no docker runs). +func (m *Manager) SetSharesPassdbCapturer(fn func() ([]byte, error)) { m.sharesPassdbCapture = fn } + +// sharesPassdbCapturer returns the passdb capture seam (nil → the real docker exec). +func (m *Manager) sharesPassdbCapturer() func() ([]byte, error) { + if m.sharesPassdbCapture != nil { + return m.sharesPassdbCapture + } + return defaultSharesPassdbCapture +} + +// defaultSharesPassdbCapture tars the samba private/ subtree out of the running container on stdout. +// Best-effort by contract: a stopped container simply yields an error and the payload ships +// manifest-only (re-setting the SMB password is a cheap UX step; the definitions are the load-bearing +// part). Uses the same `docker exec` shape as stacks.extractInitialCreds. +func defaultSharesPassdbCapture() ([]byte, error) { + cmd := exec.Command("docker", "exec", infra.SambaContainerName, + "tar", "cf", "-", "-C", infra.SambaPassdbMount, sharesPassdbMember) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("passdb capture: %w", err) + } + return out, nil +} + +// sharesPayloadDir is the staging directory both tiers read. It lives in the controller DATA DIR +// (not on a customer drive) for two reasons: the payload is kilobytes — manifest JSON plus a small +// tdb archive — so the F-A1 rootfs-filler concern does not apply, and a DETERMINISTIC absolute path +// makes the restic snapshot path stable, which is what the restore mapper anchors on. 0700 because +// the passdb archive is secret-bearing. +func (m *Manager) sharesPayloadDir() string { + return filepath.Join(m.cfg.Paths.DataDir, "shares-payload") +} + +// SharesPayloadDir exposes the staging path for the restore mapper and tests. +func (m *Manager) SharesPayloadDir() string { return m.sharesPayloadDir() } + +// buildSharesManifest renders the deterministic manifest document from the live registry. Shares are +// sorted case-insensitively by name so an unchanged registry always marshals to identical bytes +// regardless of the order the customer happened to add them in. +func (m *Manager) buildSharesManifest() SharesManifest { + mf := SharesManifest{Version: sharesManifestVersion, Shares: []settings.SMBShare{}} + if m.settings == nil { + return mf + } + smb := m.settings.GetSMBSettings() + mf.ServerName = smb.EffectiveServerName() + mf.Shares = append(mf.Shares, m.settings.GetSMBShares()...) + sort.Slice(mf.Shares, func(i, j int) bool { + a, b := strings.ToLower(mf.Shares[i].Name), strings.ToLower(mf.Shares[j].Name) + if a != b { + return a < b + } + return mf.Shares[i].Name < mf.Shares[j].Name + }) + return mf +} + +// buildSharesPayload stages the payload and returns its directory. The manifest is ALWAYS written +// (it is the definitions-protection floor that must never regress); the passdb archive is +// best-effort and its absence is a WARN, not an error. Returns the dir plus whether the passdb made +// it in, so callers can report honestly. +func (m *Manager) buildSharesPayload() (dir string, passdbOK bool, err error) { + dir = m.sharesPayloadDir() + if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil { + return "", false, fmt.Errorf("shares payload dir: %w", mkErr) + } + // Tighten an inherited-loose mode from an older layout (the passdb archive lives here). + if chErr := os.Chmod(dir, 0o700); chErr != nil { + m.logger.Printf("[WARN] [shares] payload dir chmod failed: %v", chErr) + } + + blob, mErr := json.MarshalIndent(m.buildSharesManifest(), "", " ") + if mErr != nil { + return "", false, fmt.Errorf("shares manifest marshal: %w", mErr) + } + blob = append(blob, '\n') + if wErr := writeFileAtomic(filepath.Join(dir, sharesManifestName), blob, 0o600); wErr != nil { + return "", false, fmt.Errorf("shares manifest write: %w", wErr) + } + + passdbPath := filepath.Join(dir, sharesPassdbName) + tar, pErr := m.sharesPassdbCapturer()() + switch { + case pErr != nil || len(tar) == 0: + // Container down / never deployed. Keep any PREVIOUSLY captured archive rather than deleting + // it — a stale credential copy is strictly better for DR than none, and the payload stays + // self-consistent because the manifest carries no password of its own. + if _, sErr := os.Stat(passdbPath); sErr == nil { + m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — keeping the previously captured copy: %v", pErr) + passdbOK = true + } else { + m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — payload is manifest-only; the SMB password must be re-set after a restore: %v", pErr) + } + default: + if wErr := writeFileAtomic(passdbPath, tar, 0o600); wErr != nil { + // Never fatal: definitions protection must not hinge on the credential copy. + m.logger.Printf("[WARN] [shares] passdb stage failed — payload is manifest-only: %v", wErr) + } else { + passdbOK = true + m.logger.Printf("[DEBUG] [shares] passdb archive staged (%d bytes)", len(tar)) + } + } + m.logger.Printf("[INFO] [shares] payload staged: %d share definition(s), credential copy=%v", len(m.buildSharesManifest().Shares), passdbOK) + return dir, passdbOK, nil +} + +// writeFileAtomic writes via tmp + rename at the requested mode (mirrors stacks.sambaWriteAtomic's +// discipline: a crash mid-write can never leave a half-manifest a restore would read). +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, mode); err != nil { + return err + } + if err := os.Chmod(tmp, mode); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +// classifiedShare is one share the backup sources act on, with its class resolved from the registry +// exactly as stacks.sambaClassifiedBinds does it: Offsite=true ⇒ MANDATORY (offsite + tier-2), +// Offsite=false ⇒ OPTIONAL (tier-2 only). Availability is resolved here too, so BOTH jobs skip a +// dead mount identically (Scenario F). +type classifiedShare struct { + settings.SMBShare + mandatory bool +} + +// classifiedShares returns the shares both jobs act on, dropping unavailable ones with a loud WARN. +// A share whose owning drive is disconnected/decommissioned, or whose folder simply is not there, is +// NEVER handed to a mirror or a restic argv: mirroring a missing mountpoint would copy an empty dir +// over a good backup, and that is the silently-wrong-restore class this codebase refuses by rule. +func (m *Manager) classifiedShares() []classifiedShare { + if m.settings == nil { + return nil + } + var out []classifiedShare + for _, sh := range m.settings.GetSMBShares() { + if !m.shareBackupAvailable(sh.Path) { + m.logger.Printf("[WARN] [shares] share skipped — folder unavailable (drive away or path missing): name=%s", sh.Name) + continue + } + out = append(out, classifiedShare{SMBShare: sh, mandatory: sh.Offsite}) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) + return out +} + +// shareBackupAvailable mirrors stacks.shareAvailable (the export-time gate) so the backup sources and +// the smb.conf renderer agree on what "available" means. Kept local rather than imported: the backup +// package must not depend on the stacks package. +func (m *Manager) shareBackupAvailable(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + if m.settings != nil { + p := filepath.ToSlash(path) + for _, sp := range m.settings.GetStoragePaths() { + root := filepath.ToSlash(sp.Path) + if p == root || strings.HasPrefix(p, root+"/") { + if sp.Disconnected || sp.Decommissioned { + return false + } + break + } + } + } + st, err := os.Stat(path) + return err == nil && st.IsDir() +} + +// sharesEnabled reports whether the shares source has anything to do at all: the feature is on AND at +// least one share is registered. A disabled feature or an empty registry is a clean no-op in both +// jobs — no `_shares` restic group, no empty dest dirs (the zero-shares edge case). +func (m *Manager) sharesEnabled() bool { + if m.settings == nil { + return false + } + return m.settings.GetSMBSettings().Enabled && len(m.settings.GetSMBShares()) > 0 +} + +// sharesNow is the timestamp helper for the shares status records (kept in one place so tests that +// assert on recorded status have a single seam to reason about). +func sharesNow() string { return time.Now().Format(time.RFC3339) } diff --git a/controller/internal/backup/shares_test.go b/controller/internal/backup/shares_test.go new file mode 100644 index 0000000..cfb3d8c --- /dev/null +++ b/controller/internal/backup/shares_test.go @@ -0,0 +1,415 @@ +package backup + +import ( + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// --- shared harness ------------------------------------------------------------------------------- + +// sharesEnv is a Manager wired for the R-7b shares source: a real settings store, real temp drives, +// and FAKE copy/exec seams so nothing shells out to rsync, du, docker or restic. +type sharesEnv struct { + m *Manager + // mirrored records every tier2Mirror call as "src=>dst" in call order. + mirrored []string + // drives maps a label to the temp dir standing in for that registered drive. + drives map[string]string + sett *settings.Settings + tmp string +} + +// newSharesEnv registers the named drives, turns sharing on, and installs the fake seams. Shares are +// added afterwards with addShare so each test states exactly the registry it needs. +func newSharesEnv(t *testing.T, driveNames ...string) *sharesEnv { + t.Helper() + tmp := t.TempDir() + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + env := &sharesEnv{drives: map[string]string{}, sett: sett, tmp: tmp} + for _, name := range driveNames { + p := filepath.Join(tmp, name) + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: name, Schedulable: true}); err != nil { + t.Fatal(err) + } + env.drives[name] = p + } + if err := sett.SetSMBEnabled(true); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = filepath.Join(tmp, "data") + sysDrive := filepath.Join(tmp, "sysdrive") + if err := os.MkdirAll(sysDrive, 0o755); err != nil { + t.Fatal(err) + } + cfg.Paths.SystemDataPath = sysDrive + + env.m = &Manager{ + cfg: cfg, settings: sett, logger: log.New(io.Discard, "", 0), systemDataPath: sysDrive, + // Fake mirror: records the call AND actually copies, so restore tests have real bytes. + tier2Mirror: func(src, dst string) error { + env.mirrored = append(env.mirrored, src+"=>"+dst) + return copyTreeForTest(src, dst) + }, + sharesPassdbCapture: func() ([]byte, error) { return []byte("FAKE-PASSDB-TAR"), nil }, + } + return env +} + +// addShare registers a share, creating its folder with one file so it is "available". +func (e *sharesEnv) addShare(t *testing.T, drive, name string, offsite bool) string { + t.Helper() + p := filepath.Join(e.drives[drive], name) + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p, name+".txt"), []byte("content-of-"+name), 0o644); err != nil { + t.Fatal(err) + } + if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil { + t.Fatal(err) + } + return p +} + +// copyTreeForTest is a minimal recursive copy standing in for rsync in the mirror seam. +func copyTreeForTest(src, dst string) error { + fi, err := os.Stat(src) + if err != nil { + return err + } + if !fi.IsDir() { + b, rErr := os.ReadFile(src) + if rErr != nil { + return rErr + } + if mErr := os.MkdirAll(filepath.Dir(dst), 0o755); mErr != nil { + return mErr + } + return os.WriteFile(dst, b, 0o644) + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + if err := copyTreeForTest(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil { + return err + } + } + return nil +} + +// mirroredDsts returns just the destination side of every recorded mirror call. +func (e *sharesEnv) mirroredDsts() []string { + var out []string + for _, s := range e.mirrored { + out = append(out, s[strings.Index(s, "=>")+2:]) + } + return out +} + +// --- Part 1: payload ------------------------------------------------------------------------------ + +// The manifest must carry the share DEFINITIONS verbatim — that is the whole point of the payload: +// after a DR rebuild the customer gets their files AND their „Megosztás" page back, not an empty one. +func TestSharesPayloadManifestMatchesRegistry(t *testing.T) { + env := newSharesEnv(t, "hdd_1") + env.addShare(t, "hdd_1", "dokumentumok", true) + env.addShare(t, "hdd_1", "filmek", false) + + dir, passdbOK, err := env.m.buildSharesPayload() + if err != nil { + t.Fatalf("buildSharesPayload: %v", err) + } + if !passdbOK { + t.Fatal("expected the (faked) passdb capture to succeed") + } + blob, err := os.ReadFile(filepath.Join(dir, sharesManifestName)) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var mf SharesManifest + if err := json.Unmarshal(blob, &mf); err != nil { + t.Fatalf("manifest is not valid JSON: %v", err) + } + if mf.Version != sharesManifestVersion { + t.Errorf("manifest version = %d, want %d", mf.Version, sharesManifestVersion) + } + if mf.ServerName != settings.DefaultSMBServerName { + t.Errorf("server name = %q, want %q", mf.ServerName, settings.DefaultSMBServerName) + } + want := env.sett.GetSMBShares() + sort.Slice(want, func(i, j int) bool { return want[i].Name < want[j].Name }) + if len(mf.Shares) != len(want) { + t.Fatalf("manifest has %d shares, registry has %d", len(mf.Shares), len(want)) + } + for i := range want { + if mf.Shares[i] != want[i] { + t.Errorf("share %d: manifest %+v != registry %+v", i, mf.Shares[i], want[i]) + } + } + // The credential copy must be present and 0600 — it is secret-bearing. + st, err := os.Stat(filepath.Join(dir, sharesPassdbName)) + if err != nil { + t.Fatalf("passdb archive missing: %v", err) + } + if runtimeIsPOSIX() && st.Mode().Perm() != 0o600 { + t.Errorf("passdb archive mode = %o, want 600 (secret-bearing)", st.Mode().Perm()) + } +} + +// Determinism: an unchanged registry must marshal to IDENTICAL bytes regardless of the order shares +// were added in, so a no-op nightly run gives the tier-2 mirror nothing to rewrite. +func TestSharesPayloadManifestIsDeterministic(t *testing.T) { + // Built from the registry only (no filesystem), so the two runs differ ONLY in insertion order — + // otherwise the temp-dir paths baked into each share would make any comparison meaningless. + read := func(order []string) []byte { + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + for _, n := range order { + if err := sett.AddSMBShare(settings.SMBShare{ + Name: n, Path: "/mnt/hdd_1/" + n, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + } + m := &Manager{settings: sett, logger: log.New(io.Discard, "", 0)} + b, err := json.MarshalIndent(m.buildSharesManifest(), "", " ") + if err != nil { + t.Fatal(err) + } + return b + } + a := read([]string{"alpha", "zulu", "mike"}) + b := read([]string{"zulu", "mike", "alpha"}) + if string(a) != string(b) { + t.Errorf("manifest is order-dependent:\n%s\n---\n%s", a, b) + } +} + +// Scenario: samba container down. The payload must still be produced (manifest-only) and must NOT +// error — re-setting the SMB password is cheap; losing the definitions is not. +func TestSharesPayloadPassdbAbsentIsManifestOnly(t *testing.T) { + env := newSharesEnv(t, "hdd_1") + env.addShare(t, "hdd_1", "dokumentumok", true) + env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist } + + dir, passdbOK, err := env.m.buildSharesPayload() + if err != nil { + t.Fatalf("a down container must not fail the payload: %v", err) + } + if passdbOK { + t.Error("passdbOK must be false when the capture failed and no prior copy exists") + } + if _, err := os.Stat(filepath.Join(dir, sharesManifestName)); err != nil { + t.Errorf("manifest must still be written: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, sharesPassdbName)); !os.IsNotExist(err) { + t.Error("no passdb archive should exist when the capture failed and none was staged before") + } +} + +// A previously captured credential copy must SURVIVE a later failed capture — a stale credential is +// strictly better for DR than none, and the manifest carries no password of its own to contradict it. +func TestSharesPayloadKeepsPriorPassdbOnCaptureFailure(t *testing.T) { + env := newSharesEnv(t, "hdd_1") + env.addShare(t, "hdd_1", "dokumentumok", true) + dir, _, err := env.m.buildSharesPayload() + if err != nil { + t.Fatal(err) + } + env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist } + _, passdbOK, err := env.m.buildSharesPayload() + if err != nil { + t.Fatal(err) + } + if !passdbOK { + t.Error("a previously staged passdb copy must keep passdbOK true") + } + b, err := os.ReadFile(filepath.Join(dir, sharesPassdbName)) + if err != nil || string(b) != "FAKE-PASSDB-TAR" { + t.Errorf("prior passdb copy was lost: %q, %v", b, err) + } +} + +// The reserved key must never reach a Hungarian surface raw. +func TestDisplayStackNameMapsReservedKey(t *testing.T) { + if got := DisplayStackName(SharesPseudoStack); got != SharesDisplayName { + t.Errorf("DisplayStackName(%q) = %q, want %q", SharesPseudoStack, got, SharesDisplayName) + } + if got := DisplayStackName("immich"); got != "immich" { + t.Errorf("DisplayStackName must be identity for ordinary stacks, got %q", got) + } +} + +// --- Part 2: tier-2 shares job -------------------------------------------------------------------- + +// Scenario B: an OPTIONAL (Felhőmentés-off) share is tier-2'd like any other — the tier-2 tier is the +// local copy every share gets; only the offsite tier discriminates. +func TestSharesTier2MirrorsBothClasses(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + env.addShare(t, "hdd_1", "dokumentumok", true) + env.addShare(t, "hdd_1", "filmek", false) + + if err := env.m.RunSharesTier2(); err != nil { + t.Fatalf("RunSharesTier2: %v", err) + } + key := sharesDriveKey(env.drives["hdd_1"]) + destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack) + + for _, share := range []string{"dokumentumok", "filmek"} { + want := filepath.Join(destBase, key, share) + if _, err := os.Stat(filepath.Join(want, share+".txt")); err != nil { + t.Errorf("share %s was not mirrored to %s: %v", share, want, err) + } + } + // The payload rides the target so it is independently restorable. + if _, err := os.Stat(filepath.Join(destBase, sharesPayloadDestRel, sharesManifestName)); err != nil { + t.Errorf("payload manifest missing from the tier-2 target: %v", err) + } + // Marker LAST: its presence means every leg + reconcile completed. + b, err := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)) + if err != nil || string(b) != tier2LayoutVersion { + t.Errorf("layout marker missing/wrong: %q, %v", b, err) + } + // Status is recorded under the reserved key, and renders as „Megosztások". + cd := env.m.SharesTier2Status() + if cd == nil || cd.LastStatus != "ok" { + t.Fatalf("shares tier-2 status = %+v, want LastStatus=ok", cd) + } +} + +// The target may NEVER be the leg's own source drive — that would be a same-disk copy pretending to +// be a second backup. With shares on two drives each group must land on the other one. +func TestSharesTier2NeverTargetsItsOwnSourceDrive(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + env.addShare(t, "hdd_1", "egy", true) + env.addShare(t, "hdd_2", "ketto", true) + + if err := env.m.RunSharesTier2(); err != nil { + t.Fatalf("RunSharesTier2: %v", err) + } + for _, call := range env.mirrored { + src, dst, _ := strings.Cut(call, "=>") + if strings.HasPrefix(src, env.drives["hdd_1"]) && strings.HasPrefix(dst, env.drives["hdd_1"]) { + t.Errorf("leg mirrored onto its own source drive: %s", call) + } + if strings.HasPrefix(src, env.drives["hdd_2"]) && strings.HasPrefix(dst, env.drives["hdd_2"]) { + t.Errorf("leg mirrored onto its own source drive: %s", call) + } + } +} + +// Scenario F: a share whose drive is marked away is skipped LOUDLY and its path reaches NO mirror +// argument — mirroring a dead mountpoint would copy an empty dir over a good backup. +func TestSharesTier2SkipsDeadMountAndContinues(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") + env.addShare(t, "hdd_1", "elo", true) + deadPath := env.addShare(t, "hdd_3", "halott", true) + if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil { + t.Fatal(err) + } + + if err := env.m.RunSharesTier2(); err != nil { + t.Fatalf("the run must continue for healthy shares: %v", err) + } + for _, call := range env.mirrored { + if strings.Contains(call, deadPath) { + t.Errorf("dead-mount share reached a mirror call: %s", call) + } + } + var sawLive bool + for _, dst := range env.mirroredDsts() { + if strings.HasSuffix(dst, string(filepath.Separator)+"elo") { + sawLive = true + } + } + if !sawLive { + t.Error("the healthy share must still be mirrored") + } +} + +// Zero shares / sharing disabled: a clean no-op — no dest tree, no status record, no empty dirs. +func TestSharesTier2ZeroSharesIsCleanNoOp(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + if err := env.m.RunSharesTier2(); err != nil { + t.Fatalf("RunSharesTier2: %v", err) + } + if len(env.mirrored) != 0 { + t.Errorf("expected no mirror calls, got %v", env.mirrored) + } + if cd := env.m.SharesTier2Status(); cd != nil { + t.Errorf("expected no status record for a feature nobody uses, got %+v", cd) + } + destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack) + if _, err := os.Stat(destBase); !os.IsNotExist(err) { + t.Errorf("expected no _shares dest tree, stat err = %v", err) + } +} + +// A share removed from the registry must stop occupying the secondary drive within ONE run — the +// reconcile pass prunes it, reusing the same destBase-bounded removal guard the app path uses. +func TestSharesTier2ReconcilePrunesRemovedShare(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + env.addShare(t, "hdd_1", "marad", true) + env.addShare(t, "hdd_1", "torolt", true) + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + key := sharesDriveKey(env.drives["hdd_1"]) + destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack) + stale := filepath.Join(destBase, key, "torolt") + if _, err := os.Stat(stale); err != nil { + t.Fatalf("precondition: %s should exist after the first run: %v", stale, err) + } + + if err := env.sett.RemoveSMBShare("torolt"); err != nil { + t.Fatal(err) + } + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("removed share's dest dir was not pruned: stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(destBase, key, "marad")); err != nil { + t.Errorf("surviving share was pruned by mistake: %v", err) + } +} + +// Drive keys must be collision-free across drives that share a basename. +func TestSharesDriveKeyIsCollisionFree(t *testing.T) { + if sharesDriveKey("/mnt/a/data") == sharesDriveKey("/mnt/b/data") { + t.Error("drives with the same basename must not map to the same dest key") + } + for _, bad := range []string{"/", "\x00", " "} { + if k := sharesDriveKey(bad); strings.ContainsAny(k, `/\`) { + t.Errorf("sharesDriveKey(%q) = %q — must be a single safe path segment", bad, k) + } + } +} + +// runtimeIsPOSIX reports whether file-mode assertions are meaningful on this host (Windows reports +// synthesised permissions, so mode checks there are noise rather than signal). +func runtimeIsPOSIX() bool { return os.PathSeparator == '/' } diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index f903441..2646dd3 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -86,6 +86,17 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool { // silently-wrong-owner restore). fullSize sizes the real-drive path; stateOnlySize sizes the SSD path. func (m *Manager) selectTier2Target(stackName string, fullSize, stateOnlySize int64) (*Tier2Target, error) { sourceDrive := m.GetAppDrivePath(stackName) + if sourceDrive == "" { + return nil, fmt.Errorf("no source drive for %s", stackName) + } + return m.selectTier2TargetFrom(stackName, sourceDrive, fullSize, stateOnlySize) +} + +// selectTier2TargetFrom is selectTier2Target with the source drive supplied EXPLICITLY. It exists so +// the R-7b shares source — whose "source drive" is the drive a group of shares lives on, not an app's +// GetAppDrivePath — can reuse this selection and its headroom math verbatim instead of forking it. +// The app path above is a thin wrapper; nothing about its behaviour changed. +func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, stateOnlySize int64) (*Tier2Target, error) { if sourceDrive == "" { return nil, fmt.Errorf("no source drive for %s", stackName) } @@ -232,6 +243,13 @@ func classifyTier2Rel(dirRel string, legRels []string) tier2RelClass { // descendant of any current leg relpath (§7-D — the deferred-pruning answer: a bind removed/re-classed // stops occupying the secondary drive within one run). Runs strictly inside destBase. func (m *Manager) tier2Reconcile(destBase string, legRels []string) { + m.tier2ReconcileRoots(destBase, []string{"hdd", "userdata"}, legRels) +} + +// tier2ReconcileRoots is tier2Reconcile with the top-level dest roots supplied explicitly — a pure +// extraction so the R-7b shares dest (whose roots are per-source-drive keys, not hdd/userdata) can +// reuse the SAME staleness classification and the SAME destBase-bounded removal guard. +func (m *Manager) tier2ReconcileRoots(destBase string, roots, legRels []string) { var walk func(dirAbs, dirRel string) walk = func(dirAbs, dirRel string) { entries, err := os.ReadDir(dirAbs) @@ -258,7 +276,7 @@ func (m *Manager) tier2Reconcile(destBase string, legRels []string) { } } } - for _, root := range []string{"hdd", "userdata"} { + for _, root := range roots { walk(filepath.Join(destBase, root), root) } } @@ -397,6 +415,14 @@ func (m *Manager) RunAllTier2() { } var n int for _, stack := range m.stackProvider.ListDeployedStacks() { + // Reserved-name defense in depth (R-7b): the shares source owns backups/secondary/_shares and + // the _shares status record. Stack names come from the git-synced catalog, not customer input, + // so this cannot realistically fire — but if it ever did, the app would silently overwrite the + // shares tree, so it is refused loudly instead. + if stack.Name == SharesPseudoStack { + m.logger.Printf("[ERROR] [backup] Tier 2: stack %q uses the RESERVED shares key — skipped to protect the shares backup tree", stack.Name) + continue + } // F6 (CAMPAIGN-3): volume-only apps (no HDD_PATH, backups on sys_drive) previously got NO // tier-2 copy — a single controller-level copy on one device. They now flow through too: their // recovery unit (which holds the db/volume dumps) gets a cross-drive second copy like any HDD @@ -416,6 +442,12 @@ func (m *Manager) RunAllTier2() { n++ } m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d app(s) processed (incl. volume-only — F6)", n) + + // R-7b: the SHARES source runs after the per-app loop, in the SAME orchestrator run. It is a + // sibling job — nothing above it changed — and its failure never fails the app tier. + if err := m.RunSharesTier2(); err != nil { + m.logger.Printf("[WARN] [backup] Tier 2 shares job failed: %v", err) + } } // --- per-app config-panel view (drives the Tier-2 "Beállítás" page) --- diff --git a/controller/internal/backup/tier2_shares.go b/controller/internal/backup/tier2_shares.go new file mode 100644 index 0000000..b9794b5 --- /dev/null +++ b/controller/internal/backup/tier2_shares.go @@ -0,0 +1,291 @@ +package backup + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// Tier-2 shares job — R-7b Part 2, the local cross-drive leg of Model B′. +// +// It runs AFTER the per-stack tier-2 loop, inside the SAME orchestrator run, and it is a SIBLING of +// that loop, never a modification of it: RunTier2 and its per-app dest tree are untouched (the B′ +// invariant, enforced by TestSharesTier2LeavesPerAppTreeUntouched). What it reuses instead is every +// primitive the per-app path proved: the tier2Mirror seam, selectTier2TargetFrom's target choice and +// headroom math, tier2ReconcileRoots' staleness pruning, tier2SafeRemove's destBase-bounded removal, +// the marker-written-LAST discipline, and the recordTier2* status helpers. +// +// Layout — backups/secondary/_shares/ on the target drive: +// +// .felhom-tier2-layout marker, content "2", written LAST (after every leg + reconcile) +// _payload/ the share definitions + credential copy (shares_payload.go) +// // one mirrored leg per share, grouped by the drive it came from +// +// Shares are grouped BY SOURCE DRIVE because a household's shares can span several drives and each +// group needs its own cross-drive target: a leg's target may never be that leg's own source drive. + +// sharesPayloadDestRel is the payload's relpath inside the shares dest (a reserved root name; the +// leading underscore cannot collide with a drive key because drive keys are derived from paths). +const sharesPayloadDestRel = "_payload" + +// sharesDriveKey turns an absolute source-drive path into ONE safe dest path segment. The full path +// is encoded (not just its basename) so two drives whose mountpoints share a basename — /mnt/a/data +// and /mnt/b/data — can never map onto the same dest subtree and silently overwrite each other. +// Deterministic and stable across runs, which is what lets the reconcile pass recognise its own dirs. +func sharesDriveKey(drive string) string { + s := strings.Trim(filepath.ToSlash(drive), "/") + if s == "" { + return "root" + } + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} + +// shareSourceDrive resolves the registered storage root a share's folder lives under. Returns "" when +// the share sits under no registered root — such a share has no meaningful "other drive" and is +// skipped with a warning rather than guessed at. +func (m *Manager) shareSourceDrive(path string) string { + if m.settings == nil { + return "" + } + p := filepath.ToSlash(path) + best := "" + for _, sp := range m.settings.GetStoragePaths() { + root := filepath.ToSlash(sp.Path) + if root == "" { + continue + } + // Longest match wins, so a nested registered root claims its own shares. Containment is + // compared in slash form, but the returned value is the REGISTRY'S OWN string: the target + // selector compares the source drive against sp.Path by equality, and handing it a normalised + // variant would make that comparison miss and let a group target its own source drive. + if (p == root || strings.HasPrefix(p, root+"/")) && len(root) > len(best) { + best = sp.Path + } + } + return best +} + +// sharesTier2Group is one source drive's worth of shares plus the target chosen for it. +type sharesTier2Group struct { + sourceDrive string + key string + shares []classifiedShare +} + +// RunSharesTier2 mirrors every classified, available share to a cross-drive target and stages the +// payload beside it. Best-effort and idempotent, exactly like RunTier2: an absent target is an +// honest recorded status, not an error. Returns the first hard copy error. +func (m *Manager) RunSharesTier2() error { + if !m.sharesEnabled() { + // Feature off or no shares registered: a clean no-op. Deliberately NOT a recorded status — + // writing one would make the „Megosztás" page claim a backup tier for a feature in use by + // nobody, and would create the dest tree for zero shares. + return nil + } + if m.settings != nil { + if cd := m.settings.GetCrossDriveConfig(SharesPseudoStack); cd != nil && cd.UserDisabled { + m.logger.Printf("[INFO] [shares] tier-2 skipped — disabled by customer") + return nil + } + } + shares := m.classifiedShares() + if len(shares) == 0 { + // Every registered share is on a drive that is away / a folder that vanished. That is a real + // operational state the customer must see, not silence. + m.recordTier2NoTarget(SharesPseudoStack, "egyetlen megosztás mappája sem érhető el — ellenőrizd a meghajtókat") + m.logger.Printf("[WARN] [shares] tier-2: no available share folders — nothing mirrored") + return nil + } + + // Group by source drive (deterministic order so logs and dest trees are reproducible). + byDrive := map[string]*sharesTier2Group{} + var warns []string + for _, sh := range shares { + drive := m.shareSourceDrive(sh.Path) + if drive == "" { + m.logger.Printf("[WARN] [shares] tier-2: share %s is not under a registered storage root — skipped", sh.Name) + warns = append(warns, fmt.Sprintf("A(z) „%s” megosztás nem regisztrált adatmeghajtón van — kimaradt a 2. mentésből.", sh.Name)) + continue + } + g := byDrive[drive] + if g == nil { + g = &sharesTier2Group{sourceDrive: drive, key: sharesDriveKey(drive)} + byDrive[drive] = g + } + g.shares = append(g.shares, sh) + } + var groups []*sharesTier2Group + for _, g := range byDrive { + groups = append(groups, g) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].sourceDrive < groups[j].sourceDrive }) + if len(groups) == 0 { + m.recordTier2NoTarget(SharesPseudoStack, "a megosztások egyike sincs regisztrált adatmeghajtón") + return nil + } + + // Payload once per run — both tiers read the same staged directory. + payloadDir, _, perr := m.buildSharesPayload() + if perr != nil { + m.logger.Printf("[WARN] [shares] tier-2: payload staging failed — mirroring files without the definition manifest: %v", perr) + warns = append(warns, "A megosztás-beállítások mentése nem sikerült — a fájlok mentése megtörtént.") + payloadDir = "" + } + + mirror := m.tier2Mirror + if mirror == nil { + mirror = rsyncMirror + } + start := time.Now() + var ( + totalSize int64 + lastTarget *Tier2Target + noTargetWhy []string + mirrored int + ) + for _, g := range groups { + // Two sizes, mirroring RunTier2's contract: full = every share in the group; state-only = the + // MANDATORY (Felhőmentés-on) subset, which is what the SSD headroom guard must fit. + var fullSize, stateOnlySize int64 + for _, sh := range g.shares { + sz := dirSizeBytes(sh.Path) + fullSize += sz + if sh.mandatory { + stateOnlySize += sz + } + } + target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize) + if err != nil { + why := tier2NoTargetReason(err) + noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why)) + m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why) + continue + } + // Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never + // be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2. + if system.SamePhysicalDevice(g.sourceDrive, target.NamespaceRoot) { + noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive)) + continue + } + legs := g.shares + if target.StateOnly { + kept := legs[:0] + dropped := false + for _, sh := range legs { + if sh.mandatory { + kept = append(kept, sh) + } else { + dropped = true + } + } + legs = kept + if dropped { + warns = append(warns, "A belső SSD-re csak a felhőmentésre jelölt megosztások férnek el — a többi nem került másolásra.") + } + } + + destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", SharesPseudoStack) + legRels := make([]string, 0, len(legs)+1) + for _, sh := range legs { + rel := g.key + "/" + sh.Name + if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil { + m.recordTier2Failure(SharesPseudoStack, target, err) + if m.tier2Notify != nil { + m.tier2Notify(SharesPseudoStack, target.Label, time.Since(start), err) + } + return fmt.Errorf("tier2 shares mirror %s: %w", sh.Name, err) + } + legRels = append(legRels, rel) + totalSize += dirSizeBytes(sh.Path) + mirrored++ + } + // The payload rides every target so each one is independently restorable. + if payloadDir != "" { + if err := mirror(payloadDir, filepath.Join(destBase, sharesPayloadDestRel)); err != nil { + m.logger.Printf("[WARN] [shares] tier-2: payload mirror to %s failed (files are copied): %v", destBase, err) + } else { + legRels = append(legRels, sharesPayloadDestRel) + } + } + // Prune dest dirs no share covers any more (a share deleted or renamed since the last run + // stops occupying the secondary drive within one run), then the marker LAST — a half-written + // dest therefore has no marker and the restore path refuses it until the next good run. + m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels) + if err := m.writeTier2Marker(destBase); err != nil { + m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err) + } + lastTarget = target + m.logger.Printf("[INFO] [shares] tier-2 copied %d share(s) from %s → %s (%s)", + len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize)) + } + + dur := time.Since(start) + if lastTarget == nil { + reason := strings.Join(noTargetWhy, "; ") + if reason == "" { + reason = "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges" + } + m.recordTier2NoTarget(SharesPseudoStack, reason) + m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for any share group — %s", reason) + return nil + } + if len(noTargetWhy) > 0 { + warns = append(warns, "Néhány meghajtón lévő megosztásnak nincs másodlagos célja: "+strings.Join(noTargetWhy, "; ")) + } + m.recordTier2Success(SharesPseudoStack, lastTarget, totalSize, strings.Join(warns, " "), dur) + if m.tier2Notify != nil { + m.tier2Notify(SharesPseudoStack, lastTarget.Label, dur, nil) + } + m.logger.Printf("[INFO] [shares] tier-2 run complete: %d share leg(s), %s, %s", + mirrored, humanizeBytes(totalSize), dur.Round(time.Second)) + return nil +} + +// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may +// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that +// is no longer registered still gets visited (and pruned) instead of lingering forever. +func sharesTier2DestRoots(destBase string) []string { + entries, err := os.ReadDir(destBase) + if err != nil { + return nil + } + var roots []string + for _, e := range entries { + if e.IsDir() { + roots = append(roots, e.Name()) + } + } + sort.Strings(roots) + return roots +} + +// writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its +// presence means "every leg and the reconcile completed". +func (m *Manager) writeTier2Marker(destBase string) error { + return os.WriteFile(filepath.Join(destBase, tier2LayoutMarker), []byte(tier2LayoutVersion), 0o644) +} + +// SharesTier2Status returns the recorded shares tier-2 status for the „Megosztás" page (nil when the +// job has never run). It is the SAME CrossDriveBackup record the per-app rows use, keyed by the +// reserved pseudo-stack. +func (m *Manager) SharesTier2Status() *settings.CrossDriveBackup { + if m.settings == nil { + return nil + } + return m.settings.GetCrossDriveConfig(SharesPseudoStack) +} diff --git a/controller/internal/infra/samba.go b/controller/internal/infra/samba.go index b74b7de..2b01177 100644 --- a/controller/internal/infra/samba.go +++ b/controller/internal/infra/samba.go @@ -30,6 +30,21 @@ type SambaData struct { // SambaHouseholdUser is the single household SMB account name (matches the entrypoint's unix user). const SambaHouseholdUser = "felhom" +// SambaContainerName is the fixed container name the compose render pins. It is THE single source of +// truth for it: the renderer below interpolates this constant, stacks.sambaContainer aliases it for +// the exec paths, monitor's effective-protected set watches it (R-7b liveness), and the backup +// package execs it for the passdb capture/restore. Anything that needs "which container is samba" +// reads this — never a second string literal. +const SambaContainerName = "felhom-samba" + +// SambaPassdbVolume / SambaPassdbMount name the docker volume that holds the household SMB credential +// (the passdb tdb set) and where the container mounts it. R-7b's payload capture/restore targets this +// mount point, so it is pinned here beside the render that creates it rather than duplicated there. +const ( + SambaPassdbVolume = "samba-passdb" + SambaPassdbMount = "/var/lib/samba" +) + // RenderSambaConfig renders smb.conf: the hardened global block (bind interfaces only = lo eth0, // SMB2+ floor, NetBIOS on for flat-name resolution) plus one [section] per share. Deterministic: // shares are emitted in the given order (the caller preserves registry order). force user/group pin @@ -95,21 +110,21 @@ func RenderSambaCompose(d SambaData) string { # receive the LAN multicast that WSD/mDNS discovery needs. services: - felhom-samba: - image: %s - container_name: felhom-samba + %[1]s: + image: %[2]s + container_name: %[1]s restart: unless-stopped network_mode: host environment: - - FELHOM_SERVER_NAME=%s + - FELHOM_SERVER_NAME=%[3]s - FELHOM_IFACE=eth0 - - FELHOM_UID=%d - - FELHOM_GID=%d + - FELHOM_UID=%[4]d + - FELHOM_GID=%[4]d volumes: - ./smb.conf:/etc/samba/smb.conf:ro - - samba-passdb:/var/lib/samba -%s + - %[5]s:%[6]s +%[7]s volumes: - samba-passdb: -`, SambaImage, d.ServerName, d.UID, d.UID, binds.String()) + %[5]s: +`, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String()) } diff --git a/controller/internal/settings/smb.go b/controller/internal/settings/smb.go index e0d43b9..6306a9c 100644 --- a/controller/internal/settings/smb.go +++ b/controller/internal/settings/smb.go @@ -66,6 +66,15 @@ func ValidateSMBShareName(name string) error { if strings.ContainsAny(name, `/\.` ) { return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot") } + // RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares" + // — a restic tag, a tier-2 dest root and a status record. nbNameRe below starts with [A-Za-z0-9_], + // so before this guard „_shares" was an ACCEPTED share name and the underscore namespace was not in + // fact reserved (the R-7b task's assumption to the contrary was verified false here). Reserving the + // whole leading-underscore space keeps future system keys collision-free too. Validation runs on + // ADD only, so an already-registered share is never invalidated retroactively. + if strings.HasPrefix(name, "_") { + return fmt.Errorf("a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva") + } if !nbNameRe.MatchString(name) { return fmt.Errorf("a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat") } diff --git a/controller/internal/stacks/samba.go b/controller/internal/stacks/samba.go index aaaf116..8e1df19 100644 --- a/controller/internal/stacks/samba.go +++ b/controller/internal/stacks/samba.go @@ -22,8 +22,9 @@ import ( const ( // SambaStackName is the stack directory name under StacksDir (and the protected-stack key). SambaStackName = "samba" - // sambaContainer is the fixed container name (set in the generated compose). - sambaContainer = "felhom-samba" + // sambaContainer is the fixed container name. Aliased from the RENDERER's constant so the compose + // this package writes and the execs it runs can never name different containers. + sambaContainer = infra.SambaContainerName // sambaUID is the household uid/gid every SMB write is forced to, so apps (group 1000) and both // backup tiers see consistent ownership. sambaUID = 1000