feat(samba): lifecycle — ensureSamba/ReconcileSamba/password/disable (R-7 slice 1, Part 2)
ensureSamba joins EnsureBaseStack after filebrowser, gated on SMB.Enabled (cloudflared conditional precedent); reconcile is idempotent (unchanged config + running container = ZERO compose calls, asserted via seam). Atomic tmp+fsync+ rename config writes. Password applied via smbpasswd on STDIN (never argv/log/ settings). Disable = compose down, volumes + folders KEPT. samba added to IsProtectedStack in code (controller.yaml is golden-generated and predates it), which also makes the app-backup loops correctly skip it.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, "; "))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user