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 ) // SharingDeniedRoots returns the SYSTEM subset of ProtectedHDDPaths(root) whose SUBTREES may never // be exported over SMB: appdata/ (live app databases — writable SMB access to them is the [R3] // corruption foot-gun), backups/, and the legacy felhom-data nest. // // The drive root itself is deliberately NOT in this set: it is denied by an EXACT-match check at the // call site ("a whole drive is not shareable"). Putting it here would make every path under the drive // — i.e. every legitimate share — match the subtree rule and be refused. // // It is DERIVED from ProtectedHDDPaths, never a parallel list: each candidate is emitted only if that // guard already contains it, so this set can only ever SHRINK relative to the delete guard — it can // never drift into a stale second source of truth. media/ and Dokumentumok/ are protected THERE as // delete targets but are customer data and stay shareable (Scenario B shares media/filmek). func SharingDeniedRoots(root string) []string { if root == "" { return nil } protected := ProtectedHDDPaths(root) candidates := []string{ filepath.Join(root, "appdata"), filepath.Join(root, "backups"), filepath.Join(root, felhomDataDir), filepath.Join(root, felhomDataDir, "appdata"), filepath.Join(root, felhomDataDir, "backups"), } var out []string for _, c := range candidates { if protected[c] { out = append(out, c) } } return out } // 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() }