c81df55dcb
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)
209 lines
7.5 KiB
Go
209 lines
7.5 KiB
Go
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")
|
||
}
|
||
// 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)
|
||
}
|