This is D5's precondition and it is now met.
An app's namespace root IS its backup root: namespaceRoot returns a non-system
drive path as-is, so the recovery unit lands at <HDD_PATH>/backups/primary/<stack>/.
On a NAS that sits inside the share, which FileBrowser binds WHOLE — share root,
:rslave, download:true.
The bind was NOT narrowed, and establishing why inverted the fix. The share-root
:rslave bind is load-bearing (a 2026-07-22 probe proved an in-container access
through it wakes the idle automount trigger), and scoping is undefinable anyway:
apps on a share store at <share>/<app>, there is no userdata/ layer, and creating
one would write Felhom convention onto a customer's own NAS, which R-67 forbids.
So the browsing surface cannot be narrowed and the backup tree must never be
placed under it. Operator ruling: refuse the placement, keep the browse bind.
Tier 2 already refuses network targets for this reason (F-6C-1).
Nothing stranded: zero apps on network storage across all six hub customers
including Peti. R-67's browse capability is byte-identical.
FIVE surfaces, not the four the register named — settings.RefuseAsAppNamespace is
the single predicate. The deploy POST is the real boundary (it accepts any
caller-supplied HDD_PATH; DeployStack validates only os.Stat). Surface 4,
handleStorageDecommission mode=migrate, guarded only its SOURCE, so a whole
namespace could be decommissioned ONTO a NAS — that one is not in the register.
Fails closed: /mnt/felhom-drives holds both kinds, Kind exists only on a
registered path, so an unregistered path under that root refuses.
Supersedes README's "NAS backup locality — decision A" (v0.118.0).
9 tests, all non-effect (nil stackMgr, so a guard that misses panics rather than
passing). 4 red-proofs, each mutation asserted to have landed.
Suite rc=0, 27 packages, 0 FAIL. vet rc=0. Template + emoji gates OK.
Registered as /api/backup-target inside ServeStorageAPI, which main.go mounts ONLY
at /api/storage/. Live result: endpoint not found, while every unit test passed --
the tests called the handlers directly and never travelled the mount. Caught by
the first live call, which is why the live call is part of the procedure.
Moved to /api/storage/backup-target[/assign]. A new test asserts the dispatcher
source contains both paths, so a handler nothing routes to fails the suite --
the seam-wiring rule applied to a route rather than a button.
The half that makes the rest work: a degraded backup target recorded only in
config is the silent-degradation pattern this arc has spent a week removing.
Part 3 -- POST /api/backup-target/assign moves the target via the agent's
POST /backup/target. It is the ONLY writer of the role: registration does not set
it, the drive-gate does not, no scheduler does. Declining is not calling it. The
agent returns restart_required rather than restarting itself, because restarting
with a backup in flight records a spurious tier failure for a backup that
actually succeeded (E-1 did exactly that).
Part 4 -- GET /api/backup-target returns the state and, when degraded, Hungarian
copy in FACT -> CONSEQUENCE -> REMEDY order, pinned by a test: a customer told
only the fact cannot act on it.
Healthy renders NOTHING -- no badge, no reassurance, no tonal change.
degradedMessageFor is the single decision point, so exactly one place could start
decorating a working box. Red-proofed: reassuring on the healthy branch fails
Scenario E.
UNKNOWN is not degraded: an unreachable or pre-R-82 agent means we could not ask,
which is not evidence of degradation (R-88 Part 2's class).
A HOLLOW TEST caught by its own red-proof: TestUnknownStateRendersNothing used
{Known:false} with Degraded left false, so it passed even with the !Known guard
deleted -- the second condition covered for it. Now {Known:false, Degraded:true},
which fails properly. Without the red-proof the test would have been decoration.
State is derived from the AGENT, never from our intent flag: on the two boxes
migrated by hand in E-1 the intent was never recorded while the drive really is
the target.
MinAgent: 0.113.0
Green gate: build + vet + test rc=0 (27 packages), run separately from this commit.
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
- F6: POST /api/storage/init runs format→mount→register as a DETACHED single-flight job (context.Background, netAddState shape) the wizard polls via GET /api/storage/init/status; 3-step progress; register-last marker-last crash-safety. Fixes the client-disconnect-aborts-mount bug. No agent change (chain reaches FileBrowser sync = controller-only). Red-proof TestStorageInit_DetachedSurvivesClientDisconnect.
- F7: storage_init/attach Vissza → /storage (was /settings). Test TestStorageWizardBackAnchors_PointToStorage.
runStorageInit/runStorageAttach resolved the fs UUID only via agent.Disks(),
which does NOT include a raw (unenrolled, non-PVE-storage) device — so a raw
candidate could be offered but never enrolled ("no fs identifier"). New
resolveEnrollUUID falls back to the raw-device scan (/disks/candidates), which
reports each free disk's durable_id (uuid:<fs-uuid>). Both enroll paths use it;
legacy re-attach (drive in /disks) still works. Test + red-proof.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Controller-side of NAS network storage, proxying to agent A1 /netstorage/*. Distinct
'network' storage kind (no drive lifecycle); add/list/remove + per-share health UI;
unreachable NAS is a recoverable warning, never the drive missing/stop cascade; SMB
creds pass through to the agent, never persisted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
In the intermediary-mount model an enrolled drive binds live into the running
guest (no reboot), so the "… meghajtó aktiválásra vár / Újraindítás most" banner
was an obsolete relic — also dead since v0.78 (pendingActivationDrives keyed on
the raw MountPath vs the now-stable sp.Path). Removed the banner block +
activatePendingDrives JS (settings.html), the PendingDrives feed (handlers.go),
and the dead pendingActivationDrives helper + its unused internal/system import.
Renamed handleStorageActivate -> HandleServerReboot (split out a testable
serverReboot core), removed the /api/storage/activate case, and mounted the
handler at the new non-storage route /api/server/reboot (RequireAuth+CsrfProtect).
The agent GuestReboot primitive is reused unchanged.
Added the standalone "Kiszolgáló újraindítása" settings card (sibling to the
controller-only "Vezérlő újraindítása"), reusing the pollRestart() loop.
Test: TestHandleServerReboot_CallsGuestReboot (fake diskAgent asserts GuestReboot
invoked once + 202). diskAgent/mockAgent gained GuestReboot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PsnU2ASocYrvzqE82YDYW
The storage card displayed each external drive's raw host PVE mount (/mnt/<name>,
which doesn't exist in the guest) instead of the stable in-guest path
(/mnt/felhom-drives/<name> = guest_path) the registry/HDD_PATH/FileBrowser use.
The eject/wipe buttons also posted the raw path, so they would unmount the drive
but leave the stable registry entry orphaned, and the impact warning found no apps.
Fix: card sub-line + eject/wipe buttons use the stable path (regKey); type-to-confirm
name uses the basename; register keeps the raw path. handleStorageWipe maps to raw
via agentWhere() for the agent eject (matching handleStorageEject). Agent ops
unchanged (same raw paths); display + registry bookkeeping corrected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PsnU2ASocYrvzqE82YDYW
handleStorageRegister (the "Regisztrálás" action for an already-mounted,
unregistered drive) registered the raw /mnt/<name> path verbatim, unlike
runStorageInit/runStorageAttach which map to the stable /mnt/felhom-drives/<name>
path the agent actually binds the drive at. The controller then watched an empty
placeholder dir on the guest rootfs → "Rendszermeghajtón" + stuck "activation
pending" banner after a re-provision.
Fix: register stablePathForName(path.Base(req.Where)); attachIntoGuest still uses
the raw path (the agent operates on raw). Test + red-proof added.
Diagnosis: felhom.eu/documentation/audits/DIAGNOSE-drive-bind-after-reprovision-2026-06-23.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PsnU2ASocYrvzqE82YDYW
Drives are visible in-guest only at the STABLE /mnt/felhom-drives/<name>; the
registered path + HDD_PATH + FileBrowser source repoint there while agent calls
map back to raw /mnt/<name> (agentWhere). Enroll binds-under-parent before
register. Drive-absent GATE (planDriveGates + 30s driveGateLoop) stops/blocks
apps when a drive vanishes and auto-restarts on return; start-gate refuses start
when the drive is absent. H1 endpoints (disconnect/reconnect/restart-apps) routed
onto host-side ops. Non-hollow tests + companions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agentapi.Decommission + handleStorageDecommission (migrate-all-or-none, Change 2):
migrate-then-decommission via the migration done-hook, or decommission-anyway (stop
apps, keep HDD_PATH). 'Hiányzó tárhely' badge on dashboard/stacks/app card when an
app's drive is decommissioned/disconnected/absent. Change 4: registerStoragePath
clears the decommissioned marker on re-enroll (ClearDecommissioned had no callers).
Non-hollow tests incl. mutation-proven Change-4 companion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ServeStorageAPI gains POST /api/storage/migrate (whole-namespace), POST
/api/storage/migrate-app (single app), GET /api/storage/migrate/status (poll).
settings.html: the greyed migrate-all span becomes a real target-select + button +
shared progress panel; app_info.html gains a per-app 'Áthelyezés másik tárhelyre'
control. Both poll the shared status endpoint and render Hungarian phase progress.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Backups page: whole-guest backup shown as real DR — target label "Biztonsági szerver –
külön hardver (PBS)"; app-data "Távoli mentés" card now reflects the PBS offsite tier
(guestBackupView.Offsite) instead of "nincs beállítva".
- Model-A double-nest fix: appbackup path helpers take a felhom-data NAMESPACE ROOT (no
internal felhom-data join); backup.Manager.namespaceRoot/AppNamespaceRoot resolve
HDD-vs-systemDataPath provenance so a drive-resident app's backups land single-nested
(<drive>/backups/... on the guest = <drive>/felhom-data/backups/... on the host) instead
of .../felhom-data/felhom-data/.... Writes, deletion (GetStackBackupData/RemoveStack/
ProtectedHDDPaths), wipe-warning scan, and export updated coherently; legacy double-nest
dirs kept protected. New appbackup test asserts no doubled segment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4A: user-data drives are backup-target-eligible (not role-locked) — surfaced in
the drive purpose note. 4B: handleStorageImpact returns backup_copies (apps whose
cross-drive backups live on the drive, via backupCopiesOnPath); the wipe/eject
modal warns they'd be destroyed (stays customer-confirmable — copies redundant).
Cross-drive backup engine remains out of scope. Test: TestBackupCopiesOnPath.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pendingActivationDrives() flags registered drives the agent shows attached but not
live-mounted in the container; settings banner + "Újraindítás most" button →
/api/storage/activate → agentapi.GuestReboot. Batches all pending into one restart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agentapi GuestAttach(where) → POST /disks/guest-attach; runStorageInit/Attach +
handleStorageRegister call attachIntoGuest after register (best-effort, P3 heals).
Closes Branch A: enrolled drives become usable in the guest, banner clears.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detect and offer to format empty (no filesystem) partitions on the system
disk. Adds IsSystemPartition() for granular per-partition safety checks
instead of blocking the entire system disk. Init wizard shows formatable
partitions with appropriate warnings. Add felhotest demo node to docs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add structured operational logging at INFO, WARN, and ERROR levels to
every controller module. Standardize custom prefixes ([GEO], [SCHED],
[SYNC]) to use [INFO/WARN/ERROR] [module] format. Fix misleveled logs
(WARN->ERROR for data loss scenarios, WARN->INFO for routine operations).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.
Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Concurrency fixes:
- Deep-copy stacks in GetStack/GetStacks to prevent shared state mutation (C04)
- Add per-state mutex to watchdog pathProbeState (C05)
- Guard MetricsCollector.Start() with sync.Once against double-start (C06)
- Hold diskJobMu across entire raw mount operation (C07)
- Add mutex to SetEncryptionKey (C08), MigrateEncryption write lock (H03)
- Use sync.Once for sync.Stop() channel close (H08)
- Set syncing=true before releasing lock in TriggerSync (H09)
- Deep-copy lastDBDump/lastBackup in GetFullStatus (H11)
- Add WaitGroup for stderr goroutine in MigrateDrive (H19)
- Add mutex to SetBackupRunningCheck (M18)
Security fixes:
- Validate Bearer token against Hub API key in CSRF middleware (H16)
- Validate backup paths start with expected prefix in RemoveStack (M12)
- Guard uuid[:8] slice with length check (H20)
- Parse fstab fields exactly for mount target matching (H21)
Bug fixes:
- Use decrypted env vars for compose deploy (C01)
- Log decrypt failures in DecryptMap instead of swallowing (C02)
- Move Deployed=false inside lock in runComposeDeploy (C03)
- Fix activeDrives() to skip disconnected drives (H02)
- Fix Snapshot() stderr extraction from exec.ExitError (H01)
- Check unlockCmd.Run() error in restic (H01)
- Buffer template rendering via bytes.Buffer (H07)
- Thread context.Context through cloudflare client (H10)
- Fix leaf-name collision detection in cross-drive backup (H15)
- Add nil check for crossDriveRunner (H17)
- Use strings.TrimSpace instead of slice on command output (H18)
- Make SaveAppConfig atomic with write-to-tmp+rename (H04)
- Pass encKey on deploy failure SaveAppConfig (H05)
- Fix IPv6 address format in TCP health probe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Passwords and secrets from deploy fields (type: password/secret) are now
encrypted at rest in app.yaml using a per-node 32-byte key. Values stored
as ENC:base64(nonce+ciphertext), decrypted transparently for docker-compose
and web UI. Key included in infra backup bundle for disaster recovery.
Existing plaintext values migrated automatically on startup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase 1: Deprecate restic as Tier 2 method (rsync only), auto-migrate on startup
Phase 2: Enhanced per-app migration with backup awareness, DB dump copy, auto-cleanup
Phase 3: Full drive migration with decommissioned state, rollback support, wizard UI
Phase 4: Hub report includes decommissioned drive state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After an interrupted attach wizard, the raw mount stays behind,
causing the device to appear as "mounted" in scan results. Now the
scan button calls cancel first, which unmounts any stale raw mounts
that have no bind mount in fstab.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New Settings wizard to attach drives with existing filesystems without
formatting. Mounts partition at staging path, lets user browse and pick
a subfolder, then bind-mounts it at /mnt/<name> with fstab entries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Detect stale data on non-active storage paths after migration; show on
deploy/settings page with size info and two-step delete confirmation
- Add POST /api/storage/stale-cleanup handler with safety checks (active
path protection, registered-path validation, ProtectedHDDPaths guard)
- Export ProtectedHDDPaths() from stacks package for reuse in web handlers
- Sync FileBrowser mounts after successful app data migration
- Deploy page title/h2 now shows "Beállítások" for already-deployed apps
instead of always showing "Telepítés"
- Also add delete-old-data button on migration-done card in migrate.html
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Bug 1 (sfdisk): Add wipefs before sfdisk; change partition type from
,,,L (unsupported GPT shorthand) to ,, (default Linux GUID); add
--force --wipe always flags to handle existing partition tables.
Bug 2 (mount): Replace fstab-lookup mount with explicit device path:
mount -t ext4 -o defaults,noatime /host-dev/sdb1 /mnt/hdd_1
Container's /etc/fstab is Docker's auto-generated one, not the host's.
Bug 3 (mount propagation): Change /mnt volume to long-form bind with
propagation: rshared so mounts created inside container propagate to
the host. Requires mount --make-rshared /mnt on host before restart.
Safety: Use req.MountName (ASCII) for ext4 -L label (16-byte limit;
UTF-8 display label stays in settings.json). Add findmnt verification
after mount. Improve progress messages with command details.
Smart partition: In storageInitAPIHandler, if disk already has exactly
1 empty partition (no filesystem), skip wipefs+sfdisk and format the
existing partition directly.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Startup ping: fire heartbeat + health + hub report immediately on boot
(5s delay after scheduler start, instead of waiting 5-15 min for first tick)
- Storage init wizard: new internal/storage/ package with disk scanning
(lsblk -J), format+mount pipeline (sfdisk → mkfs.ext4 → blkid → fstab →
mount → chown), safety guards (system disk detection, confirmation "FORMÁZÁS"),
progress channel, auto-register in settings.json
- Data migration: MigrateAppData() with rsync --info=progress2 progress parsing,
stop/rsync/update-config/start flow, rollback on failure, old data preserved
- New pages: /settings/storage/init (wizard), /stacks/{name}/migrate (migration)
- New API routes: /api/storage/{scan,init,init/status,migrate,migrate/status}
- Deploy page: storage info section for deployed apps (path, size, free, migrate link)
- Settings page: "Mozgatás" button per app in storage path details
- Container: privileged: true, /dev:/dev, /etc/fstab:/host-fstab, /run/udev:/run/udev:ro
- Dockerfile: add util-linux, e2fsprogs, rsync, parted for disk ops
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>