Files
felhom-controller/REUSE.md
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

51 KiB
Raw Blame History

REUSE.md — felhom-controller

Before writing new code, check here. Canonical helpers, patterns to copy, traps to avoid. Maintenance: update in the SAME commit that adds/changes/deprecates a shared helper. Entries cite file + symbol. Line numbers are landmarks only — reconfirm before editing.

1. Canonical helpers (MUST reuse — do not reinvent)

Paths & namespaces (felhom-data layout)

Symbol File Short signature Use for Gotchas
NamespaceRoot controller/internal/appbackup/paths.go (drivePath string, inGuestDrive bool) string Resolve felhom-data root for a drive inGuestDrive=true returns path AS-IS (Model A: guest mount IS the ns root); false appends felhom-data. Never double-nest
PrimaryBackupPath / RecoveryUnitPath / RecoveryUnitComposePath / RecoveryUnitManifestPath controller/internal/appbackup/paths.go (nsRoot[, stackName]) string All backup dir layout Take the NAMESPACE ROOT, not a bare drive path
AppDBDumpPath / AppVolumeDumpPath / AppDataDir controller/internal/appbackup/paths.go (nsRoot, stackName) string Per-app dump/data dirs Same nsRoot contract. AppDataDir's final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → paperless); resolve via AppDataDirNames first (F-S2/F-S3)
AppDataDirNames / AppDataBindsPresent controller/internal/appbackup/paths.go (hddPath, stackName string, hddMounts []string) []string / (hddPath, hddMounts) bool Resolve the real appdata/<name> dir(s) from compose ${HDD_PATH} binds (F-S2/F-S3) hddMounts = ParseComposeHDDMounts shape. Deduped+sorted; falls back to [stackName] when no appdata bind. Tier-2 (backup.Manager.tier2AppDataName) refuses N>1; migrate (stacks.Manager.ResolveAppDataDirNames) loops N. BindsPresent drives the WARN-on-missing-declared-dir
UserdataDir / EnsureUserdataSkeleton / EnsureDirOwned controller/internal/appbackup/userdata.go (nsRoot) / (path, gid int) userdata/ tree w/ 2775 setgid gid-1000 convention Linux-only chown via build-tag twin userdata_linux.go
HumanizeBytes controller/internal/appbackup/appdata.go (b int64) string Human byte sizes Exported canonical; private clones exist (§6)
stablePathForName / agentWhere controller/internal/web/intermediary.go (name/registeredPath) string Map registry stable path /mnt/felhom-drives/<n> ↔ raw agent mount Registry stores STABLE path; agent ops take the RAW mount — always convert
offsiteRestoreRootFor controller/internal/backup/offbox_verify_copies.go (drivePath string) string THE only place backups/offsite-restore is spelled offboxRestoreScratchDir builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0)
ProtectedHDDPaths controller/internal/stacks/delete.go (hddPath string) map[string]bool Never-delete set (root, appdata, backups, media, legacy felhom-data) Consult before ANY recursive delete under a drive

Subprocess + timeout + exit-code discipline

Symbol File Short signature Use for Gotchas
Manager.composeExec / composeExecCustomEnv controller/internal/stacks/manager.go (dir string, [env,] args...) (string, error) ALL docker-compose invocations Logs env KEYS only (secrets safety), truncates output to 500, extracts exit code; up triggers userdata pre-create belt. NO timeout — see §3
rsyncCopy controller/internal/stacks/migrate.go (ctx, src, dst, onBytes) Additive copy (migration/moves) -a --checksum, NEVER --delete; progress2 byte callback; ctx timeout
rsyncVerify controller/internal/stacks/migrate.go (ctx, src, dst) error Post-copy verification Dry-run -ani; fails on any pending content transfer; attr-only lines ignored
walkMerge controller/internal/stacks/migrate.go (lg, srcNS, dstNS, skip, assertOnly, onBytes) Collision-safe userdata merge Renames to lowest-free sibling on content mismatch; additive
runCommand / runCommandStdin controller/internal/selfupdate/updater.go (name, args...) (string, error) docker CLI in updater stdin variant for docker login --password-stdin (no secret in argv); package VARS since v0.112.0 — override in tests (fakeRunner in registry_anon_test.go)
parseWWWAuthenticate + fetchAnonymousToken controller/internal/selfupdate/updater.go Bearer-challenge parse + anonymous Docker v2 token Any credential-free registry API access realm comes FROM THE HEADER (never hardcode a token URL); denial = errAnonymousDenied, never "credentials missing"
Syncer.runGit / runGitInDir controller/internal/sync/sync.go (args...) error git CLI ops Credentials masked in logs via maskRepoURL

HTTP/JSON envelopes + flash messages

