From b0c5ef4823155aa9ac83d340d28f3e57d480e3a3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 18 Jul 2026 11:21:05 +0200 Subject: [PATCH] feat(samba): settings registry + smb.conf/compose renderers (R-7 slice 1, Part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMBSettings + SMBShare registry in settings (password never stored — only UserSet); NetBIOS-safe name validation. Pure infra renderers: RenderSambaConfig (hardened global block: SMB2 floor, bind interfaces only=lo eth0, disable netbios=no, force user block) + RenderSambaCompose (network_mode host, pinned image, :ro bind for read-only shares, passdb volume). Exact smb.conf golden + CRUD/validation tests. --- controller/internal/infra/infra.go | 3 + controller/internal/infra/samba.go | 115 +++++++++++++ controller/internal/infra/samba_test.go | 103 ++++++++++++ controller/internal/settings/settings.go | 8 + controller/internal/settings/smb.go | 199 +++++++++++++++++++++++ controller/internal/settings/smb_test.go | 140 ++++++++++++++++ 6 files changed, 568 insertions(+) create mode 100644 controller/internal/infra/samba.go create mode 100644 controller/internal/infra/samba_test.go create mode 100644 controller/internal/settings/smb.go create mode 100644 controller/internal/settings/smb_test.go diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index a6348ca..1f7d6ab 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -25,6 +25,9 @@ const ( TraefikImage = "traefik:v3.6.7" CloudflaredImage = "cloudflare/cloudflared:2026.6.0" FileBrowserImage = "gtstef/filebrowser:1.3.3-stable" + // SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by + // controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest. + SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.0.0" ) //go:embed templates/*.tmpl diff --git a/controller/internal/infra/samba.go b/controller/internal/infra/samba.go new file mode 100644 index 0000000..b74b7de --- /dev/null +++ b/controller/internal/infra/samba.go @@ -0,0 +1,115 @@ +package infra + +import ( + "fmt" + "sort" + "strings" +) + +// Samba (LAN network-sharing) renderers — R-7 slice 1. PURE like the rest of this package: share +// list in, file contents out; no docker, no filesystem. The orchestration (availability filtering, +// write, compose up, smbpasswd) lives in internal/stacks/samba.go (ReconcileSamba). The transport + +// daemon set are the R-6 spike verdict (SPIKE-lan-discovery-2026-07-18): host network, smbd + nmbd + +// wsdd, `bind interfaces only` on lo+eth0. + +// SambaShareRender is one exported folder as the renderer needs it (already availability-filtered by +// the caller — a dead-mount share is simply absent from the slice, never rendered). +type SambaShareRender struct { + Name string + Path string // absolute host path + ReadOnly bool +} + +// SambaData is the full input for both samba renderers. +type SambaData struct { + ServerName string // NetBIOS name (validated by the settings layer) + Shares []SambaShareRender + UID int // household uid/gid the container runs shares as (1000) +} + +// SambaHouseholdUser is the single household SMB account name (matches the entrypoint's unix user). +const SambaHouseholdUser = "felhom" + +// 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 +// every written file to the household uid so apps and both backup tiers see consistent ownership. +func RenderSambaConfig(d SambaData) string { + var b strings.Builder + b.WriteString("# Samba (LAN network-sharing) — managed by felhom-controller (R-7).\n") + b.WriteString("# WARNING: auto-generated. Manual edits are overwritten on the next share change.\n") + b.WriteString("[global]\n") + b.WriteString(" workgroup = WORKGROUP\n") + b.WriteString(" server string = Felhom hálózati megosztás\n") + fmt.Fprintf(&b, " netbios name = %s\n", d.ServerName) + b.WriteString(" security = user\n") + b.WriteString(" map to guest = never\n") + b.WriteString(" server min protocol = SMB2\n") + b.WriteString(" disable netbios = no\n") + b.WriteString(" bind interfaces only = yes\n") + b.WriteString(" interfaces = lo eth0\n") + b.WriteString(" smb ports = 445\n") + b.WriteString(" load printers = no\n") + b.WriteString(" printing = bsd\n") + b.WriteString(" printcap name = /dev/null\n") + b.WriteString(" disable spoolss = yes\n") + + for _, sh := range d.Shares { + ro := "no" + if sh.ReadOnly { + ro = "yes" + } + fmt.Fprintf(&b, "\n[%s]\n", sh.Name) + fmt.Fprintf(&b, " path = %s\n", sh.Path) + fmt.Fprintf(&b, " read only = %s\n", ro) + fmt.Fprintf(&b, " valid users = %s\n", SambaHouseholdUser) + fmt.Fprintf(&b, " force user = %s\n", SambaHouseholdUser) + fmt.Fprintf(&b, " force group = %s\n", SambaHouseholdUser) + b.WriteString(" create mask = 0644\n") + b.WriteString(" directory mask = 0755\n") + } + return b.String() +} + +// RenderSambaCompose renders the samba stack's docker-compose.yml: host network (the spike mandate — +// the default bridge is deaf to LAN multicast), the pinned image, smb.conf bind-mounted read-only, +// the passdb named volume, and one bind per share (:ro for read-only shares — defense in depth beside +// the smb.conf-level `read only`). Deterministic: share binds are sorted so the output is stable. +func RenderSambaCompose(d SambaData) string { + shares := make([]SambaShareRender, len(d.Shares)) + copy(shares, d.Shares) + sort.Slice(shares, func(i, j int) bool { return shares[i].Path < shares[j].Path }) + + var binds strings.Builder + for _, sh := range shares { + if sh.ReadOnly { + fmt.Fprintf(&binds, " - %s:%s:ro\n", sh.Path, sh.Path) + } else { + fmt.Fprintf(&binds, " - %s:%s\n", sh.Path, sh.Path) + } + } + + return fmt.Sprintf(`# Samba (LAN network-sharing) — managed by felhom-controller (R-7 slice 1). +# WARNING: auto-generated. Manual edits are overwritten on the next share change. +# Host network is REQUIRED (SPIKE-lan-discovery-2026-07-18): the default docker bridge cannot +# receive the LAN multicast that WSD/mDNS discovery needs. + +services: + felhom-samba: + image: %s + container_name: felhom-samba + restart: unless-stopped + network_mode: host + environment: + - FELHOM_SERVER_NAME=%s + - FELHOM_IFACE=eth0 + - FELHOM_UID=%d + - FELHOM_GID=%d + volumes: + - ./smb.conf:/etc/samba/smb.conf:ro + - samba-passdb:/var/lib/samba +%s +volumes: + samba-passdb: +`, SambaImage, d.ServerName, d.UID, d.UID, binds.String()) +} diff --git a/controller/internal/infra/samba_test.go b/controller/internal/infra/samba_test.go new file mode 100644 index 0000000..6dfface --- /dev/null +++ b/controller/internal/infra/samba_test.go @@ -0,0 +1,103 @@ +package infra + +import ( + "strings" + "testing" +) + +func sampleSambaData() SambaData { + return SambaData{ + ServerName: "FELHOM", + UID: 1000, + Shares: []SambaShareRender{ + {Name: "dokumentumok", Path: "/mnt/felhom-drives/scratch1/shares/dokumentumok", ReadOnly: false}, + {Name: "filmek", Path: "/mnt/felhom-drives/media/filmek", ReadOnly: true}, + }, + } +} + +// Exact golden for smb.conf (§10): the hardened global block + one section per share, in registry +// order, with the force-user block. A drift in any managed directive fails here. +func TestRenderSambaConfig_Golden(t *testing.T) { + const want = `# Samba (LAN network-sharing) — managed by felhom-controller (R-7). +# WARNING: auto-generated. Manual edits are overwritten on the next share change. +[global] + workgroup = WORKGROUP + server string = Felhom hálózati megosztás + netbios name = FELHOM + security = user + map to guest = never + server min protocol = SMB2 + disable netbios = no + bind interfaces only = yes + interfaces = lo eth0 + smb ports = 445 + load printers = no + printing = bsd + printcap name = /dev/null + disable spoolss = yes + +[dokumentumok] + path = /mnt/felhom-drives/scratch1/shares/dokumentumok + read only = no + valid users = felhom + force user = felhom + force group = felhom + create mask = 0644 + directory mask = 0755 + +[filmek] + path = /mnt/felhom-drives/media/filmek + read only = yes + valid users = felhom + force user = felhom + force group = felhom + create mask = 0644 + directory mask = 0755 +` + got := RenderSambaConfig(sampleSambaData()) + if got != want { + t.Errorf("smb.conf golden mismatch.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestRenderSambaConfig_NoShares(t *testing.T) { + got := RenderSambaConfig(SambaData{ServerName: "OTTHON", UID: 1000}) + if !strings.Contains(got, "netbios name = OTTHON") { + t.Error("server name not rendered") + } + if strings.Count(got, "[") != 1 { // only [global] + t.Errorf("no shares should mean only the [global] section:\n%s", got) + } +} + +// Compose: host network + pinned image + config :ro + passdb volume, and — the Scenario B core — a +// read-only share gets a :ro bind while a writable one does not. Share binds are sorted (filmek path + Path string `json:"path"` // absolute host path + ReadOnly bool `json:"read_only,omitempty"` // smb.conf `read only = yes` + a :ro compose bind + Offsite bool `json:"offsite"` // [R4] true (default) → backup class mandatory; false → optional (tier-2 only) + CreatedAt string `json:"created_at"` // RFC3339 +} + +// nbNameRe matches a NetBIOS-safe name: 1–15 chars, letters/digits/hyphen/underscore, not starting +// or ending with a hyphen. Deliberately stricter than SMB share-name rules (slice 1 keeps the flat +// name and the share name in the same safe space; slice 2 may relax share names). +var nbNameRe = regexp.MustCompile(`^[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,13}[A-Za-z0-9_])?$`) + +// ValidateSMBServerName checks a proposed server (NetBIOS) name, returning a Hungarian error on defect. +func ValidateSMBServerName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("a kiszolgáló neve nem lehet üres") + } + if len(name) > 15 { + return fmt.Errorf("a kiszolgáló neve legfeljebb 15 karakter lehet") + } + if !nbNameRe.MatchString(name) { + return fmt.Errorf("a kiszolgáló neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat") + } + return nil +} + +// ValidateSMBShareName checks a proposed share name, returning a Hungarian error on defect. It rejects +// path traversal (slashes/backslashes/dots), whitespace, over-length, and any non-NetBIOS-safe char — +// so a name can never turn into a path segment or a second [section] header. +func ValidateSMBShareName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("a megosztás neve nem lehet üres") + } + if len(name) > 15 { + return fmt.Errorf("a megosztás neve legfeljebb 15 karakter lehet") + } + if strings.ContainsAny(name, `/\.` ) { + return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot") + } + 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") + } + return nil +} + +// EffectiveServerName returns the configured server name or the default when unset. +func (s *SMBSettings) EffectiveServerName() string { + if s == nil || strings.TrimSpace(s.ServerName) == "" { + return DefaultSMBServerName + } + return s.ServerName +} + +// ---- accessors (thread-safe, mirror the OffboxTarget getter/setter shape) -------------------------- + +// GetSMBSettings returns a copy of the SMB feature settings (never nil; a zero-value disabled struct +// with the default server name when unconfigured). +func (s *Settings) GetSMBSettings() SMBSettings { + s.mu.RLock() + defer s.mu.RUnlock() + if s.SMB == nil { + return SMBSettings{Enabled: false, ServerName: DefaultSMBServerName} + } + cp := *s.SMB + if strings.TrimSpace(cp.ServerName) == "" { + cp.ServerName = DefaultSMBServerName + } + return cp +} + +// SetSMBEnabled toggles the feature and saves. +func (s *Settings) SetSMBEnabled(on bool) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.SMB == nil { + s.SMB = &SMBSettings{ServerName: DefaultSMBServerName} + } + s.SMB.Enabled = on + return s.save() +} + +// SetSMBServerName updates the server (NetBIOS) name and saves. Caller validates. +func (s *Settings) SetSMBServerName(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.SMB == nil { + s.SMB = &SMBSettings{} + } + s.SMB.ServerName = strings.TrimSpace(name) + return s.save() +} + +// SetSMBUserSet records that the household SMB password has been set at least once. +func (s *Settings) SetSMBUserSet(set bool) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.SMB == nil { + s.SMB = &SMBSettings{ServerName: DefaultSMBServerName} + } + s.SMB.UserSet = set + return s.save() +} + +// GetSMBShares returns a copy of the share registry. +func (s *Settings) GetSMBShares() []SMBShare { + s.mu.RLock() + defer s.mu.RUnlock() + if len(s.SMBShares) == 0 { + return nil + } + out := make([]SMBShare, len(s.SMBShares)) + copy(out, s.SMBShares) + return out +} + +// AddSMBShare appends a share, refusing a case-insensitive name collision. Caller has already +// validated the name (ValidateSMBShareName) and the path (against the storage registry + deny-list). +func (s *Settings) AddSMBShare(share SMBShare) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, ex := range s.SMBShares { + if strings.EqualFold(ex.Name, share.Name) { + return fmt.Errorf("már létezik „%s” nevű megosztás", share.Name) + } + } + if share.CreatedAt == "" { + share.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + s.SMBShares = append(s.SMBShares, share) + if s.log != nil { + s.log.Printf("[INFO] [settings] Added SMB share: %s", share.Name) + } + return s.save() +} + +// RemoveSMBShare deletes a share by name (case-insensitive). Config-only: never touches the folder. +func (s *Settings) RemoveSMBShare(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + var kept []SMBShare + found := false + for _, ex := range s.SMBShares { + if strings.EqualFold(ex.Name, name) { + found = true + continue + } + kept = append(kept, ex) + } + if !found { + return fmt.Errorf("nincs „%s” nevű megosztás", name) + } + s.SMBShares = kept + if s.log != nil { + s.log.Printf("[INFO] [settings] Removed SMB share: %s", name) + } + return s.save() +} + +// SetSMBShareOffsite flips a share's „Felhőmentés” (offsite/mandatory) toggle [R4]. +func (s *Settings) SetSMBShareOffsite(name string, offsite bool) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.SMBShares { + if strings.EqualFold(s.SMBShares[i].Name, name) { + s.SMBShares[i].Offsite = offsite + return s.save() + } + } + return fmt.Errorf("nincs „%s” nevű megosztás", name) +} diff --git a/controller/internal/settings/smb_test.go b/controller/internal/settings/smb_test.go new file mode 100644 index 0000000..112fefa --- /dev/null +++ b/controller/internal/settings/smb_test.go @@ -0,0 +1,140 @@ +package settings + +import ( + "log" + "os" + "path/filepath" + "strings" + "testing" +) + +func newSMBTestSettings(t *testing.T) (*Settings, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + s, err := Load(path, log.New(os.Stderr, "", 0)) + if err != nil { + t.Fatalf("Load: %v", err) + } + return s, path +} + +func TestValidateSMBServerName(t *testing.T) { + good := []string{"FELHOM", "Otthon-1", "box_2", "A", "sixteencharnam"} // last = 14 chars + for _, n := range good { + if err := ValidateSMBServerName(n); err != nil { + t.Errorf("%q should be valid: %v", n, err) + } + } + bad := []string{"", " ", "sixteencharname1", "has space", "bad/slash", "-lead", "trail-", "dot.name", "back\\slash"} + for _, n := range bad { + if err := ValidateSMBServerName(n); err == nil { + t.Errorf("%q should be invalid", n) + } + } +} + +func TestValidateSMBShareName(t *testing.T) { + good := []string{"dokumentumok", "filmek", "csalad_kepek", "A1"} + for _, n := range good { + if err := ValidateSMBShareName(n); err != nil { + t.Errorf("%q should be valid: %v", n, err) + } + } + // Scenario C: "../x", >15-char, and invalid NetBIOS charset must all refuse. + bad := []string{"", "../x", "a/b", "a\\b", "file.txt", "way-too-long-nam", "has space", "-x", "x-"} + for _, n := range bad { + if err := ValidateSMBShareName(n); err == nil { + t.Errorf("%q should be invalid", n) + } + } +} + +func TestSMBShareCRUD(t *testing.T) { + s, path := newSMBTestSettings(t) + + sh := SMBShare{Name: "dokumentumok", Path: "/mnt/felhom-drives/scratch1/shares/dokumentumok", Offsite: true} + if err := s.AddSMBShare(sh); err != nil { + t.Fatalf("AddSMBShare: %v", err) + } + got := s.GetSMBShares() + if len(got) != 1 || got[0].Name != "dokumentumok" || !got[0].Offsite { + t.Fatalf("share not stored as expected: %+v", got) + } + if got[0].CreatedAt == "" { + t.Error("CreatedAt not stamped on add") + } + + // Case-insensitive collision is refused AND does not mutate the registry (Scenario C non-effect). + if err := s.AddSMBShare(SMBShare{Name: "DOKUMENTUMOK", Path: "/other"}); err == nil { + t.Error("case-insensitive name collision should be refused") + } + if len(s.GetSMBShares()) != 1 { + t.Error("refused collision must not mutate the registry") + } + + // Offsite (Felhőmentés) toggle persists. + if err := s.SetSMBShareOffsite("dokumentumok", false); err != nil { + t.Fatalf("SetSMBShareOffsite: %v", err) + } + if s.GetSMBShares()[0].Offsite { + t.Error("offsite toggle did not persist") + } + + // Persistence across reload. + s2, err := Load(path, log.New(os.Stderr, "", 0)) + if err != nil { + t.Fatalf("reload: %v", err) + } + if r := s2.GetSMBShares(); len(r) != 1 || r[0].Name != "dokumentumok" { + t.Errorf("share did not persist across reload: %+v", r) + } + + // Remove is config-only + errors on a missing name. + if err := s.RemoveSMBShare("dokumentumok"); err != nil { + t.Fatalf("RemoveSMBShare: %v", err) + } + if len(s.GetSMBShares()) != 0 { + t.Error("share not removed") + } + if err := s.RemoveSMBShare("nope"); err == nil { + t.Error("removing a missing share should error") + } +} + +func TestSMBSettingsDefaultsAndNoPassword(t *testing.T) { + s, path := newSMBTestSettings(t) + + // Unconfigured → disabled with the default server name (never nil). + def := s.GetSMBSettings() + if def.Enabled || def.ServerName != DefaultSMBServerName { + t.Errorf("default SMB settings wrong: %+v", def) + } + + if err := s.SetSMBEnabled(true); err != nil { + t.Fatal(err) + } + if err := s.SetSMBServerName("OTTHON"); err != nil { + t.Fatal(err) + } + if err := s.SetSMBUserSet(true); err != nil { + t.Fatal(err) + } + cur := s.GetSMBSettings() + if !cur.Enabled || cur.ServerName != "OTTHON" || !cur.UserSet { + t.Errorf("SMB settings not stored: %+v", cur) + } + + // The household SMB password must NEVER be persisted (rule 4). The struct has no field for it; + // prove the on-disk file carries only the UserSet flag, no plaintext secret. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "password") && strings.Contains(string(raw), "smb") { + // there is a claim_code_hash etc.; assert no *smb* password key specifically + if strings.Contains(string(raw), "smb_password") || strings.Contains(string(raw), "\"password\": \"") { + t.Errorf("settings.json appears to store an SMB password:\n%s", raw) + } + } +}