Files
felhom-controller/controller/internal/stacks/samba.go
T
admin badf17bebd v0.151.0 — the Megosztás page stops reloading, and says how to connect
S-1: /sharing/status coerced idle->running on the PHASE channel, so the first
poll of every steady-state page load reported a terminal job that never ran and
the client's repaint-reload fired ~1.2s apart, forever. The coercion's real duty
(liveness must never be contradicted) belongs to the 'running' LEVEL field
beside it, and is now pinned by its own regression test.

S-4 core: a terminal 'running' is served exactly once, so a REAL bring-up cannot
re-arm the reload on the page it just caused. failed/needs_password/in-flight
are never consumed. Unified async-job feedback stays the ROADMAP item.

S-2/S-5: new connect card with the Windows form, the Mac form and the direct
smb://<IP>, read from the SAMBA container's netns (the controller is on a docker
bridge and would answer 172.x). Derived per render, cached nowhere - the address
is a DHCP lease. Underivable => the line is omitted.

sharing.html's <script> block is byte-identical to v0.150.0. Red-proofed three
ways. 23/23 packages green.
2026-07-20 10:46:21 +02:00

387 lines
14 KiB
Go

package stacks
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Samba (LAN network-sharing) lifecycle — R-7 slice 1. The FOURTH protected infra stack, deployed
// and reconciled entirely by the controller (never a catalog app: it needs host networking, its
// config is a generated share list, and its roots ride the backup classification).
//
// Destructive-write boundary: NOTHING here deletes or moves customer files. Disabling the feature or
// deleting a share is a CONFIG-only operation — the folder and its contents always survive.
const (
// SambaStackName is the stack directory name under StacksDir (and the protected-stack key).
SambaStackName = "samba"
// sambaContainer is the fixed container name. Aliased from the RENDERER's constant so the compose
// this package writes and the execs it runs can never name different containers.
sambaContainer = infra.SambaContainerName
// sambaUID is the household uid/gid every SMB write is forced to, so apps (group 1000) and both
// backup tiers see consistent ownership.
sambaUID = 1000
)
// SharingDeniedRoots returns the SYSTEM subset of ProtectedHDDPaths(root) whose SUBTREES may never
// be exported over SMB: appdata/ (live app databases — writable SMB access to them is the [R3]
// corruption foot-gun), backups/, and the legacy felhom-data nest.
//
// The drive root itself is deliberately NOT in this set: it is denied by an EXACT-match check at the
// call site ("a whole drive is not shareable"). Putting it here would make every path under the drive
// — i.e. every legitimate share — match the subtree rule and be refused.
//
// It is DERIVED from ProtectedHDDPaths, never a parallel list: each candidate is emitted only if that
// guard already contains it, so this set can only ever SHRINK relative to the delete guard — it can
// never drift into a stale second source of truth. media/ and Dokumentumok/ are protected THERE as
// delete targets but are customer data and stay shareable (Scenario B shares media/filmek).
func SharingDeniedRoots(root string) []string {
if root == "" {
return nil
}
protected := ProtectedHDDPaths(root)
candidates := []string{
filepath.Join(root, "appdata"),
filepath.Join(root, "backups"),
filepath.Join(root, felhomDataDir),
filepath.Join(root, felhomDataDir, "appdata"),
filepath.Join(root, felhomDataDir, "backups"),
}
var out []string
for _, c := range candidates {
if protected[c] {
out = append(out, c)
}
}
return out
}
// sambaDir is the samba stack directory.
func (m *Manager) sambaDir() string {
return filepath.Join(m.cfg.Paths.StacksDir, SambaStackName)
}
// sambaUp runs `docker compose up -d` for the samba stack through an injectable seam (tests count
// calls to assert the idempotent no-op performs none).
func (m *Manager) sambaUp(dir string) error {
if m.sambaUpFn != nil {
return m.sambaUpFn(dir)
}
return m.composeUp(dir)
}
// sambaIsRunning probes the samba container's liveness through an injectable seam.
func (m *Manager) sambaIsRunning() bool {
if m.sambaRunFn != nil {
return m.sambaRunFn()
}
return containerRunning(sambaContainer)
}
// SambaImagePresent reports whether the pinned samba image is already in local Docker storage.
//
// This is what makes the progress card HONEST rather than decorative: on a golden that baked the
// image (see felhom-agent build-golden.sh) the bring-up is seconds and the card should say
// „elindítás"; on a box that must fetch ~100MB from the registry it is minutes and the card must say
// „képfájl letöltése" so the wait is explained instead of silent. Asked BEFORE compose runs, because
// afterwards the answer is always yes.
func (m *Manager) SambaImagePresent() bool {
if m.sambaImgFn != nil {
return m.sambaImgFn()
}
return exec.Command("docker", "image", "inspect", infra.SambaImage).Run() == nil
}
// shareAvailable reports whether a share's folder can be exported right now: its owning registered
// storage path must be neither disconnected nor decommissioned, AND the folder must exist. A dead
// mount is NEVER exported — publishing a missing mountpoint would show an empty share and let a
// write land on the underlying root directory instead of the drive.
func (m *Manager) shareAvailable(path string) bool {
if m.settings != nil {
// Separator-agnostic containment: production paths are POSIX, but the check must not silently
// no-op on a non-POSIX separator (which would export a share on a drive marked away).
p := filepath.ToSlash(path)
for _, sp := range m.settings.GetStoragePaths() {
root := filepath.ToSlash(sp.Path)
if p == root || strings.HasPrefix(p, root+"/") {
if sp.Disconnected || sp.Decommissioned {
return false
}
break
}
}
}
st, err := os.Stat(path)
return err == nil && st.IsDir()
}
// sambaRenderData builds the renderer input from the shares registry, dropping unavailable shares
// (the config for them is retained; only the export is withheld).
func (m *Manager) sambaRenderData(smb settings.SMBSettings) infra.SambaData {
d := infra.SambaData{ServerName: smb.EffectiveServerName(), UID: sambaUID}
if m.settings == nil {
return d
}
for _, sh := range m.settings.GetSMBShares() {
if !m.shareAvailable(sh.Path) {
m.logger.Printf("[WARN] [samba] share omitted from smb.conf — folder unavailable: name=%s", sh.Name)
continue
}
d.Shares = append(d.Shares, infra.SambaShareRender{Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly})
}
return d
}
// ensureSamba is the boot / health-tick entry, called from EnsureBaseStack (which already holds
// infraMu — so this must NOT re-acquire it).
func (m *Manager) ensureSamba(dir string) error {
return m.reconcileSambaAt(dir)
}
// ReconcileSamba re-renders and applies the samba stack. Called after EVERY share/settings mutation
// from the web layer. Takes infraMu (the mutation path is outside EnsureBaseStack).
func (m *Manager) ReconcileSamba() error {
m.infraMu.Lock()
defer m.infraMu.Unlock()
return m.reconcileSambaAt(m.sambaDir())
}
// reconcileSambaAt is the single lifecycle core. Idempotent: when the rendered config is unchanged
// AND the container is running it performs NO compose call at all.
func (m *Manager) reconcileSambaAt(dir string) error {
if m.settings == nil {
return nil
}
smb := m.settings.GetSMBSettings()
if !smb.Enabled {
return nil // disable is an explicit action (DisableSamba), not a silent down here
}
if !smb.UserSet {
// Edge case: sharing on but no household password yet → the stack stays undeployed and the
// UI blocks with „először adj meg jelszót". (SetSMBPassword brings it up as part of the
// password-set action, which is the only way UserSet becomes true.)
m.logger.Printf("[INFO] [samba] deploy skipped — household SMB password not set yet")
return nil
}
data := m.sambaRenderData(smb)
changed, err := m.writeSambaFiles(dir, data)
if err != nil {
return err
}
if !changed && m.sambaIsRunning() {
return nil // nothing to do — no compose call
}
m.logger.Printf("[INFO] [samba] applying samba stack: shares=%d config_changed=%v", len(data.Shares), changed)
return m.sambaUp(dir)
}
// writeSambaFiles renders and atomically writes smb.conf + docker-compose.yml, reporting whether
// either actually changed on disk (the idempotency signal).
func (m *Manager) writeSambaFiles(dir string, d infra.SambaData) (bool, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return false, fmt.Errorf("mkdir %s: %w", dir, err)
}
files := []struct{ name, content string }{
{"smb.conf", infra.RenderSambaConfig(d)},
{"docker-compose.yml", infra.RenderSambaCompose(d)},
}
changed := false
for _, f := range files {
p := filepath.Join(dir, f.name)
if cur, err := os.ReadFile(p); err == nil && string(cur) == f.content {
continue
}
if err := sambaWriteAtomic(p, []byte(f.content), 0o644); err != nil {
return changed, fmt.Errorf("write %s: %w", f.name, err)
}
changed = true
}
return changed, nil
}
// sambaWriteAtomic writes via tmp + fsync + rename so a crash mid-write can never leave smbd with a
// truncated config (crash-safety per the lifecycle contract).
func sambaWriteAtomic(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Chmod(tmp, mode); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// SetSMBPassword applies the household SMB password.
//
// SECRET HANDLING (rule 4): the password exists ONLY as this argument and on smbpasswd's stdin. It
// is never logged (not even at DEBUG), never written to settings.json, and never included in an
// error string — settings records ONLY the boolean UserSet.
func (m *Manager) SetSMBPassword(password string) error {
m.infraMu.Lock()
defer m.infraMu.Unlock()
if m.settings == nil {
return fmt.Errorf("a beállítások nem érhetők el")
}
smb := m.settings.GetSMBSettings()
if !smb.Enabled {
return fmt.Errorf("a hálózati megosztás nincs bekapcsolva")
}
dir := m.sambaDir()
data := m.sambaRenderData(smb)
if _, err := m.writeSambaFiles(dir, data); err != nil {
return err
}
// The container must be running to accept smbpasswd. Bringing it up before a password exists is
// safe: `security = user` + `map to guest = never` means nothing is reachable until this lands.
if !m.sambaIsRunning() {
if err := m.sambaUp(dir); err != nil {
return fmt.Errorf("a megosztás szolgáltatás indítása sikertelen: %w", err)
}
}
if err := m.sambaSetPassword(password); err != nil {
return err
}
if err := m.settings.SetSMBUserSet(true); err != nil {
return err
}
m.logger.Printf("[INFO] [samba] household SMB password applied for user=%s", infra.SambaHouseholdUser)
return nil
}
// sambaSetPassword runs smbpasswd inside the container with the password on STDIN (never argv —
// argv is world-readable via /proc). Injectable seam so unit tests never touch docker.
func (m *Manager) sambaSetPassword(password string) error {
if m.sambaPasswdFn != nil {
return m.sambaPasswdFn(password)
}
var lastErr error
// The container may need a moment to accept exec right after `compose up -d`.
for attempt := 1; attempt <= 10; attempt++ {
cmd := exec.Command("docker", "exec", "-i", sambaContainer,
"smbpasswd", "-s", "-a", infra.SambaHouseholdUser)
cmd.Stdin = strings.NewReader(password + "\n" + password + "\n")
out, err := cmd.CombinedOutput()
if err == nil {
return nil
}
// smbpasswd's output carries status text only — never the password — but truncate anyway.
lastErr = fmt.Errorf("smbpasswd: %s: %w", truncateStr(strings.TrimSpace(string(out)), 200), err)
time.Sleep(time.Second)
}
return lastErr
}
// DisableSamba stops the stack. The passdb volume AND every shared folder are KEPT — disabling
// network sharing never destroys data (Scenario E).
func (m *Manager) DisableSamba() error {
m.infraMu.Lock()
defer m.infraMu.Unlock()
dir := m.sambaDir()
if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err != nil {
return nil // never deployed — nothing to stop
}
if _, err := m.composeExec(dir, "down"); err != nil {
return fmt.Errorf("a megosztás leállítása sikertelen: %w", err)
}
m.logger.Printf("[INFO] [samba] stack stopped (passdb volume and all shared folders kept)")
return nil
}
// SambaRunning reports whether the samba container is currently up (UI status line).
func (m *Manager) SambaRunning() bool { return m.sambaIsRunning() }
// SambaLANAddress returns the guest's LAN IPv4 as the SMB service itself sees it, or "" when it
// cannot be determined. Never an error to the caller: this feeds one optional hint line on the
// Megosztás page (S-2), and a page that renders without it is strictly better than a page that
// fails.
//
// WHY THE SAMBA CONTAINER AND NOT net.InterfaceAddrs(): the controller runs on a docker BRIDGE, so
// its own addresses are 172.x — the classic wrong answer that already burned the setup wizard
// (setup.DetectLocalIPs needs a HOST_IP env var for exactly this reason). felhom-samba is
// `network_mode: host` inside the guest, so a docker-exec there reads the guest's real netns. That
// also makes the answer the right KIND of true: it is the address smbd is bound to, not merely an
// address the box happens to own.
//
// NEVER CACHED, NEVER PERSISTED (S-5): the guest holds this address by DHCP (`pct config 9201` →
// `ip=dhcp`), so a value stored anywhere is a value that goes stale and starts misdirecting
// customers. Callers re-derive per render.
func (m *Manager) SambaLANAddress() string {
addr, err := m.sambaLANAddr()
if err != nil {
// Debug, not warn: the overwhelmingly common cause is "sharing is off, so the container is
// not there", which is not a fault worth an operator's attention.
m.logger.Printf("[DEBUG] [samba] LAN address unavailable: %v", err)
return ""
}
return addr
}
func (m *Manager) sambaLANAddr() (string, error) {
if m.sambaAddrFn != nil {
return m.sambaAddrFn()
}
out, err := exec.Command("docker", "exec", sambaContainer,
"ip", "-4", "-o", "addr", "show", infra.SambaHostInterface).Output()
if err != nil {
return "", fmt.Errorf("docker exec ip addr: %w", err)
}
return parseIPv4FromIPAddrOutput(string(out))
}
// parseIPv4FromIPAddrOutput pulls the address out of `ip -4 -o addr show <iface>`, whose one-line
// form is:
//
// 2: eth0 inet 192.168.0.104/24 brd 192.168.0.255 scope global dynamic eth0\ valid_lft ...
//
// Pure and separately tested — the parsing is the only part that can silently produce a plausible
// wrong string, and a wrong address on this page is worse than no address at all.
func parseIPv4FromIPAddrOutput(out string) (string, error) {
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
for i, f := range fields {
if f != "inet" || i+1 >= len(fields) {
continue
}
addr := fields[i+1]
if slash := strings.IndexByte(addr, '/'); slash >= 0 {
addr = addr[:slash]
}
ip := net.ParseIP(addr)
if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsUnspecified() || !ip.IsGlobalUnicast() {
continue
}
return ip.String(), nil
}
}
return "", fmt.Errorf("no global-unicast IPv4 in ip-addr output")
}