Symbol File Short signature Use for Gotchas
writeJSON controller/internal/api/router.go (w, status, v) REST API (/api/* router) responses Pair with apiResponse{OK,Data,Error} envelope
writeDiskJSON controller/internal/web/agent_disk_handlers.go (w, status, ok, errMsg, data) Storage/disk web-API responses The {ok,error,data} envelope the storage JS expects
jsonResponse / jsonError controller/internal/web/handler_export.go (w, v) / (w, msg, code) Export/import API Third envelope shape — keep within export surface
limitBody controller/internal/api/router.go (w, req) Bound request bodies (1MB) Apply before decode on any new POST
offboxRedirect controller/internal/web/offbox_handlers.go (w, r, msg string, isErr bool) Flash-message redirects Flash = ?flash= / ?flash_error= query params, read by page handlers
redirectTier2 controller/internal/web/tier2_config_handler.go (w, r, name, flash, flashErr) Tier2 page flash redirects Same convention
validStackName controller/internal/web/validate.go (name string) bool Any stack name from a request Single-segment, no / \ .. — blocks path traversal into stacks/userdata
ValidateSegment controller/internal/appexport/validate.go (kind, s string) error Any attacker-controlled path segment (.fab manifest fields) CTRL-001 guard; deliberately NOT for dotfile ConfigFiles
validateSubdomain / SubdomainInUse controller/internal/stacks/deploy.go (s) / (subdomain, excludeStack) Subdomain fields on deploy

Crash-safe journal / atomic writes

Symbol File Short signature Use for Gotchas
atomicWrite controller/internal/backup/recovery_unit.go (path, data, perm) error Atomic file writes (backup pkg) tmp+rename; no dir creation, no fallback
writeFileAtomic controller/internal/bootstrap/bootstrap.go (path, b) error controller.yaml writes from bootstrap Always 0600 (holds local-api token + hub key)
writeConfig0600 controller/internal/api/router.go (path, body) error config writes via API ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!)
atomicWriteFile controller/internal/setup/handlers.go (path, data, perm) error setup-wizard writes Same bind-mount fallback caveat
Settings.save (unexported) controller/internal/settings/settings.go via mutator methods only ALL settings.json persistence tmp+rename, then .bak last-known-good AFTER rename succeeds. Never write settings.json by hand
settings.Load controller/internal/settings/settings.go (path, logger) (*Settings, error) Startup load Corruption recovery: .bak restore → else preserve .corrupt-<ts> + safe defaults; never crash-loops
Manager.writeJournal / loadJournal controller/internal/stacks/migrate.go (j *MigrationJob) Migration crash journal Enables RecoverMigration at startup
backup.SharesPseudoStack / DisplayStackName controller/internal/backup/shares_payload.go "_shares" / (key) string THE reserved key for the shares source (restic tag, backups/secondary/_shares, CrossDriveBackup record) + its display mapping NEVER let the raw key reach a Hungarian surface — map at the notification/prose boundary ONLY; the persisted EnlargedBlocked set and the templates index by the RAW key
Manager.buildSharesPayload / classifiedShares controller/internal/backup/shares_payload.go () (dir, passdbOK, error) / () []classifiedShare the definitions+credential payload and the availability-filtered share set both tiers read payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. classifiedShares is the single place a dead mount is dropped, so both jobs agree
Manager.selectTier2TargetFrom controller/internal/backup/tier2.go (stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error) tier-2 target choice with the source drive supplied EXPLICITLY the seam the shares job reuses — NEVER fork the headroom math; selectTier2Target is now a thin wrapper over it
Manager.tier2ReconcileRoots controller/internal/backup/tier2.go (destBase, roots, legRels) staleness pruning with explicit dest roots pure extraction from tier2Reconcile (which now calls it with hdd/userdata); reuse it rather than writing a second pruner
Manager.liveShareRootOK / scratchJoin controller/internal/backup/shares_restore.go (dst) bool / (scratch, abs) string THE place guard for shares restore + scratch path reconstruction a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse .. and the drive root itself. scratchJoin strips the volume name — plain filepath.Join splices a drive letter mid-path
infra.SambaContainerName / SambaPassdbVolume / SambaPassdbMount controller/internal/infra/samba.go consts single source of truth for the samba container identity the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (felhom-samba) is NOT the stack name (samba) — EffectiveProtected needs the container one
sambaWriteAtomic controller/internal/stacks/samba.go (path, data, mode) error samba smb.conf/compose writes tmp+fsync+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6
Loop.writeMarker / Recover controller/internal/quiesce/quiesce.go (m Marker) / () Quiesce crash-safety Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot

Compose ops / stack lifecycle

Symbol File Short signature Use for Gotchas
Manager.DeployStack controller/internal/stacks/deploy.go (req DeployRequest) (string, error) Full deploy flow Sets in-memory Deployed BEFORE compose up (slow-pull race), reverts on failure
Manager.RedeployFromEnv controller/internal/stacks/deploy.go (name, env map[string]string) error Re-up with changed env (migration flip, config edits) compose up -d, never restart (restart won't pick up images/env)
Manager.StartStack/StopStack/RestartStack/UpdateStack controller/internal/stacks/manager.go (name string) error Lifecycle Protected stacks refuse stop; all funnel through composeExec
Manager.DeleteStack / RemoveStack controller/internal/stacks/delete.go (name, removeHDDData[, backupPaths]) THE guarded removal paths Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll
resolveContainerState / aggregateState controller/internal/stacks/manager.go (dockerState, dockerStatus) / ([]ContainerInfo) State classification .State says "running" even when unhealthy — .Status parse is the fix
Manager.logPostStartStatus controller/internal/stacks/manager.go (name, stackDir, env) Async post-start verification compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks
Manager.EnsureBaseStack controller/internal/stacks/infra.go () error Traefik/cloudflared/FileBrowser infra convergence Renders from internal/infra templates
appbackup.ClassifyBinds / ValidateBackupSpec controller/internal/appbackup/classify.go (spec, binds) ([]ClassifiedBind, bool) / (spec, binds) error Backup-classification (Task 2, referential coupling) — pure Two-level default: explicit wins over :ro; unlisted writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet
ParseComposeClassifiableBinds controller/internal/stacks/classify_binds.go (composePath) []appbackup.ComposeBind ${VAR}-relative binds + :ro for classification Do NOT use ParseComposeHDDMounts/ExportDataMounts as classifier input (§traps) — they resolve absolutes, drop :ro, or union the userdata ROOT. Short-syntax only
Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds controller/internal/stacks/metadata.go, appbackup/appdata.go (name) ([]appbackup.ClassifiedBind, bool) Per-stack classification through the REAL LoadMetadata validate path The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy)
backup.Manager.DumpAppVolumesSafe controller/internal/backup/backup.go (stackName) error Volume tar of a live app Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check GetDockerVolumes()!=0 + IsProtectedStack BEFORE calling — it stops the stack before its own volume check (see runVolumeDumps)
backup.Manager.ListRestorePoints controller/internal/backup/restore_points.go (stackName) ([]RestorePoint, bool) Restorable keep-side backups (the /api/backup/snapshots payload) ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore)
backup.Manager.RestoreTier2Files controller/internal/backup/tier2_restore.go (stackName) (filesRestored int, err error) In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (POST /backup/tier2/restore) Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded DestinationPath, never re-selected
Manager.acquireRunning/releaseRunning, acquireMigrating controller/internal/backup/backup.go, controller/internal/stacks/migrate.go () error Single-flight for long ops Copy this mutex-flag pattern for any new long-running manager op

Secrets hygiene

Symbol File Short signature Use for Gotchas
crypto.Encrypt/Decrypt/IsEncrypted/DecryptMap controller/internal/crypto/crypto.go AES-256-GCM, ENC: prefix app.yaml sensitive values Decrypt errors on non-ENC input — use DecryptMap for whole env maps (passes through + warns)
crypto.LoadOrCreateKey controller/internal/crypto/crypto.go (path) ([]byte, error) The 32-byte key file (0600)
SaveAppConfig / LoadAppConfigDecrypted controller/internal/stacks/deploy.go (stackDir, cfg, encKey, sensitiveVars) app.yaml persistence Encrypts only SensitiveEnvVars(meta); never write app.yaml directly
generateValue / randomAlphanumeric controller/internal/stacks/deploy.go (spec "password:N|hex:N|base64key:N|static:v") Auto-generated secrets crypto/rand-backed; reuse the spec grammar
Manager.GenerateSecretForField controller/internal/stacks/deploy.go (stackName, envVar) (string, bool) Replacement value for a RESETTABLE secret from its catalog generate spec (O4 restore path via backup.SetSecretGenerator) REFUSES data_key fields, spec-less and non-secret fields; never log the value
reconcileRestoreSecrets controller/internal/backup/restore_unit.go (nonSecretEnv, recoveredSecrets, secretNames, dataKeyNames) Recovery-unit restore env merge Units are secret-FREE by design; secrets come from live app.yaml
EncryptFile / DecryptFile / IsEncryptedFAB controller/internal/appexport/crypto.go password-based file crypto .fab export bundles scrypt-derived AES+HMAC keys
maskRepoURL controller/internal/sync/sync.go (url) string Logging git URLs Strips embedded credentials
metrics.RedactLine controller/internal/metrics/redact.go (s string) string ANY log line shipped off-box (issue context, log tails) Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative

Storage registry + mount detection

Symbol File Short signature Use for Gotchas
Settings.AddStoragePath/RemoveStoragePath/RepointStoragePath controller/internal/settings/settings.go registry CRUD ALL drive registration AddStoragePath dedupes (double-register is clean no-op); AutoDiscoverStoragePaths never re-adds a known-in-any-state path
Settings.SetDisconnected/ClearDisconnected/SetDecommissioned controller/internal/settings/settings.go state flags + stopped-stacks memo Drive lifecycle state Records stoppedStacks so reconnect restarts exactly those
registerStoragePath controller/internal/web/storage_handlers.go (where, label, setDefault) error Post-enroll registration The single funnel used by init/attach/manual-add
system.IsMountPoint / IsWritable / PathsOverlap controller/internal/system/mounts_linux.go (path) bool Mount checks _other.go stubs return permissive values — Linux behavior is the real one
system.CheckBackupDestination controller/internal/system/mounts_linux.go (path) DestinationHealth Tier2/offbox target vetting Detects same-physical-device (SamePhysicalDevice)
system.ProbeStoragePath controller/internal/system/mounts_linux.go (path) ProbeResult Disconnect detection
appexport.DiskFree controller/internal/appexport/estimate.go (path) int64 Free bytes for space gates (df-based, 0 on any error) Exported v0.128.0 for the browser-upload gate; test seam = web.uploadDiskFree package var
stacks.ExportDataMounts controller/internal/stacks/delete.go (composePath, hddPath) []string THE .fab-export mount discovery (v0.130.0 C6B-F1) Unions ${HDD_PATH} binds + the ${USERDATA_PATH} ROOT (single userdata entry — basename must round-trip the import's <HDD_PATH>/<subdir> mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side stackAdapter deliberately does NOT use it
Server.deployedAppsOnPath controller/internal/web/netstorage_handlers.go (base) []string Deployed stacks whose HDD_PATH is base or a subpath The C6B-F2 share-removal guard; nil-safe on stackMgr
planDriveGates / Server.ReconcileDriveGates controller/internal/web/intermediary.go pure plan + executor Drive appear/disappear reactions planDriveGates is PURE (unit-testable); loop at driveGateLoop
Server.runStorageInit / runStorageAttach controller/internal/web/storage_handlers.go wizard pipelines New-drive enroll / re-attach Format goes through the agent's two-step confirm (below)

| Server.sharingResolvePath / sharingResolveStorageRoot | controller/internal/web/sharing_handlers.go | (raw) (string, error) | THE guard for every customer-supplied SMB share path | resolvePath validates a share TARGET (refuses the drive root); resolveStorageRoot validates the new-folder PARENT (accepts exactly a registered live root). Refusals are UNIFORM (no filesystem oracle). Never add a second deny-list — stacks.SharingDeniedRoots derives from ProtectedHDDPaths |

Agent local-API client (cross-repo edge)

Symbol File Short signature Use for Gotchas
agentapi.New controller/internal/agentapi/client.go (endpoint, token, fingerprintHex) (*Client, error) Pinned-TLS client to felhom-agent Leaf-DER SHA-256 pin replaces chain verify; fails closed. Bounded idle pool (leak fix)
Server.agentClient controller/internal/web/agent_disk_handlers.go () (*agentapi.Client, error) THE memoized client accessor Always use this, never a fresh agentapi.New per request (§3)
Server.ProbeAgentChannel controller/internal/web/agent_disk_handlers.go (ctx) (constructionErr bool, err error) Channel health probe Probes via the PRODUCTION client on purpose (self-heals, mirrors UI)
Client.FormatDisk controller/internal/agentapi/client.go (ctx, device, fstype, confirmed, durableID) ONLY format/wipe entry Sentinels: ErrNeedsConfirmation (user-data, resubmit confirmed+durableID) / ErrFormatRefused (system/backup — operator opsign only). Agent re-checks role server-side
Client.EjectDisk / Decommission / AssignDisk / GuestAttach / ListCandidates controller/internal/agentapi/client.go disk lifecycle Delegate ALL disk ops to agent Controller holds no Proxmox creds — never shell out to disk tools in-guest
Client.AddNetStorage/ListNetStorage/RemoveNetStorage controller/internal/agentapi/client.go NAS mounts (A1) Network storage Password passes through to agent's 0600 cred file; controller NEVER persists it
agentapi.StatusError controller/internal/agentapi/client.go {Path, Code} typed non-2xx GET error Distinguishing HTTP statuses from transport errors (errors.As) NEVER string-match agent error text — the capability probe keys on Code==404
SupportCache.Supports / Client.Supports controller/internal/agentapi/features.go (ctx, prober, Feature) SupportState Agent-capability gate for COUPLED features (route probe, TTL 5m) 404 ⇒ No; transport/5xx ⇒ Unknown (NEVER refuse on Unknown). New coupled feature = new featureProbes row + gate call at the entry point + MinAgent: in the CHANGELOG header (publish-train-rules.md). Web layer: Server.netFeatures through the netAgent seam

Notifications / hub sync

Symbol File Short signature Use for Gotchas
Notifier.PushEvent controller/internal/notify/notifier.go (eventType, severity, message, details) Hub events Async goroutine, 3 attempts/3s backoff. NEW event types MUST be added to hub allowedEventTypes or POST /event 400s; hub only emails warning/error from this path
Notifier.Notify* convenience methods controller/internal/notify/notifier.go typed wrappers (backup/DB/storage/channel/DR…) Standard events Add a typed wrapper rather than raw PushEvent calls
report.BuildReport / Pusher.Push controller/internal/report/builder.go + pusher.go periodic hub report Box→hub reporting ACK carries config_versionConfigRefresher.Reconcile
report.Trigger (NewTrigger/Fire/Run) controller/internal/report/trigger.go Fire() after a hub-relevant user action THE out-of-cycle report push (v0.139.0) — fire via api.Router.reportPushNow / web.Server.reportTriggerNow, both nil-safe Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the internal/sync REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit
report.SetPendingLogTails + buildLogTailsSection controller/internal/report/logtail.go ACK log_tail_requests → next report log_tails THE pull-based ACK-flag pattern (hub asks, controller pushes next cycle) — copy for any new hub→box request Consume-once drain at BuildReport; failed push re-arms from the hub's still-pending request; NEVER add a hub→controller push channel
metrics.FetchContainerLogTail controller/internal/metrics/logscanner.go (name, tailLines) (string, error) Raw per-container docker logs --tail=N 15s timeout; caller caps/redacts (capTailLines)
ConfigRefresher.Reconcile controller/internal/report/config_refresh.go (ackVersion int) Pull-based config refresh Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart
bootstrap.MaybeIngest / RefreshConfig controller/internal/bootstrap/bootstrap.go bootstrap.json → controller.yaml Day-0 + refresh Overwrites controller.yaml, NEVER settings.json
api.GracefulSelfRestart controller/internal/api/selfrestart.go (logger) Controller self-restart Detached exit; bootstrap unit re-runs the image
Settings.AddPendingEvent/DrainPendingEvents controller/internal/settings/settings.go offline event queue Events while hub unreachable

Scheduler / time / UI

Symbol File Short signature Use for Gotchas
Scheduler.Every / Daily controller/internal/scheduler/scheduler.go (name, interval/"HH:MM", fn) ALL background jobs Daily is Europe/Budapest, DST-safe (nextDailyRun avoids Add(24h)); register in main.go block (§5)
getBudapestLocation controller/internal/scheduler/scheduler.go () *time.Location Local-time math web has its own getTimezone (§6)
Server.templateFuncMap controller/internal/web/funcmap.go template.FuncMap ALL template functions stateColor outputs v2 suffixes run/progress/warn/neutral/off; stopped = NEUTRAL not red (operator-approved); stateLabel copy is frozen byte-identical (unit-tested)
timeAgoStr controller/internal/web/funcmap.go (s RFC3339 string) string Ago-format for STRING timestamps Exists because timeAgo(time.Time) 500'd on strings (v0.93 bug)
Server.baseData / executeTemplate controller/internal/web/handlers.go + server.go page-data plumbing New pages baseData injects nav/alerts/version; templates must pass controller/scripts/template_id_gate.py + controller/scripts/emoji_gate.py
Server.RequireAuth / CsrfProtect / csrfField controller/internal/web/auth.go + csrf.go middleware Any new authed route/form csrfField emits the hidden input; setup wizard has its OWN csrf (§6)
LogBuffer + Lines(maxBytes) controller/internal/web/logbuffer.go ring buffer io.Writer In-memory log capture for the debug UI + the report controller_log_tail source v0.116.0: ALWAYS constructed (any logging.level) — the logger is MultiWriter(LevelFilterWriter(stdout, level), ring); Lines drops OLDEST to honor the byte budget
logx.Debugf/Infof/Warnf/Errorf controller/internal/logx/logx.go (l *log.Logger, format, args…) ALL NEW leveled log lines (the v0.116.0 sweep standard) routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3)
web.LevelFilterWriter controller/internal/web/levelfilter.go NewLevelFilterWriter(w, minLevel) stdout leveling under the always-on ring untagged lines parse INFO; always reports full length written
monitor.RunHealthCheck / EffectiveProtected controller/internal/monitor/healthcheck.go system health report Health + protected-container list
util.TruncateStr controller/internal/util/strings.go (s, maxLen) string Rune-safe truncation The intended shared helper; stacks still uses its byte-based twin (§6)

2. Canonical patterns (copy structure from THE named file)

Pattern Canonical file Key traits
Agent-proxy web handler controller/internal/web/agent_disk_handlers.go memoized s.agentClient() → typed client call → writeDiskJSON envelope, Hungarian error strings, 502/503 mapping
Two-step confirmed destructive op controller/internal/web/storage_handlers.go handleStorageWipe server-side type-to-confirm + probe(unconfirmed) → sentinel error → resubmit bound to agent durable-id; agent re-checks role regardless
Crash-safe long job (journal + recover) controller/internal/stacks/migrate.go state machine + writeJournal per transition + RecoverMigration at startup + single-flight acquire/release + done-hook
Quiesce/marker loop controller/internal/quiesce/quiesce.go marker BEFORE side effects, guaranteed undo (defer + max bound), Recover() once at startup, TriggerNow 409 single-flight
Settings mutator controller/internal/settings/settings.go (any Set*/Add*) Lock → mutate → s.save(); getters return copies; never expose internal slices
Channel-health checker w/ born-down alerting controller/internal/channelhealth/checker.go classify → debounce N≥2 → alerted flag re-armed on reason change (F2)
Platform split controller/internal/system/mounts_linux.go + mounts_other.go _linux.go/_other.go twins; other = permissive no-op stubs for dev on Windows
Debounced trigger + status (REFUSE-style — a too-soon fire is refused/lost) controller/internal/sync/sync.go TriggerSync 30s debounce, Status() snapshot struct, post-sync hook fan-out
Coalescing trigger (trailing edge — a burst collapses but the LAST state always fires) controller/internal/report/trigger.go buffered-1 chan + non-blocking Fire() + single worker (quiet window → drain → min-interval → fire once); shape from hub wgsync/reconciler.go
Detached job + status poll (single-flight, phase strings) controller/internal/web/storage_init_job.go acquire/release/set/deep-copied snapshot; phases mapped to Hungarian in the template; 13 s poll; terminal state PROBED, not inferred. Clones: netstorage_job.go, samba_ensure_job.go (v0.147.0). Five of these now exist and agree on nothing — R-45 will unify them; prefer extending an existing one over a sixth
Streaming subprocess progress controller/internal/backup/offbox_progress.go offboxStreamRunner seam (stdout scanned line-by-line, stderr buffered, output tail-bounded) + a PURE line parser + a mutex-guarded published snapshot. Traps it encodes: a source reporting nothing is normal (restic sends 0 bytes for a whole incremental run) and the progress source may only update on unit completion — degrade bytes → files → current item + elapsed, never fake a percentage
Post-start async verification controller/internal/stacks/manager.go logPostStartStatus goroutine + sleep, INFO log, never blocks/fails the operation
Startup wiring order controller/cmd/controller/main.go init-only setters (SetStackProvider M2 contract: exactly once, before scheduler/HTTP), scheduler registration block

