Files
felhom-controller/controller/internal/settings/smb.go
T
admin c81df55dcb feat(shares): R-7b Parts 1-2 — shares payload builder + tier-2 shares job (Model B')
Sibling shares source for the local cross-drive tier. Reuses the tier2Mirror seam,
selectTier2TargetFrom (narrow source-drive seam extracted from selectTier2Target),
tier2ReconcileRoots (pure extraction), tier2SafeRemove, the marker-LAST discipline
and the recordTier2* helpers. Per-app paths are untouched.

- shares_payload.go: deterministic _shares-manifest.json + best-effort passdb capture
- tier2_shares.go: per-source-drive legs -> cross-drive target, payload, marker LAST
- infra.SambaContainerName/SambaPassdbVolume/Mount: single source of truth for the
  container identity (renderer, stacks execs, backup execs, monitor all read it)
- RESERVED-NAME finding: ValidateSMBShareName did NOT exclude a leading underscore,
  so "_shares" was an accepted share name. Now refused; RunAllTier2 additionally
  skips a "_shares" stack loudly as defense in depth.
- fix: shareSourceDrive returned a slash-normalised path, which made the target
  selector's source-drive equality check miss (a group could target its own drive)
2026-07-18 12:45:57 +02:00

209 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
// RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares"
// — a restic tag, a tier-2 dest root and a status record. nbNameRe below starts with [A-Za-z0-9_],
// so before this guard „_shares" was an ACCEPTED share name and the underscore namespace was not in
// fact reserved (the R-7b task's assumption to the contrary was verified false here). Reserving the
// whole leading-underscore space keeps future system keys collision-free too. Validation runs on
// ADD only, so an already-registered share is never invalidated retroactively.
if strings.HasPrefix(name, "_") {
return fmt.Errorf("a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva")
}
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)
}