Files
felhom-controller/controller/internal/stacks/samba.go
T
admin 7c05b59708
gates / gates (push) Successful in 24s
v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00

388 lines
15 KiB
Go

package stacks
import (
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"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 util.MsgError("err.stacks.a_beallitasok_nem_erhetok_el")
}
smb := m.settings.GetSMBSettings()
if !smb.Enabled {
return util.MsgError("err.stacks.a_halozati_megosztas_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 util.MsgError("err.stacks.a_megosztas_szolgaltatas_inditasa_sikertelen", 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 util.MsgError("err.stacks.a_megosztas_leallitasa_sikertelen", 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")
}