3. Dangerous lookalikes — do NOT reuse

Trap Why it bites Use instead
rsyncMirror (controller/internal/backup/tier2.go) rsync -a --delete — DESTROYS anything extra at dst; correct only for tier-2 mirror dirs (backup DIRECTION). In the tier2→live restore direction it would erase every live file created since the last copy rsyncCopy + rsyncVerify (controller/internal/stacks/migrate.go) for any move/copy; rsyncRestoreMissing (controller/internal/backup/tier2_restore.go, -a --ignore-existing) for the additive-only restore direction
raw os.RemoveAll on drive/HDD paths Bypasses the protected-set; wipes appdata/backups/media Manager.DeleteStack/RemoveStack (controller/internal/stacks/delete.go) — gated by ProtectedHDDPaths + orphan/protected/running checks
fresh agentapi.New per request Idle-conn leak → EADDRNOTAVAIL, port exhaustion (live incident, fixed ctrl v0.74.0) Server.agentClient() memoized accessor
timeAgo on an RFC3339 string field Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) timeAgoStr
backup.Manager.DumpAppVolumes on a running DB app Inconsistent tar of live DB volume DumpAppVolumesSafe (stop → dump → restart, both errors surfaced)
stacks.Manager.execCommand / composeExecCustomEnv for NEW long-running calls No context/timeout — a hung docker CLI blocks forever exec.CommandContext + explicit timeout (copy rsyncCopy or appexport composeExecEnv)
config.LoadPermissive Skips validation — setup-mode only (customer.id/domain may be unset) config.Load everywhere else
ExportDataMounts / ParseComposeHDDMounts as backup-classification input ExportDataMounts unions the ${USERDATA_PATH} ROOT (export-capture logic, not per-bind); ParseComposeHDDMounts resolves absolutes AND drops the :ro flag — classification needs ${VAR}-relative paths + read-only awareness ParseComposeClassifiableBinds (controller/internal/stacks/classify_binds.go)
docker compose restart (any wrapper) Does not pick up new images or env RedeployFromEnv / composeExec up -d

