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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,14 @@ type Settings struct {
|
||||
// Offbox is the off-box (NAS) restic-SFTP backup target (Part B). One per box. No secrets here —
|
||||
// the repo password + SSH key are 0600 files in the data dir.
|
||||
Offbox *OffboxTarget `json:"offbox,omitempty"`
|
||||
|
||||
// SMB holds the LAN network-sharing (Samba) feature state (R-7 slice 1). The household SMB
|
||||
// password is NEVER stored here — only UserSet records that one exists (it lives in the samba
|
||||
// container's passdb volume). nil = feature never touched (disabled). See internal/settings/smb.go.
|
||||
SMB *SMBSettings `json:"smb,omitempty"`
|
||||
// SMBShares is the ordered registry of exported folders. Each Path is an absolute host path under
|
||||
// a registered storage root; the smb.conf + compose + backup classification all ride this list.
|
||||
SMBShares []SMBShare `json:"smb_shares,omitempty"`
|
||||
}
|
||||
|
||||
// AppEmail holds the global app-email toggle and an optional household display name.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LAN network-sharing (Samba) settings — R-7 slice 1. The household SMB password is NEVER persisted
|
||||
// here (it lives in the samba container's passdb volume); SMBSettings.UserSet only records that one
|
||||
// exists. Everything in this file is customer-modifiable state behind the „Megosztás” page.
|
||||
|
||||
// DefaultSMBServerName is the NetBIOS name shown in Windows Explorer's Network view when the customer
|
||||
// hasn't chosen one. Kept short + uppercase (NetBIOS is case-folded).
|
||||
const DefaultSMBServerName = "FELHOM"
|
||||
|
||||
// SMBSettings holds the network-sharing feature toggle + server identity.
|
||||
type SMBSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ServerName string `json:"server_name"` // NetBIOS name (≤15, NetBIOS-safe); "" ⇒ DefaultSMBServerName
|
||||
UserSet bool `json:"user_set,omitempty"` // the household SMB password has been set at least once
|
||||
}
|
||||
|
||||
// SMBShare is one exported folder. Path is an absolute host path under a registered storage root
|
||||
// (validated by the web layer against the storage registry + deny-list before it ever reaches here).
|
||||
type SMBShare struct {
|
||||
Name string `json:"name"` // share name (NetBIOS-safe, ≤15); the [section] in smb.conf and the \\SERVER\<name> 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user