b5d78d1e0f
The systemic complaint, twice in one evening: you press a button and nothing happens. No progress, no ETA, no named result. Three worst offenders, fixed on the two patterns already here (deploy 3-step panel, storage-init status poll). No new framework — that is a ROADMAP item; three targeted cards ship tonight. 4a — a verification restore names its result. The flash said the app had been restored "to a verification folder on the drive"; which folder, on which drive, was invisible, so the customer could not go and look at what they had just asked for. Full path now. The restore page gained a listing of existing verification copies (app, size, date, path) — nothing anywhere showed these, so they piled up and the only way to find them was SSH — each with a double-confirmed delete. That delete is the only one this release adds, so it names a STACK, never a path: the Manager resolves the name inside a backups/offsite-restore root it computed itself and refuses anything landing outside. Red-proofed — neutralise the name guard and stack:"" resolves to the offsite-restore ROOT and takes every copy with it. Refusals are asserted as non-effects. 4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba synchronously inside the POST handler; on a golden without felhom-samba baked that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post followed by "Beállítás mentve." whether or not anything came up. Detached + polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE the work starts, since afterwards the image is always present. Success is probed, not inferred (compose up -d exits 0 on a crash-loop). The password form starts the same job: with UserSet false reconcile deploys nothing, so on a fresh box that is where the pull actually happens. 4c — "Távoli mentés most" streams real progress. restic was already reporting bytes and percent; the runner seam used CombinedOutput() and discarded them. The manual run now passes --json and scans stdout line-by-line: total bytes, percent, current app. Manual only — the nightly stays silent, pinned by a test that fails if it ever passes --json. The poll now arms unconditionally, closing a race the manual trigger always ran: the redirect rendered before the goroutine wrote LastStatus=running, so the poll never armed and the page sat static during the very run just started. Red-proofed twice. Also closes the golden/controller infra-image drift at the source: infra.Images() derives from the existing pins and --print-infra-images exposes it, so the golden bake can stop carrying its own copy. That copy had already drifted — felhom-samba was never added, so the golden baked 3 of 4, which is why enabling Megosztás pulled at runtime in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
320 lines
12 KiB
Go
320 lines
12 KiB
Go
package stacks
|
|
|
|
import (
|
|
"fmt"
|
|
"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() }
|