4. Seams & interfaces (testing + cross-repo)

Interface Defined in Implemented by Fakes/tests at
diskAgent controller/internal/web/storage_handlers.go *agentapi.Client mockAgent in controller/internal/web/storage_handlers_test.go
netAgent + Server.netAgentFn/netProbeFn/netListFn controller/internal/web/netstorage_job.go (+ server.go fields) *agentapi.Client / runNetProbe (linux re-exec) / agent.ListNetStorage fakeNetAgent + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests
Server.agentLogsFn (func seam) controller/internal/web/server.go nil → agentClient().DebugLogs (agent GET /debug/logs) injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path)
escrowAgent + Server.escrowAgentFn/escrowStageFn/escrowStaleFn controller/internal/web/escrow_handlers.go (+ server.go fields) *agentapi.Client / PushOffboxPasswordForEscrow / report.EscrowAutoConfirmer.StaleBlob (SetEscrowStale) fakeEscrowAgent + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated
offboxCeremonyWaitState + escrowCeremonyGraceWindow controller/internal/web/handlers.go pure pick: (awaiting, timedOut) from OffboxTarget.{EscrowState,CeremonyCompletedAt} — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via >=)
Manager.sambaUpFn / sambaPasswdFn / sambaRunFn / sambaAddrFn (func seams) controller/internal/stacks/manager.go (fields) + samba.go nil → composeUp / docker exec smbpasswd (STDIN) / containerRunning("felhom-samba") / docker exec felhom-samba ip -4 -o addr show eth0 injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called zero times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. sambaRunFn has an EXPORTED setter (SetSambaRunProbe) — internal/web's status-contract tests need a live-container world from another package. sambaAddrFn backs SambaLANAddress() (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address
Manager.SambaLANAddress() controller/internal/stacks/samba.go () string — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) Read from the SAMBA container's netns (network_mode: host), never net.InterfaceAddrs() — the controller is on a docker BRIDGE and would answer 172.x (the same trap setup.DetectLocalIPs needs HOST_IP for). NEVER cache/persist it — the guest holds it by DHCP (S-5); callers re-derive per render. "" = omit the line
Server.sambaAddrFn (func seam) controller/internal/web/server.go (field) + sharing_handlers.go sambaLANAddress() nil → stackMgr.SambaLANAddress() The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease
sambaEnsureState.consumeIfRunning() controller/internal/web/samba_ensure_job.go serve-once snapshot() for terminal running only /sharing/status carries a job EDGE (phase) and a service LEVEL (running) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers phase=="running" with location.reload(), so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). failed/needs_password/in-flight are NOT consumed
infra.SambaHostInterface controller/internal/infra/samba.go the guest LAN nic name (eth0) Single source for smb.conf's interfaces =, the container's FELHOM_IFACE, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart
Manager.sambaImgFn (func seam) controller/internal/stacks/manager.go (field) + samba.go nil → docker image inspect <infra.SambaImage> drives the 4b card's pulling-vs-starting decision, which MUST be taken before compose up (afterwards the image is always present)
Manager.offboxStreamRunner + SetOffboxStreamRunner controller/internal/backup/offbox_progress.go nil → defaultOffboxStreamRunner (real restic, stdout scanned live) streaming sibling of offboxRunner; fakes emit canned --json status lines in offbox_progress_test.go, so the whole progress path runs with no restic, network or repo
Manager.offsitePreDumpFn + SetOffsitePreDumpFn (R-44, v0.148.0) controller/internal/backup/offbox_reconstitute.go (seam) + offbox.go (call site) nil → runDBDumpsInternal under the SAME running flag THE dumps-before-capture ordering seam. Extracted so the order is observable without Docker/restic — an ordering guarantee no test can see is one refactor from silently reverting to the DIAG-immich-restore-2026-07-19 behaviour. Red-proof: moving the capture first yields [capture dump]
Manager.offboxFullPlaceCopier + SetOffboxFullPlaceCopier (R-43) controller/internal/backup/offbox_reconstitute.go nil → rsyncRestoreOverwrite (-a --itemize-changes; no --ignore-existing, no --delete) TRAP: do NOT reuse offboxPlaceCopier here. The two copiers have OPPOSITE semantics for an existing file — --ignore-existing is exactly what a full restore must not do, and conflating them is how a missing-only merge came to be labelled a restore. Never rsyncMirror (--delete) in any restore direction
Manager.safetyDumpFn + SetSafetyDumpFn (R-43) controller/internal/backup/offbox_reconstitute.go nil → DumpOne the pre-restore undo. Invariant: the pre-restore--prefixed dump must be verified ON DISK before anything is stopped/overwritten/replayed; failure ⇒ refuse with zero changes. Red-proof requires removing BOTH guards (the err != nil return and the os.Stat) — removing one leaves the other holding
reimportDBDumpsFrom(ctx, stack, dumpDir) controller/internal/backup/restore_db.go explicit-dir sibling of reimportDBDumps (which passes AppDBDumpPath) offsite reconstitution replays from the SCRATCH unit: the live unit is deliberately never overwritten, so replaying from it would replay the current DB over itself and restore nothing
Manager.OffsiteScratchPair / OffsitePairInfo controller/internal/backup/offbox_reconstitute.go reads the restored scratch unit's manifest (offsite_run_id / dumps_at) + the R-44 sniff the confirm-dialog honesty surface. All warn-level: a pre-v0.148 (unstamped) pair and an empty-looking dump are SURFACED, never blocked — a false positive that refused a legitimate restore would be worse than the skew
appbackup.DumpValidation.LooksEmpty (R-44 sniff) controller/internal/appbackup/dbdump.go computed in ValidateDump's existing single pass; userTableNames is EXACT-match size and table count are both useless as emptiness heuristics (the 2026-07-19 dump: 52MB, 60+ tables, zero users — all geodata). TRAP: never widen to a substring match on "user" — it would flag user_metadata / album_user / user_audit on every healthy single-user box. A row wider than the read buffer still counts as a row
report.SetPendingControllerLog / SetControllerLogSource controller/internal/report/selftail.go ACK-armed consume-once self-log pull (the logtail.go shape) selftail_test.go; source = logBuffer.Lines, wired once in main.go
util.ParseVersion / util.Version.Compare controller/internal/util/version.go THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81)
agentapi.AgentVersionReporter + featureMinAgent controller/internal/agentapi/features.go version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: SupportsWithSource also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line
netProbeReadBack (package var) controller/internal/web/netprobe.go os.ReadFile overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production
system.ClassifyPathFS(Timeout) + netProbeFSClass / Server.classifyFSPath / Router.classifyFSPath controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go
quiesce.Backend / quiesce.Stacks controller/internal/quiesce/quiesce.go adapter over *agentapi.Client / *stacks.Manager fakeBackend/fakeStacks in controller/internal/quiesce/quiesce_test.go
channelhealth.Probe (func) + Sink controller/internal/channelhealth/checker.go Server.ProbeAgentChannel / notifier adapter fakeSink in controller/internal/channelhealth/checker_test.go
appbackup.StackDataProvider controller/internal/appbackup/appdata.go *stacks.Manager (via backup.SetStackProvider) fakeRecoveryProvider in controller/internal/backup/recovery_unit_test.go
appexport.ExportStackProvider controller/internal/appexport/provider.go *stacks.Manager exercised in appexport tests
selfupdate.AgentSwapper controller/internal/selfupdate/updater.go *agentapi.Client (SwapController/SwapStatus) fakeAgent in controller/internal/selfupdate/updater_test.go
mailrelay.Forwarder controller/internal/mailrelay/forward.go HubForwarder (hub relay endpoint) fakeForwarder in controller/internal/mailrelay/mailrelay_test.go
integrations.Handler + StackProvider controller/internal/integrations/integrations.go + manager.go OnlyOffice handlers table-driven tests in package
bootstrap.PullFunc controller/internal/bootstrap/bootstrap.go report.PullConfig injected in bootstrap tests
offboxRunner (func) controller/internal/backup/offbox.go defaultOffboxRunner (restic exec) SetOffboxRunner injection point
dumpVolumesSafe (func seam) controller/internal/backup/backup.go nil → real DumpAppVolumesSafe injected in controller/internal/backup/volume_dumps_test.go (gating tests without Docker)
generateSecret (func seam) controller/internal/backup/backup.go stacks.Manager.GenerateSecretForField via SetSecretGenerator (main.go) injected in controller/internal/backup/restore_secrets_gen_test.go
restoreFilesCopier (func seam) controller/internal/backup/backup.go nil → real rsyncRestoreMissing injected in controller/internal/backup/tier2_restore_test.go (orchestration without rsync)
tier2Mirror (func seam) controller/internal/backup/backup.go nil → real rsyncMirror both RunTier2 rsync legs; injected in controller/internal/backup/tier2_appdata_test.go (resolve→mirror without rsync)
migSeams.resolveNames (func seam) controller/internal/stacks/migrate.go nil → real ResolveAppDataDirNames (compose-derived) injected in controller/internal/stacks/migrate_fs3_test.go (F-S3 appdata dir-name resolution)

