diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go index ca03ed4..4549f6a 100644 --- a/controller/internal/config/config.go +++ b/controller/internal/config/config.go @@ -396,8 +396,22 @@ func validate(cfg *Config) error { return nil } -// IsProtectedStack checks if a stack name is in the protected list. +// alwaysProtectedStacks are controller-MANAGED infra stacks that are protected regardless of the +// configured list. cfg.Stacks.Protected comes from controller.yaml (golden/bootstrap-generated) and +// predates these, so a box whose controller.yaml has not been regenerated would otherwise expose +// them as ordinary, stoppable/deletable apps. Protection means: no stop/delete from the UI, and the +// app-backup loops skip them (they are infrastructure, not customer apps — samba's share data is +// classified through its own registry instead, see stacks.ClassifiedBinds). +var alwaysProtectedStacks = map[string]bool{ + "samba": true, // LAN network-sharing infra stack (R-7) +} + +// IsProtectedStack checks if a stack name is protected — either controller-managed infra (always) +// or listed in the configured protected list. func (cfg *Config) IsProtectedStack(name string) bool { + if alwaysProtectedStacks[strings.ToLower(name)] { + return true + } for _, p := range cfg.Stacks.Protected { if strings.EqualFold(p, name) { return true diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index 7e53c8c..8129955 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -71,6 +71,15 @@ func (m *Manager) EnsureBaseStack() error { errs = append(errs, fmt.Sprintf("filebrowser: %v", err)) } + // samba (LAN network-sharing, R-7) — conditional deploy, same shape as cloudflared: only when the + // customer turned the feature on. reconcileSambaAt additionally holds off until a household SMB + // password exists. Deployed LAST (it joins no docker network — host networking by spike mandate). + if m.settings != nil && m.settings.GetSMBSettings().Enabled { + if err := m.ensureSamba(filepath.Join(base, SambaStackName)); err != nil { + errs = append(errs, fmt.Sprintf("samba: %v", err)) + } + } + if len(errs) > 0 { return fmt.Errorf("base-infra bring-up: %s", strings.Join(errs, "; ")) } diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 0fdfcfb..4a0f729 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -111,6 +111,13 @@ type Manager struct { // isMountPoint reports whether a path is a live mountpoint; defaults to system.IsMountPoint. // Injectable so the userdata-belt drive-absent gate is testable (a t.TempDir is never a real mount). isMountPoint func(string) bool + + // Samba (R-7) seams — nil in production. sambaUpFn replaces the `compose up -d` call (tests + // assert the idempotent no-op performs ZERO calls); sambaPasswdFn replaces the smbpasswd + // docker-exec so no unit test touches docker or handles a real secret. + sambaUpFn func(dir string) error + sambaPasswdFn func(password string) error + sambaRunFn func() bool // replaces the docker-inspect liveness probe } // NewManager creates a new stack manager. diff --git a/controller/internal/stacks/samba.go b/controller/internal/stacks/samba.go new file mode 100644 index 0000000..404f750 --- /dev/null +++ b/controller/internal/stacks/samba.go @@ -0,0 +1,271 @@ +package stacks + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Samba (LAN network-sharing) lifecycle — R-7 slice 1. The FOURTH protected infra stack, deployed +// and reconciled entirely by the controller (never a catalog app: it needs host networking, its +// config is a generated share list, and its roots ride the backup classification). +// +// Destructive-write boundary: NOTHING here deletes or moves customer files. Disabling the feature or +// deleting a share is a CONFIG-only operation — the folder and its contents always survive. + +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" + // 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 +) + +// sambaDir is the samba stack directory. +func (m *Manager) sambaDir() string { + return filepath.Join(m.cfg.Paths.StacksDir, SambaStackName) +} + +// sambaUp runs `docker compose up -d` for the samba stack through an injectable seam (tests count +// calls to assert the idempotent no-op performs none). +func (m *Manager) sambaUp(dir string) error { + if m.sambaUpFn != nil { + return m.sambaUpFn(dir) + } + return m.composeUp(dir) +} + +// sambaIsRunning probes the samba container's liveness through an injectable seam. +func (m *Manager) sambaIsRunning() bool { + if m.sambaRunFn != nil { + return m.sambaRunFn() + } + return containerRunning(sambaContainer) +} + +// shareAvailable reports whether a share's folder can be exported right now: its owning registered +// storage path must be neither disconnected nor decommissioned, AND the folder must exist. A dead +// mount is NEVER exported — publishing a missing mountpoint would show an empty share and let a +// write land on the underlying root directory instead of the drive. +func (m *Manager) shareAvailable(path string) bool { + if m.settings != nil { + // Separator-agnostic containment: production paths are POSIX, but the check must not silently + // no-op on a non-POSIX separator (which would export a share on a drive marked away). + 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() +} + +// sambaRenderData builds the renderer input from the shares registry, dropping unavailable shares +// (the config for them is retained; only the export is withheld). +func (m *Manager) sambaRenderData(smb settings.SMBSettings) infra.SambaData { + d := infra.SambaData{ServerName: smb.EffectiveServerName(), UID: sambaUID} + if m.settings == nil { + return d + } + for _, sh := range m.settings.GetSMBShares() { + if !m.shareAvailable(sh.Path) { + m.logger.Printf("[WARN] [samba] share omitted from smb.conf — folder unavailable: name=%s", sh.Name) + continue + } + d.Shares = append(d.Shares, infra.SambaShareRender{Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly}) + } + return d +} + +// ensureSamba is the boot / health-tick entry, called from EnsureBaseStack (which already holds +// infraMu — so this must NOT re-acquire it). +func (m *Manager) ensureSamba(dir string) error { + return m.reconcileSambaAt(dir) +} + +// ReconcileSamba re-renders and applies the samba stack. Called after EVERY share/settings mutation +// from the web layer. Takes infraMu (the mutation path is outside EnsureBaseStack). +func (m *Manager) ReconcileSamba() error { + m.infraMu.Lock() + defer m.infraMu.Unlock() + return m.reconcileSambaAt(m.sambaDir()) +} + +// reconcileSambaAt is the single lifecycle core. Idempotent: when the rendered config is unchanged +// AND the container is running it performs NO compose call at all. +func (m *Manager) reconcileSambaAt(dir string) error { + if m.settings == nil { + return nil + } + smb := m.settings.GetSMBSettings() + if !smb.Enabled { + return nil // disable is an explicit action (DisableSamba), not a silent down here + } + if !smb.UserSet { + // Edge case: sharing on but no household password yet → the stack stays undeployed and the + // UI blocks with „először adj meg jelszót". (SetSMBPassword brings it up as part of the + // password-set action, which is the only way UserSet becomes true.) + m.logger.Printf("[INFO] [samba] deploy skipped — household SMB password not set yet") + return nil + } + data := m.sambaRenderData(smb) + changed, err := m.writeSambaFiles(dir, data) + if err != nil { + return err + } + if !changed && m.sambaIsRunning() { + return nil // nothing to do — no compose call + } + m.logger.Printf("[INFO] [samba] applying samba stack: shares=%d config_changed=%v", len(data.Shares), changed) + return m.sambaUp(dir) +} + +// writeSambaFiles renders and atomically writes smb.conf + docker-compose.yml, reporting whether +// either actually changed on disk (the idempotency signal). +func (m *Manager) writeSambaFiles(dir string, d infra.SambaData) (bool, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return false, fmt.Errorf("mkdir %s: %w", dir, err) + } + files := []struct{ name, content string }{ + {"smb.conf", infra.RenderSambaConfig(d)}, + {"docker-compose.yml", infra.RenderSambaCompose(d)}, + } + changed := false + for _, f := range files { + p := filepath.Join(dir, f.name) + if cur, err := os.ReadFile(p); err == nil && string(cur) == f.content { + continue + } + if err := sambaWriteAtomic(p, []byte(f.content), 0o644); err != nil { + return changed, fmt.Errorf("write %s: %w", f.name, err) + } + changed = true + } + return changed, nil +} + +// sambaWriteAtomic writes via tmp + fsync + rename so a crash mid-write can never leave smbd with a +// truncated config (crash-safety per the lifecycle contract). +func sambaWriteAtomic(path string, data []byte, mode os.FileMode) error { + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + 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 +} + +// SetSMBPassword applies the household SMB password. +// +// SECRET HANDLING (rule 4): the password exists ONLY as this argument and on smbpasswd's stdin. It +// is never logged (not even at DEBUG), never written to settings.json, and never included in an +// error string — settings records ONLY the boolean UserSet. +func (m *Manager) SetSMBPassword(password string) error { + m.infraMu.Lock() + defer m.infraMu.Unlock() + if m.settings == nil { + return fmt.Errorf("a beállítások nem érhetők el") + } + smb := m.settings.GetSMBSettings() + if !smb.Enabled { + return fmt.Errorf("a hálózati megosztás nincs bekapcsolva") + } + dir := m.sambaDir() + data := m.sambaRenderData(smb) + if _, err := m.writeSambaFiles(dir, data); err != nil { + return err + } + // The container must be running to accept smbpasswd. Bringing it up before a password exists is + // safe: `security = user` + `map to guest = never` means nothing is reachable until this lands. + if !m.sambaIsRunning() { + if err := m.sambaUp(dir); err != nil { + return fmt.Errorf("a megosztás szolgáltatás indítása sikertelen: %w", err) + } + } + if err := m.sambaSetPassword(password); err != nil { + return err + } + if err := m.settings.SetSMBUserSet(true); err != nil { + return err + } + m.logger.Printf("[INFO] [samba] household SMB password applied for user=%s", infra.SambaHouseholdUser) + return nil +} + +// sambaSetPassword runs smbpasswd inside the container with the password on STDIN (never argv — +// argv is world-readable via /proc). Injectable seam so unit tests never touch docker. +func (m *Manager) sambaSetPassword(password string) error { + if m.sambaPasswdFn != nil { + return m.sambaPasswdFn(password) + } + var lastErr error + // The container may need a moment to accept exec right after `compose up -d`. + for attempt := 1; attempt <= 10; attempt++ { + cmd := exec.Command("docker", "exec", "-i", sambaContainer, + "smbpasswd", "-s", "-a", infra.SambaHouseholdUser) + cmd.Stdin = strings.NewReader(password + "\n" + password + "\n") + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + // smbpasswd's output carries status text only — never the password — but truncate anyway. + lastErr = fmt.Errorf("smbpasswd: %s: %w", truncateStr(strings.TrimSpace(string(out)), 200), err) + time.Sleep(time.Second) + } + return lastErr +} + +// DisableSamba stops the stack. The passdb volume AND every shared folder are KEPT — disabling +// network sharing never destroys data (Scenario E). +func (m *Manager) DisableSamba() error { + m.infraMu.Lock() + defer m.infraMu.Unlock() + dir := m.sambaDir() + if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err != nil { + return nil // never deployed — nothing to stop + } + if _, err := m.composeExec(dir, "down"); err != nil { + return fmt.Errorf("a megosztás leállítása sikertelen: %w", err) + } + m.logger.Printf("[INFO] [samba] stack stopped (passdb volume and all shared folders kept)") + return nil +} + +// SambaRunning reports whether the samba container is currently up (UI status line). +func (m *Manager) SambaRunning() bool { return m.sambaIsRunning() } diff --git a/controller/internal/stacks/samba_test.go b/controller/internal/stacks/samba_test.go new file mode 100644 index 0000000..2cecd72 --- /dev/null +++ b/controller/internal/stacks/samba_test.go @@ -0,0 +1,298 @@ +package stacks + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// newSambaManager builds a Manager whose samba lifecycle runs for real (render → atomic write → +// change detection) with ONLY the docker touchpoints seamed out: compose up, smbpasswd, liveness. +func newSambaManager(t *testing.T) (*Manager, *settings.Settings, string, *int) { + t.Helper() + root := t.TempDir() + cfg := &config.Config{} + cfg.Paths.StacksDir = filepath.Join(root, "stacks") + + sett, err := settings.Load(filepath.Join(root, "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatalf("settings.Load: %v", err) + } + upCalls := 0 + m := &Manager{ + cfg: cfg, + logger: log.New(io.Discard, "", 0), + stacks: map[string]*Stack{}, + settings: sett, + sambaUpFn: func(string) error { upCalls++; return nil }, + sambaRunFn: func() bool { return false }, + } + return m, sett, root, &upCalls +} + +// seedShare registers a storage root and creates a share folder with a file inside it. +func seedShare(t *testing.T, sett *settings.Settings, root, name string) string { + t.Helper() + storageRoot := filepath.Join(root, "drive") + shareDir := filepath.Join(storageRoot, "shares", name) + if err := os.MkdirAll(shareDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(shareDir, "csalad.txt"), []byte("fontos adat"), 0o644); err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{ + Path: storageRoot, Label: "teszt", Schedulable: true, + AddedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatal(err) + } + return shareDir +} + +// snapshotTree hashes every file under dir so a test can prove NOTHING changed. +func snapshotTree(t *testing.T, dir string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if fi.IsDir() { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + sum := sha256.Sum256(b) + rel, _ := filepath.Rel(dir, p) + out[rel] = hex.EncodeToString(sum[:]) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func enableSMB(t *testing.T, sett *settings.Settings) { + t.Helper() + if err := sett.SetSMBEnabled(true); err != nil { + t.Fatal(err) + } + if err := sett.SetSMBUserSet(true); err != nil { + t.Fatal(err) + } +} + +// Scenario A: enable + first share → smb.conf carries exactly that share, compose written, one up. +func TestSambaReconcile_HappyPath(t *testing.T) { + m, sett, root, upCalls := newSambaManager(t) + shareDir := seedShare(t, sett, root, "dokumentumok") + enableSMB(t, sett) + if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil { + t.Fatal(err) + } + + if err := m.ReconcileSamba(); err != nil { + t.Fatalf("ReconcileSamba: %v", err) + } + conf, err := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf")) + if err != nil { + t.Fatalf("smb.conf not written: %v", err) + } + if !strings.Contains(string(conf), "[dokumentumok]") { + t.Errorf("share section missing:\n%s", conf) + } + if !strings.Contains(string(conf), "path = "+shareDir) { + t.Errorf("share path missing:\n%s", conf) + } + if !strings.Contains(string(conf), "force user = felhom") { + t.Error("force user block missing") + } + if _, err := os.Stat(filepath.Join(m.sambaDir(), "docker-compose.yml")); err != nil { + t.Errorf("compose not written: %v", err) + } + if *upCalls != 1 { + t.Errorf("expected exactly 1 compose up, got %d", *upCalls) + } +} + +// §10 idempotency: re-running with an UNCHANGED registry while the container runs performs ZERO +// compose calls (the fake seam is the assertion). +func TestSambaReconcile_IdempotentNoComposeCall(t *testing.T) { + m, sett, root, upCalls := newSambaManager(t) + shareDir := seedShare(t, sett, root, "dokumentumok") + enableSMB(t, sett) + if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil { + t.Fatal(err) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + if *upCalls != 1 { + t.Fatalf("setup: expected 1 up, got %d", *upCalls) + } + // Container now running + config unchanged → the re-run must be a pure no-op. + m.sambaRunFn = func() bool { return true } + before := *upCalls + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + if *upCalls != before { + t.Errorf("unchanged registry must perform NO compose call: calls went %d → %d", before, *upCalls) + } + // A real change must still apply. + if err := sett.SetSMBShareOffsite("dokumentumok", false); err != nil { + t.Fatal(err) + } + if err := sett.RemoveSMBShare("dokumentumok"); err != nil { + t.Fatal(err) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + if *upCalls != before+1 { + t.Errorf("a changed registry must trigger exactly one compose up, got %d", *upCalls-before) + } +} + +// Edge case: a share on a DISCONNECTED storage path is never exported (config retained). +func TestSambaReconcile_DeadMountOmitted(t *testing.T) { + m, sett, root, _ := newSambaManager(t) + shareDir := seedShare(t, sett, root, "filmek") + enableSMB(t, sett) + if err := sett.AddSMBShare(settings.SMBShare{Name: "filmek", Path: shareDir, Offsite: true}); err != nil { + t.Fatal(err) + } + if err := sett.SetDisconnected(filepath.Join(root, "drive"), true, nil); err != nil { + t.Fatal(err) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + conf, err := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(conf), "[filmek]") { + t.Errorf("a share on a disconnected drive must NOT be exported:\n%s", conf) + } + // The share config itself is retained (only the export is withheld). + if len(sett.GetSMBShares()) != 1 { + t.Error("share config must be retained while the drive is away") + } +} + +// Scenario E: deleting a share and disabling sharing NEVER touch the folder or its contents. +func TestSambaShareDeleteAndDisableKeepData(t *testing.T) { + m, sett, root, _ := newSambaManager(t) + shareDir := seedShare(t, sett, root, "dokumentumok") + enableSMB(t, sett) + if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil { + t.Fatal(err) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + before := snapshotTree(t, shareDir) + + // Delete the share → section gone, data identical. + if err := sett.RemoveSMBShare("dokumentumok"); err != nil { + t.Fatal(err) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + conf, _ := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf")) + if strings.Contains(string(conf), "[dokumentumok]") { + t.Error("deleted share must be absent from smb.conf") + } + if got := snapshotTree(t, shareDir); !sameTree(before, got) { + t.Errorf("share delete must not touch the folder:\nbefore=%v\nafter=%v", before, got) + } + + // Disable sharing → data still identical. + if err := sett.SetSMBEnabled(false); err != nil { + t.Fatal(err) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + if got := snapshotTree(t, shareDir); !sameTree(before, got) { + t.Errorf("disabling sharing must not touch the folder:\nbefore=%v\nafter=%v", before, got) + } +} + +func sameTree(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +// Rule 4: the household SMB password reaches smbpasswd's stdin and NOTHING else — settings.json +// must carry only the UserSet boolean. +func TestSambaPasswordNeverPersisted(t *testing.T) { + m, sett, root, _ := newSambaManager(t) + enableSMB(t, sett) + if err := sett.SetSMBUserSet(false); err != nil { + t.Fatal(err) + } + + const secret = "TitkosJelszo123" + var seen string + m.sambaPasswdFn = func(pw string) error { seen = pw; return nil } + + if err := m.SetSMBPassword(secret); err != nil { + t.Fatalf("SetSMBPassword: %v", err) + } + if seen != secret { + t.Errorf("password did not reach the smbpasswd seam: got %q", seen) + } + if !sett.GetSMBSettings().UserSet { + t.Error("UserSet must be recorded after a successful password apply") + } + + // The secret must appear in NO persisted artefact: settings.json, smb.conf, or compose. + for _, p := range []string{ + filepath.Join(root, "settings.json"), + filepath.Join(m.sambaDir(), "smb.conf"), + filepath.Join(m.sambaDir(), "docker-compose.yml"), + } { + b, err := os.ReadFile(p) + if err != nil { + continue // not all files exist in every path + } + if strings.Contains(string(b), secret) { + t.Errorf("SMB password leaked into %s", p) + } + } +} + +// A password apply is refused while the feature is off (no container should ever be started for it). +func TestSambaPasswordRefusedWhenDisabled(t *testing.T) { + m, _, _, upCalls := newSambaManager(t) + m.sambaPasswdFn = func(string) error { t.Fatal("smbpasswd must not run while disabled"); return nil } + if err := m.SetSMBPassword("x"); err == nil { + t.Error("setting a password while sharing is disabled must be refused") + } + if *upCalls != 0 { + t.Errorf("no compose call may happen while disabled, got %d", *upCalls) + } +}