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
+8
View File
@@ -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.
+199
View File
@@ -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: 115 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)
}
+140
View File
@@ -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)
}
}
}