feat(samba): settings registry + smb.conf/compose renderers (R-7 slice 1, Part 1)

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.
This commit is contained in:
2026-07-18 11:21:05 +02:00
parent f42f3e0e08
commit b0c5ef4823
6 changed files with 568 additions and 0 deletions
+3
View File
@@ -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
+115
View File
@@ -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())
}
+103
View File
@@ -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<scratch1).
func TestRenderSambaCompose(t *testing.T) {
got := RenderSambaCompose(sampleSambaData())
for _, want := range []string{
"network_mode: host",
"image: gitea.dooplex.hu/admin/felhom-samba:1.0.0",
"- ./smb.conf:/etc/samba/smb.conf:ro",
"- samba-passdb:/var/lib/samba",
"FELHOM_SERVER_NAME=FELHOM",
"FELHOM_UID=1000",
} {
if !strings.Contains(got, want) {
t.Errorf("compose missing %q\n%s", want, got)
}
}
// Read-only share → :ro bind (red-proof target: drop the :ro in the renderer and this fails).
roBind := "- /mnt/felhom-drives/media/filmek:/mnt/felhom-drives/media/filmek:ro"
if !strings.Contains(got, roBind) {
t.Errorf("read-only share must get a :ro bind, missing %q\n%s", roBind, got)
}
// Writable share → plain bind, NOT :ro.
rwBind := "- /mnt/felhom-drives/scratch1/shares/dokumentumok:/mnt/felhom-drives/scratch1/shares/dokumentumok\n"
if !strings.Contains(got, rwBind) {
t.Errorf("writable share must get a plain bind, missing %q\n%s", rwBind, got)
}
if strings.Contains(got, "/mnt/felhom-drives/scratch1/shares/dokumentumok:/mnt/felhom-drives/scratch1/shares/dokumentumok:ro") {
t.Error("writable share must NOT be :ro")
}
}