Cross-repo edges:

  • controller/internal/agentapi/client.gofelhom-agent local API (/storage, /disks*, /backup*, /netstorage*, /guest/*): pinned leaf SHA-256 + per-guest bearer token from bootstrap.json.
  • controller/internal/report/pusher.gohub /api/v1/report ingest; ACK config_version drives config_refresh.go; controller/internal/notify/notifier.go → hub /api/v1/event (hub-side allowedEventTypes allowlist must include new types).
  • controller/internal/sync/sync.goapp-catalog-felhom.eu: copies ONLY docker-compose.yml + .felhom.yml per app (SHA-256 change detection); NEVER overwrites app.yaml (deployed secrets).

5. Extension points (where new features plug in)

  • New storage web endpoint: switch in ServeStorageAPI (controller/internal/web/storage_handlers.go); disk ops in ServeDiskAPI (controller/internal/web/agent_disk_handlers.go); backup in ServeBackupAPI; export in ServeExportAPI; debug in handleDebugAPI (debug-mode gated).
  • New REST endpoint: path dispatch in Router.ServeHTTP (controller/internal/api/router.go); use writeJSON + limitBody.
  • New background job: sched.Every/sched.Daily registration block in controller/cmd/controller/main.go.
  • New template function: Server.templateFuncMap (controller/internal/web/funcmap.go) — obey v2 state-suffix vocabulary.
  • New page/nav item: baseData + sidebar in controller/internal/web/templates/ (nested sub-links pattern .nav-links-nested); must pass controller/scripts/template_id_gate.py + controller/scripts/emoji_gate.py + controller/scripts/native_confirm_gate.py + controller/scripts/offbox_rename_gate.py + controller/scripts/app_row_dedup_gate.py + controller/scripts/mojibake_gate.py.
  • Docker volume tar streaming (v0.125.0): appexport.dockerExec (seam, package var) + withVolumeHelper/exportVolumeTar/importVolumeTar — stream volume content via docker cp through a stopped helper container. NEVER docker run -v <controller-local path> — the daemon resolves -v host-side and strands the data when the controller is containerized (the v0.124.0 HIGH finding); controller/scripts/docker_run_volume_path_gate.py enforces (every "-v" allowlisted with its WHY).
  • Guarded file download (v0.124.0): handler_export_download.go — the canonical shape for streaming a server-side file to the browser: accept a BASENAME only (shape regexp + no separators/..), filepath.Join then assert filepath.Dir(path) == dir, io.Copy (never ReadAll), Content-Disposition: attachment, remove after a successful stream, TTL sweep (sweepFabDownloads(dir, now, maxAge, logger) — now injected for tests). Red-proof the guard by loosening to prefix-matching (the .. case must fail).
  • Backups sub-page data: backupsCommonData(page, title, r) + backupsOffboxData(data) (handlers.go) — the ONLY builders for the four /backups* pages; a new backups section extends these, never re-derives in a page handler. (The one-shot v0.124.0 move gate backups_split_move_check.py was retired in v0.126.0.)
  • App-list row (v0.126.0): app_list_row/app_list_row_end in templates/app_row.html is THE canonical list pattern — icon+name(+secondary) left, caller action block right; open with dict "Slug" ... "Name" ... (optional Secondary/RowClass/Href/FallbackIcon), close with app_list_row_end. Do NOT hand-roll app rows — scripts/app_row_dedup_gate.py enforces single-sourcing (the backups_apps expander header is the one allowlisted aligned copy). Infra display identity: inframeta.go map + infraMeta func (filebrowser is the only Linked stack).
  • Consequential-action confirm (LIGHT): felhomConfirm(el, question, onYes) in layout.html (v0.123.0) — the trigger swaps in place to "kérdés + Igen/Mégse"; form buttons opt in with data-confirm="…" (delegated listener, requestSubmit keeps formaction/name-value). NEVER native confirm()/prompt() (OS-modals freeze browser automation — drill F-11; native_confirm_gate.py enforces). Heavy destructive flows keep the .confirm-overlay openDialog pattern.
  • New hub event: typed Notify* wrapper on Notifier + hub allowlist entry (cross-repo).
  • New app integration: integrations.Manager.RegisterHandler with IntegrationKey(provider, target).
  • New startup self-check: append check fn in selftest.Run (controller/internal/selftest/selftest.go).
  • New settings field: struct + accessor pair in controller/internal/settings/settings.go following the Lock→mutate→save pattern.

6. Known duplication (observed — NOT fixed)

Duplication Locations
Atomic write ×4 (+inline in settings.save) controller/internal/backup/recovery_unit.go atomicWrite; controller/internal/bootstrap/bootstrap.go writeFileAtomic; controller/internal/setup/handlers.go atomicWriteFile; controller/internal/api/router.go writeConfig0600
String truncation ×3 controller/internal/util/strings.go TruncateStr (rune-safe, 1 caller); controller/internal/stacks/manager.go truncateStr (byte-based, widely used); controller/internal/backup/offbox.go truncate
humanizeBytes ×3 controller/internal/appbackup/appdata.go HumanizeBytes (canonical) + private twin; controller/internal/appexport/estimate.go humanizeBytes; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge)
copyFile ×2 controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go
dir-size ×6 controller/internal/stacks/delete.go getDirSizeBytes/getDirSizeHuman; controller/internal/backup/tier2.go dirSizeBytes (du -sb); controller/internal/appexport/estimate.go dirSize+duBytes; controller/internal/appexport/export.go calcDirSize; controller/internal/web/handlers.go dirSizeHuman
timeAgo switch body ×2 controller/internal/web/funcmap.go timeAgo vs timeAgoStr (identical formatting logic)
CSRF ×2 controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled
Budapest timezone loader ×2 controller/internal/scheduler/scheduler.go getBudapestLocation vs controller/internal/web/funcmap.go getTimezone
JSON writers ×5, 3 envelope shapes api writeJSON; web writeDiskJSON, jsonResponse/jsonError, writeDebugJSON
Safe-name validators ×4 controller/internal/web/validate.go validStackName; controller/internal/api/router.go validStackParam (same body — api↔web import cycle); controller/internal/backup/offbox.go isSafeStackName; controller/internal/appexport/validate.go ValidateSegment (strictest)
DB wait/import ×2 controller/internal/appbackup/dbdump.go waitDBReady/ImportDump vs controller/internal/appexport/restore.go waitForDB/importDBDump
compose exec ×2 controller/internal/stacks/manager.go composeExecCustomEnv vs controller/internal/appexport/restore.go composeExecEnv (the latter has ctx+timeout; the former has the userdata belt)