Capture on the box disproved the first theory: macOS DOES send a correct NBNS query for <NAME><20> and nmbd DOES answer it correctly in 140us (flags 0x8580, RCODE=0, right address) - macOS just never acts on it. NetBIOS there feeds legacy browsing, not smb:// URL resolution, so the bare name can never work on a Mac and nmbd was never the broken part. felhom-samba 1.1.0 adds avahi + dbus, with avahi-daemon.conf and the _smb._tcp service file templated from FELHOM_SERVER_NAME so a rename re-advertises. Both daemons are non-fatal on failure - a discovery gap must not become an outage. v0.151.0's card offered smb://<NAME> for Mac, which is exactly the dead form; now smb://<NAME>.local. Windows keeps the flat \\<NAME>, which nmbd serves correctly. Red-proofed both directions. NOT claimed: Finder-sidebar discovery - published and answering on the wire, but not observed working on the test Mac. Recorded OPEN. TestRenderSambaCompose pinned the literal 1.0.0 tag, so an image bump read as a renderer regression; now derives from SambaImage and asserts non-:latest.
543 KiB
Changelog
v0.152.0 — Megosztás on a Mac: mDNS in the image, and the page stops giving Mac users a dead form (2026-07-20)
Closes S-3 of felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md, and fixes a copy
defect v0.151.0 shipped the same day. Pairs with felhom-samba 1.1.0 — the pin in
infra.SambaImage moves with it, so Images() and the golden bake follow automatically.
The finding that redirected the fix — macOS asks, gets a correct answer, and ignores it. The
first theory was that modern macOS no longer does NetBIOS. A packet capture on the box disproved
that: on a bare smb://FELHOM the Mac broadcasts a well-formed NBNS query for FELHOM<20> (the
File Server Service suffix — exactly right for SMB), and nmbd answers in 140 microseconds with a
textbook positive response — flags 0x8580 (response, authoritative, RCODE=0), ANCOUNT 1, unique
B-node, the correct address. macOS never opens a TCP connection. Sixteen seconds later the same
Mac connected through smb://FELHOM.local on the first try. NetBIOS on macOS feeds legacy browsing,
not smb:// URL resolution — so no change on our side can ever make the bare name work there, and
nmbd is not the thing that was broken. (nmbd answers twice per broadcast, because it holds
0.0.0.0:137, <ip>:137 and <bcast>:137 and a broadcast lands on two of them. Standard Samba;
investigated and dismissed — a duplicated correct answer is still a correct answer.)
felhom-samba 1.1.0 — avahi + dbus, so the Mac has a mechanism at all. The image's discovery set
was Windows-only: nmbd for flat-name resolution, wsdd for Explorer's Network view, and nothing
whatsoever for Bonjour. It now runs avahi, with avahi-daemon.conf and an _smb._tcp service file
templated from FELHOM_SERVER_NAME in the entrypoint — renaming the server in the UI
re-advertises under the new name, where a baked name would leave the box answering to something the
customer can no longer see anywhere. A static service file rather than smbd's own multicast dns register: it needs no line in smb.conf (bind-mounted READ-ONLY, owned by the controller's
renderer) and it lets us publish _device-info._tcp for a sensible Finder icon. Both new daemons
are non-fatal on failure — sharing over an address must not become an outage because a discovery
daemon did not come up. Proven live from the operator's Mac before the image was built, then the
built image smoke-tested with all five daemons up and avahi registered as <NAME>.local.
The page no longer tells Mac users to do the one thing that cannot work. v0.151.0's connect card
offered smb://<NÉV> for Mac. That is precisely the dead form. It is now smb://<NÉV>.local; the
Windows line stays the flat \\<NÉV>, which nmbd serves correctly and which this release must not
disturb. Red-proofed: reverting the template to the bare name turns
TestSharingConnectCard_MacLineIsDotLocalNotBareName red on both the missing .local and the
present bare form, for two different configured names — and the same test asserts the Windows line
neither disappears nor wrongly gains .local.
NOT claimed: automatic Finder-sidebar discovery. The _smb._tcp record is published and answers
browse queries on the wire, but the test Mac's sidebar stayed empty — it had no Network/Bonjour
section shown at all, which is a Finder Settings toggle rather than something the box controls. This
is recorded as OPEN in the DIAG, deliberately not as a shipped feature.
TestRenderSambaCompose asserted the literal tag felhom-samba:1.0.0, so a routine image bump read
as a renderer regression. It now derives from SambaImage and separately asserts what actually
matters — that the tag is explicit and never :latest.
v0.151.0 — the Megosztás page stops reloading, and says how to connect (2026-07-20)
Closes S-1, S-2, S-5 and the core of S-4 from
felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md.
S-1 — /sharing reload-looped about once a second, for every customer with sharing enabled.
GET /sharing/status carries two things that mean different things to the client: phase (the
ensure JOB — the page answers a terminal running with a one-shot location.reload(), because the
„Állapot" badge is server-rendered) and running (the service LEVEL, straight from the liveness
probe). v0.147.0 coerced idle→running on the PHASE channel so that a missing job could never
contradict a live container. That duty was real, but it belongs to — and was already discharged by —
the running field beside it; on the phase channel the same value reads as a fresh success edge. The
poll's tick() runs synchronously at script end, so the FIRST poll of every steady-state page load
reported a terminal job that had never run, scheduled a reload 1.2s later, and the new page did it
again. The coercion is gone: no job, no edge. The defensive intent it was written for is now pinned
by its own named regression test on the running field.
S-4 (core) — a REAL bring-up is now reported exactly once. Without this the loop would return
after every future image update: the finished job outlives the reload it triggered, so the next page
load found phase:"running" waiting for it. consumeIfRunning serves a terminal running once and
clears it — and only while the single-flight slot is free, since the job goroutine sets the phase
before its deferred release() and eating it in that window would lose the success the customer is
waiting on. failed and needs_password stay sticky (their client path stops the timer and shows a
card with NO reload, so stickiness is informative and cannot loop), and in-flight phases are never
consumed. Accepted cost, stated rather than hidden: with two tabs open during a bring-up only the
first gets the success banner — both still show the true state, which comes from the level channel.
The unified async-job feedback layer remains the ROADMAP item; this is the minimal contract fix.
S-2 + S-5 — the page now names both ways in. It had only ever shown the configured NetBIOS name,
so a customer whose network fails to resolve it had no fallback but a guess — and the guess that
produced the diagnosis was the Proxmox HOST's address, which never ran smbd. New
„Csatlakozás a megosztáshoz" card: the Windows form, the Mac form, and the direct smb://<IP>.
The address comes from stacks.SambaLANAddress(), which reads the guest's netns through the SAMBA
container (network_mode: host) — the controller is on a docker bridge and would answer 172.x,
the same trap setup.DetectLocalIPs needs HOST_IP for. Reading it there also makes it the right
kind of true: it is the address smbd is bound to, not merely one the box owns. Derived per render
and cached nowhere — the guest holds it by DHCP, so a stored copy eventually misdirects people
(S-5) — and an underivable address omits the line, because no address beats a wrong address.
sharing.html's <script> block is byte-identical to v0.150.0: both fixes are server-side, so the
client contract is proven fixed rather than papered over. infra.SambaHostInterface replaces the
third eth0 literal (smb.conf, FELHOM_IFACE, and now the address read must name the same nic).
Red-proofed three ways — reinstating the coercion, deleting the serve-once clear, and memoizing the
derived address each turn the corresponding test red. 23/23 packages green.
v0.150.0 — green gate restored + the export link stops leaking the CSRF token (2026-07-20)
F7 / R-53 — app_export.html built the app's public URL from the CSRF token. The line read
var domain = '<subdomain>.{{$.CSRFToken}}', so the „Megnyitás" link was wrong for every app with a
subdomain and a session CSRF token was written into a URL (history, referrers, logs). Two-part fix:
the template token becomes {{$.Domain}}, and exportPageHandler supplies Domain — that handler
builds its own data map instead of going through baseData, which is where every other page gets
the key, so the template had nothing to read. The page's real CSRF path (the csrfH() helper
reading the meta tag) is correct and untouched. Render tests assert the joined <sub>.<domain> and
that the token appears nowhere on that line; red-proofed against the pre-fix template.
The 7 red internal/backup tests are green again — no behaviour change. TestTier2V2_* and
TestSharesTier2* had been failing on DooPlex since before v0.149.0. Root cause is environmental,
one class for all seven: Tier-2's off-drive guard asks system.SamePhysicalDevice (st_dev equality)
whether a candidate target is really a second disk, and on a host where every t.TempDir() lands
on one filesystem the fixture's "two drives" are indistinguishable — so the guard correctly refused
the target and the tests could never reach their subject. The failure message said so outright:
nincs másik fizikai meghajtó.
Fixed with one behaviour-preserving seam in the package's existing style: a nil-defaulted
Manager.samePhysicalDevice field plus a sameDevice wrapper, with the seven call sites routed
through it. Nil → system.SamePhysicalDevice, so production is byte-for-byte unchanged; only
the two test fixtures install a fake that models one drive per directory subtree. No assertion was
weakened, no test skipped, renamed or deleted, and every one of the seven was mutation-proved: the
defect each guards was re-introduced one at a time and each test failed, including the notifier
test's own documented red-proof (_shares reaching Hungarian copy).
v0.149.0 — the dashboard tells the truth about the last backup (2026-07-20)
Closes F3 from felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md.
The dashboard's backup card claimed „Utolsó mentés: Még nem futott" on every box, forever —
including boxes with dumps on disk and crossdrive_completed / db_dump_completed events already
recorded in the hub. It was not a backup failure; it was a lie in the view layer.
dashboard.html branches the row on {{if .BackupStatus}} and reads .Success / .LastRun from
it, but dashboardHandler never put BackupStatus in the template data. The key was always
missing, so the {{if}} arm was unreachable and the {{else}} — "never ran" — rendered
unconditionally. The neighbouring „Adatbázisok: N mentve" row kept working because it reads
DBDumpStatus, which was passed; that is exactly the contradiction the audit caught on the live
box (a card reporting "never ran" directly above "2 mentve").
The fix is the one-line pass-through the template always expected:
data["BackupStatus"] = fullStatus.LastDBDump. *DBDumpStatus nil/non-nil maps exactly onto the
template's branch, so a genuinely fresh box still reads „Még nem futott" honestly and no zero-value
timestamp is ever fabricated. No template change, no new view-model, and "utolsó mentés" keeps its
existing meaning (the last DB-dump run, consistent with the backups page's DB section).
Tests (internal/web/dashboard_backup_card_test.go) drive the real handler through
ServeHTTP rather than the template alone, so they bite on the handler wiring: a planted dump file
on the app's drive must surface as its own timestamp; a box with no dump must still say „Még nem
futott" and must not render 0001-01-01; a failed run must render „Sikertelen". Red-proofed —
deleting the new assignment fails the first of those.
v0.148.0 — coherent snapshot pairs + an offsite restore that actually restores (2026-07-19)
Closes R-43 and R-44, the two findings from DIAG-immich-restore-2026-07-19. The short
version of that diagnosis: Viktor deleted 11 immich photos to test offsite restore, both restore
runs flashed success, and the photos stayed gone. Two independent defects, both fixed here.
R-43 — no offsite path could restore a database. All three offsite buttons were file-only. The two „visszaállítás" actions staged into a scratch folder and never touched postgres; the place-to-live action merged only files MISSING from the live tree and never replayed a dump. For a DB-indexed app — most of the catalog — that combination cannot bring content back: the bytes return and the app still cannot see them, because its index lives in the database. The dump was faithfully carried INTO every snapshot and could never be replayed OUT of one.
New: „Teljes visszaállítás (fájlok + adatbázis)" (ReconstituteFromOffsite,
/backup/offbox/reconstitute). Safety dump → stop → files overwritten to the snapshot's version →
start → the snapshot's own dump replayed → health wait. Two invariants:
- Nothing is ever deleted. The full-restore copier is
rsync -awith NO--ignore-existing(a changed file becomes the snapshot's version) and NO--delete(a file created after the snapshot survives as an extra). A restore that silently removed newer work would be a data-loss event wearing a recovery button's label. - The undo exists before the act. A
pre-restore-dump of the live database is written and verified on disk BEFORE anything is stopped, overwritten or replayed; if it cannot be taken the whole operation refuses with zero changes. The safety dumps live in the app's own unit and appear inListDumpFiles— an undo the customer cannot see is not much of one.
The replay reads the SCRATCH unit, not the live one: the live recovery unit is still never overwritten (it is the local restore path's source), so replaying from it would replay the current database back over itself and restore nothing.
R-44 — a manual push shipped an unrefreshed dump. RunOffboxBackup went straight to the
restic push; dumps came only from the separate 02:30 local run, so a manual push at any other hour
shipped a dump up to ~24h old. On 2026-07-19 that dump was taken four hours before the customer's
account existed and probed to asset: 0 / user: 0 / album: 0 — a 52MB file whose entire bulk was
immich's shipped geodata tables. Size and table count both called it healthy.
Every offsite run — manual and nightly — now refreshes the dumps and recovery units FIRST, then
captures. Order is the mechanism: the gap can only ADD files the DB does not reference yet (a
harmless orphan blob), never remove one it does, so the file set is always a superset of what the
restored DB points at. This also makes the nightly ordering structural instead of a coincidence of
two scheduler entries at 02:30 and 04:15. Each unit manifest carries the run's offsite_run_id +
dumps_at, so a snapshot's coherence is verifiable at restore time rather than assumed.
Honesty surfaces (warn-level, never gates — a false positive that blocked a restore would be worse than the skew it guards against):
- A pre-v0.148 snapshot has no stamp → the confirm says „Az adatbázis-mentés régebbi () — a fájlok és az adatbázis eltérő időpontból származnak." It still restores.
ValidateDumpgained a content sniff: a structurally valid dump whose accounts table has zero rows raises „A mentett adatbázis üresnek tűnik". Exact table-name matching, deliberately — a substring match on "user" would flaguser_metadata/album_user/user_auditon every healthy single-user box and turn the signal into noise.- The completion flash states an OUTCOME, not a mechanism: „A(z) X: N fájl és az adatbázis visszaállítva (mentés: ) — az alkalmazás újraindult." A no-database app says so explicitly rather than borrowing the confident sentence.
- The old missing-only button now says what it does NOT do: „Adatbázist nem állít vissza — törölt tartalom ettől nem jelenik meg újra."
A new dump progress phase („Adatbázisok mentése a pillanatképhez…") names the pre-phase, which on
a large database dominates the early wall clock and would otherwise read as a hang.
Tests: 11 new, with 5 red-proofs run and reverted — replay removed (0 replayed), capture moved
before the dump ([capture dump]), both undo guards removed (no refusal), substring table matching
(join tables mistaken for accounts), buffer-exceeding rows uncounted (a wide row sniffed as empty).
Two of those red-proofs found real test weaknesses rather than confirming strength: the first undo
mutation was caught by a second guard, and the first table-matching test did not discriminate
between the two matchers at all — both tests were rewritten to the cases that actually separate
them.
NOT in this slice: the catalog-wide invariant check (stays on R-41), nightly cadence, retention, quota math, tier-2, and the v0.147.x progress semantics beyond the one added phase line. The missing-only place button's own zero-file flash is also untouched — that is the v0.147 feedback arc's item, not R-43/R-44.
docs — the workflow moved to DooPlex-local execution (2026-07-19)
Docs only, no version bump, no code change. Claude Code now runs on DooPlex
(192.168.0.180, Debian 13, kisfenyo) instead of the Windows workstation, working directly in
/mnt/5_hdd/felhom.eu/git/felhom-controller. Builds are local commands; felhom-pve is one SSH hop.
CLAUDE.md— environment/access table rewritten (local DooPlex +ssh felhom-pve, no$SSHvariable), build/deploy commands de-SSH'd, workspace-root pointer now/mnt/5_hdd/felhom.eu/git/CLAUDE.md.- New clean-tree gate in the build section:
git status --porcelainempty ANDHEAD==origin/mainbefore any build — because the CC working tree IS the treebuild.shbuilds from. An unpushed change does not exist. claude-in-chromeis not available on DooPlex — endpoint-level validation ("invoke the exact endpoint the UI invokes") is now the stated standard method; strict UI coverage is a manual pass.- Windows knowledge is preserved, not deleted: a "Legacy: Windows workstation" note in
CLAUDE.mdandRUNBOOK-e2e-live-drive.md, anddocs/vscode-ssh-fix.mdcarries a LEGACY banner.
Historical Windows references in past CHANGELOG/REPORT entries are left untouched — history is
history. Platform-genuine mentions (the _other.go dev stubs, \\FELHOM shares in Windows
Explorer, the Windows-grep multibyte rationale in the gate scripts) are unchanged.
v0.147.3 — 4c follow-up 3: the run does not end with the last app (2026-07-19)
Third real run, third thing only a live run could show. The per-app legs finished in ~15 seconds;
the remaining 40 of the 57-second run was the shares leg and forget --prune — during which the
card sat frozen on „calibre-web — 8 / 8 fájl". The same frozen-looking silence 4c exists to remove,
relocated to the end of the run.
Progress now carries a phase. The post-app stages announce themselves („Megosztott mappák mentése…", „Karbantartás: régi mentések rendezése a távoli tárolón…") and the app-scoped counters are cleared when a phase starts, so the last app's finished numbers are never shown against work that is no longer about that app. Starting the next app clears the phase again. Pinned by a test.
v0.147.2 — 4c follow-up 2: when NO counter can move, say what is being worked on (2026-07-19)
The v0.147.1 file-count fallback fixed the incremental case but not the one the demo box actually
hits. Watching a second real run: bookstack reported clean byte progress (100%, 154.0 MB, 7/7
files — the byte path works), while immich sat at files_done 1 of 46, bytes_done 0, for 42
seconds. restic 0.14 only counts a file into bytes_done/files_done when it completes, so an
app dominated by a single large archive (immich's ~430MB volume tar) freezes both counters. No
percentage can move in that window.
So stop trying to. restic keeps reporting current_files and seconds_elapsed throughout; the card
now shows the file being processed and the elapsed time — „Mentés: immich — 1 / 46 fájl (430.2 MB) ·
feldolgozás alatt: immich_upload.tar · 42 mp". „Working on this file for 42 seconds" is a completely
different message from „0%", and it is the honest one.
The last known current_files value persists across ticks that omit it (restic does not send it
every time, and blanking the label every other second is its own flicker), and switching app clears
it so one app's file is never shown against another. Both pinned by tests, along with the real
42-second status line shape.
v0.147.1 — 4c follow-up: the progress bar must move on an INCREMENTAL run (2026-07-19)
Found by watching the v0.147.0 card during a real manual run on the demo box, which is the only way this was ever going to surface.
The observation. A 430MB immich push reported 0% for 40+ seconds and then completed. The
parser was not broken — restic was genuinely reporting no transferred bytes. On an incremental run
where nothing changed, restic transfers nothing: bytes_done is omitempty on restic's side, so it
is not even present in the JSON, and percent_done stays 0 for the whole run. Confirmed against the
real schema by capturing backup --dry-run --json output from restic 0.14.0 in the controller image
(the version comment in offbox_progress.go now quotes those captured lines verbatim).
Why it mattered. A byte-only progress bar is indistinguishable from a hang in the COMMON case — the incremental run — which is precisely the silence 4c set out to remove. Shipping it would have replaced "no feedback" with "feedback that says 0% and looks stuck".
files_done/total_filesare now parsed and published alongside the byte counters. They move on an incremental run even when bytes do not.- The card prefers bytes when bytes are moving; otherwise it drives the bar from files and says „N / M fájl ellenőrizve"; only before restic knows a total does it say „a mentendő adatok felmérése…".
parseResticStatusnow returns a struct rather than four positional values, and a new test pins the real incremental-run line shape (bytes absent, files climbing) so a future refactor cannot quietly drop the file counters and restore the stuck bar.
v0.147.0 — feedback slice 1: pressing a button says something (2026-07-19)
Green: go build ./... && go vet ./... && go test ./... all pass (23 packages);
template_id_gate + emoji_gate + native_confirm_gate + offbox_rename_gate +
app_row_dedup_gate + mojibake_gate all PASS. (docker_run_volume_path_gate fails on
internal/appexport/estimate.go:179 — pre-existing on HEAD, untouched by this release; verified
by stashing this work and re-running.)
The systemic complaint, twice in one evening: you press a button and nothing happens. No progress, no ETA, no named result. This slice fixes the three worst offenders using the two patterns already in the codebase (the deploy 3-step panel and the storage-init status poll). It deliberately does not introduce a feedback framework — that is a ROADMAP item ("unified async-job feedback"), because three targeted cards are worth shipping tonight and a framework is not.
-
4a — a verification restore now names its result. The completion flash said the app had been restored „ellenőrző mappába a meghajtón" — which folder, on which drive, was invisible, so the customer could not go and look at the thing they had just asked for. It now carries the full path. The restore page gained a „Meglévő ellenőrző másolatok" listing (app · size · date · path) — until now nothing anywhere showed what these restores had accumulated, so they piled up and the only way to find them was SSH — each with a double-confirmed „Másolat törlése".
- That delete is the only delete this release adds, so it names a STACK, never a path: the
Manager resolves the name inside a
backups/offsite-restoreroot it computed itself and refuses anything landing outside. Red-proofed — neutralise the name guard andstack: ""resolves to the offsite-restore ROOT and takes every copy with it. Every refusal is asserted as a non-effect (the neighbouring copy and the live data are still on disk afterwards). backups/offsite-restorewas open-coded in three places; it now has one home (offsiteRestoreRootFor), and a test pins the path in the flash to the path in the listing so the customer can never be told about a directory the page cannot show or remove.
- That delete is the only delete this release adds, so it names a STACK, never a path: the
Manager resolves the name inside a
-
4b — Megosztás enable shows what it is waiting for. Enabling sharing ran
ReconcileSamba()synchronously inside the POST handler. On a box whose golden had not bakedfelhom-sambathat iscompose up -dpulling ~100MB from a private registry: minutes of an apparently-hung form post, then „Beállítás mentve." whether or not anything had come up. Now detached + polled, with a card that distinguishes „képfájl letöltése" (image genuinely absent — the multi-minute case) from „indítás" (already baked — seconds). The distinction is decided before the work starts, because afterwards the image is always present and the card could never truthfully say „letöltés".- Success is probed, not inferred:
compose up -dexits 0 on a crash-loop, so the terminal state is container liveness.nilfrom reconcile also covers "deliberately deployed nothing because there is no household password yet", which now gets its own message instead of a card spinning forever. - The password form starts the same job — with
UserSetfalse reconcile deploys nothing, so on a fresh box that, not the enable toggle, is where the pull actually happens.
- Success is probed, not inferred:
-
4c — „Távoli mentés most" streams real progress. restic was already reporting bytes and percentages; the runner seam used
CombinedOutput()and threw them away. The manual run now passes--json, scans stdout line-by-line, and the page shows total bytes, percent and the app currently being pushed. Before the scan finishes it says „a mentendő adatok felmérése…" rather than pinning a bar at 0%, which reads as stuck.- Manual only. The nightly run stays silent and its output format is untouched — pinned by a
test that fails if the scheduled path ever passes
--jsonor publishes progress. - The poll now arms unconditionally. It used to start only if the page already rendered „Fut…",
which loses a race the manual trigger always runs: the POST redirects and the page renders before
the detached goroutine writes
LastStatus=running, so the poll never armed and the customer watched a static page during the very run they had just started. - Red-proofed twice, both confirmed: break the parser → the percent assertion fails; drop the
wiring → the
--jsonassertion fails. The--jsonstream is tail-bounded (40 lines) so a large backup does not buffer megabytes of status spam for error diagnosis.
- Manual only. The nightly run stays silent and its output format is untouched — pinned by a
test that fails if the scheduled path ever passes
-
Golden/controller infra-image drift closed at the source (supports the agent-side change).
infra.Images()derives the list from the existing pins,--print-infra-imagesprints it, and the golden bake now asks the controller binary it is about to bake instead of carrying its own copy. The copy had already drifted:felhom-sambawas never added to it, so the golden baked 3 of 4 — which is why enabling Megosztás pulled at runtime. A test parses the const block out of the source and fails if a pin is added without reachingImages().
Live-validated on demo guest 9201 through the real UI. No floor change — Viktor decides floor timing.
v0.146.0 — nav polish: styled scrollbars + collapsible sidebar groups (2026-07-18)
UI-only; no behavioural or backup/restore surface touched. Green:
go build ./... && go vet ./... && go test ./... all pass; template_id_gate + emoji_gate +
native_confirm_gate + offbox_rename_gate + mojibake_gate + app_row_dedup_gate all PASS.
-
Scrollbars (
style.css). The platform default is a light, chunky bar that reads as a bright stripe against the navy and competes with the content it is scrolling. Now thin and hairline coloured:scrollbar-width: thin+scrollbar-colorfor Firefox,::-webkit-scrollbar(8px, thumb--line, hover--text-3,--radius) for WebKit/Blink — both declared, because neither alone covers the browsers customers actually use. The two surfaces that really scroll take their own panel background as the track (.sidebar→--bg-2,html→--bg-0) so the gutter never shows through as a lighter channel. Tokens only, no raw hexes. -
Collapsible nav groups (
layout.html+style.css, vanilla JS — no framework). Tárhely, Biztonsági mentés and Megosztás are now accordions with a chevron indicator; exactly one is open at a time, and clicking the open one closes it. Groups without sub-items (Vezérlőpult, Alkalmazások, Rendszermonitor, Debug) are untouched plain links. Hungarian labels unchanged.- The header is a real
<button>, so keyboard and assistive-tech reachability come for free rather than being simulated withtabindex/roleon a div. It carriesaria-expanded+aria-controls, and a:focus-visibleoutline. - Nothing became unreachable when the header stopped being a link: every group's own landing
page is also its first sub-item (
/storage→ Meghajtók,/backups→ Áttekintés,/sharing→ Hálózati megosztás). This was checked before the conversion, not assumed. - Progressive enhancement: the group containing the active page is rendered open
server-side (
.is-open), so the correct group is open before any JS executes and stays open if JS never runs. The listener only handles clicks. - No layout jump: the collapse animates
grid-template-rows: 0fr → 1fr(withmin-height: 0overflow: hiddenon the sub-list) rather thanmax-height. That animates to the content's REAL height, so there is no magic number to drift when a group gains or loses an item — the specific way amax-heightaccordion rots. The toggle reserves its 3px active border astransparentso becoming active adds no width shift. Transitions are .18s and both the collapse and the chevron rotation are disabled underprefers-reduced-motion: reduce.
- The header is a real
Note (unchanged, pre-existing): docker_run_volume_path_gate.py still fails on
internal/appexport/estimate.go:179. That is ROADMAP R-29, it is unrelated to this change, and
it was verified to fail identically on the untouched tree — deliberately not bundled here, per
R-29's own "do not bundle (a) into an unrelated feature commit".
v0.145.0 — R-7b: share data enters the live backup runs (Model B′) + samba liveness (2026-07-18)
The „Felhőmentés" toggle on the Megosztás page is now true. Before this release a customer could
switch a share to „Felhőmentés: bekapcsolva" and the page would render exactly that while the files
dropped on it were in no backup at all — backup.RunTier2 short-circuits on os.Stat(unitDir)
before the classification seam, and the offsite runner enumerates GetOffboxApps(). A share-only
infra stack has neither a recovery unit nor an offbox toggle, so it fell through both engines. R-7b
closes that with a sibling shares source in each tier.
Model B′ (Viktor's ruling, 2026-07-18) and its invariant. Share data enters the runs through NEW, ADDITIVE job/leg code that reuses the proven primitives — the tier-2 mirror seam, the restic wrappers, the soft-quota/enlargement gate, the status recorders — while leaving every per-app engine path byte-identical. Not Model A (a synthetic recovery unit breaks on multi-drive shares and wraps 1 KB of JSON in dump machinery) and not engine-loop surgery. The invariant is enforced by test, in both tiers, with red-proofs.
- Payload (
internal/backup/shares_payload.go, new): a staging dir holding_shares-manifest.json(the share definitions, sorted → byte-deterministic for an unchanged registry, so a no-op run gives the mirror nothing to rewrite) plus a best-effortpassdb.tarcaptured from the samba container. A restore therefore returns the files, the share configuration AND the SMB password hash — not just bytes on a disk. The credential copy is SECRET-BEARING: 0600, never logged at INFO, never in a report or a committed file. A down container degrades to manifest-only and KEEPS any previously captured copy (a stale credential beats none for DR). - Tier-2 shares job (
internal/backup/tier2_shares.go, new): runs after the per-stack loop in the same orchestrator run. Shares are grouped by source drive — a household's shares can span disks and each group needs its own cross-drive target — intobackups/secondary/_shares/<sourceDriveKey>/<share>with the payload at_payload/and the layout marker written LAST. ReusesselectTier2TargetFrom(a narrow source-drive seam extracted fromselectTier2Target; the headroom math is untouched),tier2ReconcileRoots(a pure extraction),tier2SafeRemoveand therecordTier2*helpers. - Offsite shares leg (
internal/backup/offbox_shares.go, new): ONE additionalrestic backup --tag felhom-offbox --tag _sharescarrying the manifest staging dir plus every MANDATORY share folder, hooked in AFTER the per-app loop and BEFORE retention — soforget --group-by host,tagscovers the_sharesgroup with no flag change. Same enlargement arithmetic as the per-app gate. Degradation contract: a quota-blocked push falls back to the MANIFEST ONLY, never to nothing — definitions protection must not regress because the files stopped fitting. - Restore „Megosztások" (
internal/backup/shares_restore.go, new): siblings of the per-app scratch/place pair. Files are merged missing-only (never overwriting) and every destination is prefix-asserted against registered LIVE storage roots — a snapshot is untrusted layout input, so a path that no longer sits under a live root is refused rather than created. Definitions merge with existing-wins (a restore must never silently flip a live share's settings; skipped ones are named in the flash). ThenReconcileSambare-renders smb.conf, and the credential goes back into the named volume best-effort. RoutesPOST /backup/shares/{restore,place}. - Samba liveness (the fold-in):
monitor.EffectiveProtectedgains a settings-backed dynamic extra, so a dead sharing service raises the same protected-container issue → alert → Hungarian degradation e-mail as a dead traefik — but only while sharing is ON. It watches the container name (infra.SambaContainerName), which is deliberately NOT the stack name. Finding: the alert/e-mail pipeline needed no further change and no new event type is introduced, so theallowedEventTypesgotcha does not apply. - UI truth-up: the Megosztás page states per-tier status (2. mentés / távoli mentés, amber only on
deviation) and links to the restore page. The reserved
_shareskey is mapped to „Megosztások" at the notification and Hungarian-prose boundaries ONLY — the persistedEnlargedBlockedset, the restic tag and the dest path keep the raw key, because templates index by it. - RESERVED-NAME FINDING (the task's assumption was false):
settings.nbNameRebegins with[A-Za-z0-9_], so „_shares" was an accepted share name — the underscore namespace was not in fact reserved.ValidateSMBShareNamenow refuses a leading underscore (on ADD only, so existing shares are never retroactively invalidated), andRunAllTier2/RunOffboxBackupadditionally skip a_sharesSTACK loudly as defense in depth. infra.SambaContainerName/SambaPassdbVolume/SambaPassdbMountbecome the single source of truth for the samba container identity — the compose renderer interpolates them, and stacks, backup and monitor all read them instead of repeating string literals.- Bug found by test:
shareSourceDrivereturned a slash-normalised path, which made the target selector's source-drive equality check miss — a share group could have targeted its own source drive (a same-disk copy pretending to be tier 2). Fixed; POSIX-only in effect, but real. - Tests: 24 new/extended cases in
internal/backup+ 2 ininternal/monitor. Six red-proofs run and reverted, all fired: (1) shares leg appending into the app's argv → B′ isolation FAILS; (2) mandatory→offsite mapping inverted → Scenarios A+B FAIL; (3) manifest-only degradation dropped → Scenario C FAILS; (4) prefix-assert removed → place-guard traversal FAILS; (5) dynamic samba extra removed → Scenario E enabled-case FAILS; (6) shares destBase dropping the reserved segment → tier-2 isolation FAILS.
v0.144.0 — „Megosztás": LAN SMB file sharing (R-7 slice 1) (2026-07-18)
The customer turns on network sharing, sets ONE household SMB password, and exports folders. The box
appears in Windows Explorer's Network view as \\FELHOM; opening a share and writing to it works, and
every SMB write lands as uid:gid 1000 so apps and both backup tiers see consistent ownership. SMB is
an embedded controller feature, not a catalog app — it needs host networking (the R-6 spike
verdict), its config is a generated share list, and its roots ride the backup classification.
- New infra image
felhom-samba:1.0.0(controller/infra-images/samba/, built bycontroller/scripts/build-samba-image.sh): pinned alpine 3.21 (sha256:48b0309c…) + smbd + nmbd- wsdd + tini. Dumb by design —
smb.confis bind-mounted READ-ONLY, nothing is templated inside, no name/password is baked, passdb lives on a named volume. nmbd is REQUIRED alongside wsdd: the R-6 spike proved wsdd-only leaves the box visible but the Explorer double-click fails0x80070035(no flat-name resolution). Anonymous pull verified from the guest.
- wsdd + tini. Dumb by design —
- Settings (
internal/settings/smb.go, new):SMBSettings{Enabled, ServerName, UserSet}+SMBShare{Name, Path, ReadOnly, Offsite, CreatedAt}registry with NetBIOS-safe validation (≤15 chars, no slash/dot) and case-insensitive name-collision refusal. The household SMB password is NEVER persisted — only theUserSetboolean. - Renderers (
internal/infra/samba.go, new): pureRenderSambaConfig(hardened global block:server min protocol = SMB2,bind interfaces only = yes,interfaces = lo eth0,disable netbios = no,map to guest = never, per-share force-user block) andRenderSambaCompose(network_mode: host, pinned image, config:ro, passdb volume, one bind per share —:rofor read-only shares as defence in depth beside smb.conf). Exact smb.conf golden test. - Lifecycle (
internal/stacks/samba.go, new):ensureSambajoinsEnsureBaseStackafter filebrowser, gated onSMB.Enabled(the cloudflared conditional-deploy precedent);ReconcileSambaruns after every mutation. Idempotent — unchanged config + running container performs zero compose calls. Config writes are atomic (tmp+fsync+rename). The password is applied viasmbpasswdon STDIN (never argv, never logged). Disable =compose down; the passdb volume and every shared folder are KEPT. A share on a disconnected/decommissioned drive is rendered ABSENT from smb.conf (never export a dead mountpoint) while its config is retained. - Protection:
sambais protected in CODE (config.alwaysProtectedStacks) becausecfg.Stacks.Protectedcomes from the golden-generated controller.yaml and predates it. This also makes the app-backup loops correctly skip it (it is infrastructure, not a customer app). - UI (
internal/web/sharing_handlers.go+templates/sharing.html, new): a new top-nav category „Megosztás" → „Hálózati megosztás". Enable/server-name card, household password, shares table (Név · Mappa · Írásvédett · Felhőmentés · Törlés — "a mappa és a fájlok megmaradnak"), and a create flow (new folder under<storage>/shares/or an existing folder via the browse modal). - Picker security: every customer-supplied path goes through
sharingResolvePath— absolute →EvalSymlinks→ containment in a registered LIVE storage root → deny-listed system subtree → is-a-directory. Refusals are uniform so the picker can never act as a filesystem oracle. The deny-list is DERIVED fromstacks.ProtectedHDDPaths(provably a subset, so it can only shrink, never drift); the drive root is an exact-match denial so user-data folders under it stay shareable.sharingResolveStorageRootis a separate, strictly tighter check for the new-folder parent. - Backup classification [R4] (
internal/stacks/samba_classify.go, new):ClassifiedBinds("samba")resolves from the shares registry instead of catalog metadata. Felhőmentés ON →mandatory(offsite + tier-2); OFF →optional(tier-2 only); smb.conf/passdb never classified. Verified through the realComputeCaptureSettier filter including the negative. Zero backup-engine edits. - KNOWN GAP (reported design fork, not improvised): making that seam correct does NOT by itself put
share data into a live tier-2/offsite RUN.
backup.RunTier2short-circuits onos.Stat(unitDir)before it ever callsGetStackClassifiedBinds, and the offsite runner enumeratessettings.GetOffboxApps()— both are recovery-unit shaped, which a share-only infra stack has not. Teaching them about one is more than an enumeration tweak, so per the task's STOP clause it is reported rather than improvised. SeeREPORT.md. - Live-validated end-to-end on demo guest 9201 through the REAL endpoints (curl against the exact
routes the UI posts to; the UI is password-gated so no browser leg): enable → password → create both
share kinds → guard refusals (appdata/backups//etc/drive-root all uniform 400) → smb.conf + container
:robind verified on the box → Windows 11 workstation:Test-NetConnection 445True, nbtstatFELHOM <00>/<03>/<20> Registered,ping FELHOMresolves, SMB write/read byte-compare PASS, and a write to the read-only share refused with no effect. SMB-written files land as1000:1000. Explorer leg PASSED (Viktor, 2026-07-18): both shares open from the Network view; an interactive Explorer save landed owned 1000:1000 and a write into the read-only share was refused, folder left empty. Slice 1 is fully PROVEN-LIVE.
Build infra — build root relocated (2026-07-18)
controller/build.sh:REPO_DIR+WEBSITE_ASSETS_DIRrepointed/home/kisfenyo/…→/mnt/5_hdd/felhom.eu/…. All felhom working dirs on the DooPlex build server (180) were hard-moved off the (filling) SSD to/mnt/5_hdd/felhom.eu/. No controller code/behavior change; no version bump.
v0.143.0 — guest RAM resize UI (R-24) — MinAgent: 0.90.0 (2026-07-17)
The customer sees the guest's current memory + the allowed range on the Rendszer settings page and
resizes it. The controller only proxies + maps the agent's machine code to Hungarian — the AGENT
(felhom-agent v0.90.0) enforces every bound and applies the change live (no reboot). Memory only.
- agentapi (
internal/agentapi/client.go):GuestMemory(ctx)(GET /guest/memory) andResizeMemory(ctx, mb)(POST /guest/memory). A ruled 412 refusal surfaces*MemoryRefusedErrorcarrying the machine code (below_min/above_max/below_usage_floor) + fresh bounds; a pre-0.90 agent 404s → the typed*StatusError{404}(the capability signal). - Capability (
internal/agentapi/features.go):FeatureGuestMemoryResize+featureMinAgent0.90.0 + afeatureProbesrow (GET /guest/memory is the probe; the probe type-asserts the one method it needs, so the sharedSupportProber/netAgentstay untouched). Per the publish-train convention (this CHANGELOG declares MinAgent; the Supports gate sits at the handler entry point). - UI (
internal/web/system_memory_handlers.go, new;templates/settings_system.html): a "Szerver memória (RAM)" card shows current/used memory + the2048 MB – {max} MBrange; a number input (step 256) + "Átméretezés".POST /api/system/memory/resize→ capability gate (SupportUnknown passes) → agent → POST-response flash. A JS confirm fires ONLY on a shrink. Code→Hungarian map: success "A memória átméretezése megtörtént: X MB → Y MB."; below_usage_floor "…túl közel van a jelenlegi felhasználáshoz (N MB). Állíts le néhány alkalmazást…"; below_min / above_max; agent-outdated → the control is not offered + a "rendszerfrissítése szükséges" note; agent-unreachable → the value falls back to the guest's own/proc/meminfo, control disabled, honest note (the page never 500s). The agent's English message is never shown raw. - Ripple (no code): lxcfs updates the guest
/proc/meminfolive, so the deploy-page memory math follows a resize automatically. - Tests: agentapi (GuestMemory decode, 404-typed, ResizeMemory success + refusal-code, capability
table 0.89→No / 0.90→Yes / probe 404→No / probe ok→Yes) + web handler (success maps to Hungarian;
below_usage_floor maps + the agent English never leaks; agent_outdated gate refuses with ResizeMemory
never called). Gates: template_id + emoji OK;
go build/vet/testall pass.
v0.142.0 — offsite repo continuity: orphaned-repo guard (A) + run-status auto-refresh (C) (2026-07-17)
Closes the reinstall-orphaned-repo incident class (memory offbox-repo-orphaned-2026-07-17): a
recreated data volume minted a new repo passphrase; the offsite repo, keyed under the old one, became
unreadable and surfaced only as a raw nightly wrong password or no key found. Green:
go build ./... && go vet ./... && go test ./... + template/emoji/native-confirm gates. Pairs with hub
v0.60.0 (Part B escrow retention).
- Part A — orphaned-repo guard.
ensureOffboxReponow CLASSIFIES therestic cat configfailure (classifyResticProbe, the exact 07-17 stderr):wrong password or no key found→ ORPHANED; no-repo → init; other (network/SFTP-auth) → existing error handling. An orphaned repo persistsOffboxTarget.RepoState="orphaned"and, ONLY on the transition, pushesoffbox_repo_orphaned(never nightly-spam — scheduled runs then SKIP). The remote page shows a calm Hungarian card (exception color) explaining the remote holds backups under a previous, no-longer-available key — NOT the raw restic banner. Reset (move-aside, never delete): an UNCLAIMED box auto-resets on detection (Scenario B); a CLAIMED box gets an explicit reveal-then-confirm reset (Scenario C) →mv <repo> <repo>.orphaned-<date>(collision-suffixed) +restic init+offbox_repo_resetevent.internal/backup/offbox.go(+ErrOffboxOrphaned,ResetOrphanedRepo, an ssh-exec seam),web/offbox_handlers.go(/backup/offbox/reset+ the orphaned-run refuse),backups_remote.html. Red-proofTestOffbox_OrphanDetection_Claimed(pre-fix = the incident: raw error, no state → FAIL)TestOffbox_OrphanDetection_UnclaimedAutoReset+TestOffbox_ConfirmedReset.
- Part C — run-status auto-refresh. New
GET /backup/offbox/status(JSON) + a poll onbackups_remote.html: while a run shows "Fut…" the page polls and flips to Rendben/Hiba + fresh numbers without a manual reload; polling stops at the terminal state. TestTestOffboxStatusHandler.
v0.141.0 — N100 polish: initialize-to-usable (F6) + Vissza back-routes (F7) (2026-07-17)
Closes two VALIDATION-n100-baremetal-2026-07-16.md findings. Green:
go build ./... && go vet ./... && go test ./... all pass; template/emoji/native-confirm gates pass.
- F6 (MEDIUM) — drive "initialize" now ends in a USABLE (mounted+registered) drive, disconnect-safe.
Root cause (fork verdict, source-grounded): the format→mount→register orchestration
(
internal/web/storage_handlers.gorunStorageInit) ran on the REQUEST context; a closed tab / lost connection cancelled it afterFormatDisk(the agent's mkfs continues detached, returnserrFormatClientGone), so the mount+register leg was aborted — device formatted but unmounted/unregistered (the N100-observed state). The chain must reachSyncFileBrowserMounts(controller-only), so it stays controller-side — no agent change. Fix:POST /api/storage/initstarts a DETACHED single-flight job (internal/web/storage_init_job.go, thenetAddStateshape) oncontext.Background();runStorageInitgains a nil-safe phase callback (formatting → mounting → registering). The wizard polls the newGET /api/storage/init/statusand renders the 3-step progress (storage_init.html); the confirm/refuse verdicts surface through the same poll. Register is the LAST step (marker-last, Scenario B) and every prior step is idempotent (AddStoragePathdedups) → a crash leaves at most an unregistered orphan, never a broken/duplicate registration. Red-proofTestStorageInit_DetachedSurvivesClientDisconnect(pre-fix: cancelled-ctx chain fails at mount, NOT registered → FAIL; fixed: detached job registers exactly once). Deeper half (found on the live leg — a 64 GB USB): a slow mkfs outruns the agentapi client's 15 sTimeout; the agent runs it DETACHED and records the job, sorunStorageInitnow POLLS the agent'sGET /disks/format/status(newagentapi.Client.FormatStatus) to the terminal outcome on a client timeout, then continues to mount+register (the F6 root-cause's "mkfs continues detached; poll the status"). TestTestStorageInit_PollsAgentFormatStatusOnTimeout(timeout→done registers; timeout→failed surfaces the error, no register). - F7 (LOW) — the "Vissza" (Back) anchor on
/storage/initand/storage/attachnow routes to/storage(was/settings). The init success link also points to/storage(where the new drive appears). TestTestStorageWizardBackAnchors_PointToStorage.
v0.140.0 — Direction-2 immediate-sync: hub→box wait channel client (2026-07-16)
The other half of the immediacy arc (Direction 1 = v0.139.0 box→hub trigger). An operator action on
the hub now reaches the box in seconds instead of on the next ~15-min cycle. Pairs with hub
v0.58.0 (the GET /api/v1/wait endpoint + the in-memory operator-intent notifier). Grounding:
felhom.eu/documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md (option b).
internal/report/waiter.go(new)report.Waiter: holds a hanging authenticated GET against the hub's/api/v1/wait?gen=N(reusing the SAME hub URL + key as the pusher — no new config keys). Its ownhttp.Clienthas no overall Timeout (a held GET must stay open for the hub's ~240 s hold) with sane connect/TLS/ResponseHeaderTimeoutdeadlines; a per-request context bounds a black-holed connection. On a completion whose generation differs from the last seen, it fires the v0.139.0report.Trigger— and NOTHING else; the fired report's ACK delivers config/escrow/ claim/floor through the UNCHANGED machinery (this adds zero delivery logic). Behaviors:- First observation records, never fires (the startup report already covered current state) —
prevents a spurious echo report on every process start / config-refresh restart. Red-proof:
disable the baseline branch →
TestWaiter_FirstObservationRecordsNoFirefires 1 (run-fail-reverted). - Same-generation timeout fires nothing (the hub's hold elapsed) — not interval-shortening.
- Heartbeat newlines tolerated; the body is read only for its
{"gen":N}line (contentless wake). - Any error — transport, a 404 from a hub that predates the endpoint, or a malformed body — backs off exponentially (5 s → 5 min, reset on success) with ONE WARN per state change, and the 15-min cycle keeps reconciling. Exits promptly on context cancel (even mid-hold).
- First observation records, never fires (the startup report already covered current state) —
prevents a spurious echo report on every process start / config-refresh restart. Red-proof:
disable the baseline branch →
cmd/controller/main.go: the Waiter is constructed + started right beside the Direction-1 trigger, gated on the SAMEhubPusher != nil && cfg.Hub.Enabledcondition (strict no-op when hub reporting is off). One INFO line on start.- Copy soften (Viktor-approved):
backups_remote.html+backups_escrow.html— "ez általában néhány másodperc, legfeljebb 15 perc" (was "néhány perc"). The 15-min bound stays — it is the honest worst case when both the wait and the immediate push fail. Escrow grace window unchanged. - Coupling (soft): immediacy needs hub ≥ v0.58.0; against an older hub the wait 404s and the box
degrades cleanly to the 15-min cycle. No agent coupling, no
MinAgent.
v0.139.0 — immediate out-of-cycle hub report on user actions (Direction 1) (2026-07-16)
Viktor's ruling: a user action with hub-side effects must round-trip in seconds, not minutes. One
generic, debounced out-of-cycle report trigger now sits on the proven outbound push channel; the
15-min hub-report cycle is untouched and stays the reconciliation backbone. Headline UX win: the
v0.138.0 escrow "megerősítésre vár" card collapses from ~14 min to seconds (the blob is already
uploaded at claim time — the immediate report's ACK hash-match flips pending→escrowed via the
unchanged EscrowAutoConfirmer). No hub change; no UI copy change ("legfeljebb 15 perc" stays the
honest worst case for a failed immediate push).
internal/report/trigger.go(new)report.Trigger: buffered-1 signal channel + single worker (shape: hubwgsync/reconciler.go). Non-blockingFire(); worker = quiet window 2 s (burst coalescing) → drain → min spacing 15 s → ONE full-report fire. Coalesce-and-eventually- fire (trailing edge): a burst yields ≤ 1 + ceil(burst/15 s) pushes and the last state always reaches the hub — deliberately NOT theinternal/syncrefuse-debounce (a refused fire would lose the update until the next cycle). No retries of its own (the Pusher owns 3×5 s); a fire error logs one WARN and degrades to the cycle. Exits on context cancel.cmd/controller/main.go: ONE canonical fire closure (BuildReport+Claimed+hubPusher.Push), constructed only whenhubPusher != nil && cfg.Hub.Enabled; replaces the raw per-fire goroutine behindapiRouter.SetReportPushTrigger(the v0.70.0 geo seam — kept, now debounced) and feeds the newwebServer.SetReportTrigger.internal/api/router.go: deploy / remove / delete endpoints now call the existingreportPushNow()after success (geo save/sync already did) — all backed by the trigger.internal/webseam + call sites (server.goSetReportTrigger/reportTriggerNow, nil-safe, fired only AFTER a successful local commit): escrow recovery-code claim (escrow_handlers.go), notification-prefs save + app-email toggle (handlers.go), offsite target config + per-app offsite toggle (offbox_handlers.go), customer claim completion (claim.go).hub.enabled: false→ seams stay nil → strict no-op.- Tests:
internal/report/trigger_test.go(single-fire exactly-once, Fire() non-blocking, burst-coalescing ceiling + trailing edge with red-proof, fire-error isolation with red-proof, prompt cancel exit),web/report_trigger_seam_test.go(fires-after-commit-only through the real offbox toggle handler + nil-seam no-op),api/report_trigger_nilsafe_test.go.
v0.138.0 — escrow "awaiting hub confirmation" waiting state (2026-07-16)
Closes the customer-zero (N100) UX gap: after a completed escrow ceremony the Távoli mentés page kept
showing the yellow "Helyreállítási kód szükséges" card for ~15 minutes, until the next hub-report
ACK flipped pending→escrowed. Phase-0 diagnosis (read-only) = verdict A (report-cycle lag): on the
demo box the ceremony completed 16:13:39 and the very next hub-report ACK at 16:27:58 auto-confirmed
via hash-match (d517ce7f…), escrow_state:"escrowed" — nothing was broken; the wait simply had no UI
feedback. (Hub stale-clear-on-upload — Hypothesis B — was verified to already exist: SaveHostEscrow's
ON CONFLICT sets stale_at = NULL, so no hub change was needed or made.)
settings.OffboxTarget.CeremonyCompletedAt(new,ceremony_completed_at, RFC3339) — stamped on a successful recovery-code claim (web/escrow_handlers.go, only while still pending; best-effort, a stamp failure never fails the claim) and zeroed on thepending→escrowedflip (the auto-confirmerFlipclosure incmd/controller/main.go+ the deprecated manual confirm inweb/offbox_handlers.go). Persisted → survives a controller restart mid-wait.web/handlers.gooffboxCeremonyWaitState+escrowCeremonyGraceWindow(35m): classifies the wait — awaiting (stamped, within the window) vs timed out (stamped, past two report cycles + slack). Both fall back to the plain pending CTA when escrowed, unstamped, or the stamp is unparseable.backups_remote.html: one new escrow-card branch ahead of the existing chain — an info (blue) "Helyreállítási kód létrehozva … megerősítésre vár, legfeljebb 15 perc" card, degrading to a warn "A megerősítés nem érkezett meg …" + re-ceremony CTA past the window. The existing pending/stale (Scenario F)/escrowed branches are untouched.backups_escrow.html: the wizard's final "Befejezés" step gains a "Mi történik ezután?" note so the customer expects the interim card on the page they land on.- Test:
web/escrow_wait_state_test.go(truth table + mutual-exclusion invariant; red-proof recorded in REPORT). No scheduler/agent/endpoint changes.
v0.137.0 — empty-email notification save guard (data-loss fix) (2026-07-15)
Fixes a silent alert-delivery wipe demonstrated on the demo customer on 2026-07-15: saving the
Értesítések form with a blank e-mail box while events were still enabled dropped an empty
Email into the prefs AND pushed it to the hub (SyncPreferences), overwriting the customer's
provisioning-seeded alert address — the "Kedves Ügyfél!" delivery path went dark until it was
restored by hand in 6D (P3-DELIVERY).
web/handlers.gosettingsNotificationsHandler: after computing the trimmed email + enabled events, a guard refuses the save whenemail == "" && len(enabledEvents) > 0— it returns beforeSetNotificationPrefsand before any hub sync, re-rendering the page with a Hungarian error ("Adj meg egy értesítési e-mail címet – …") and repainting the just-submitted checkboxes (an overlay onnotificationsPageData'sNotificationPrefs, render-only). Enabled events with no address is a purely destructive state reachable only via the bug. The legitimate empty-email + ZERO events clear-all still proceeds (the empty hub push is correct there).- Deliberately NOT an HTML
requiredattr on the input —requiredis unconditional and would block the legitimate clear-all case; the server-side guard is the correct, precisely-conditional floor.SyncPreferences/ the hub side / the seed-migration are untouched. - Tests (
web/notifications_guard_test.go): guard-fires (stored email survives — the wipe is prevented; red-proofed: remove the guard → the email is wiped to""), legitimate clear-all proceeds, normal save persists. Real temp-fileSettings(non-hollow: asserts stored state).
v0.136.0 — .fab exclusion scoping: classes in the manual export (Task 4) (2026-07-15)
Task 4 — the .fab column of the matrix (architecture §2; the SQ5 exclusion-scoping verdict + Viktor
ruling #1 + R1-C). The SQ6 over-capture is fixed for classified apps: a manual export no longer
drags every sibling app's content along in the userdata root tar. Mechanically unchanged from
v0.130.0 — ONE exclude-scoped userdata-root tar + per-mount skip — so the manifest stays v1
(basename keying, the userdata fallback), the import side is untouched, and old controllers
import new bundles correctly (they just extract a root tar containing less). Legacy (no-block) apps
export byte-identically to v0.130.0 (the SQ5 safety net).
appbackup.ComputeFabBuckets(new, pure): the same resolution + structural guards + equal-Abs collapse asComputeCaptureSet(shared pipeline extracted, not duplicated), but bucketed by class (mandatory / optional / excluded), guards over ALL classes (a traversal path is never plannable — opt-in or not), and NO cross-bucket containment dedup (a mandatory child inside an excluded parent stays independently addressable).- The export plan (
appexport/fabplan.go):ExportRequestgainsDeselectOptional+OptInExcluded;computeFabPlanresolves the selection (mandatory forced-in — a server-side floor ignores a client trying to deselect a mandatory path; optional default-in; excluded default-out) into{SkipMounts, SkipUserdataTar, UserdataExcludeRels}. The userdata root tar is skipped entirely when no userdata bind is selected (radarr → state-only, the SQ6 headline); else the exclude list is the topmost dirs neither ancestor nor descendant of a selected relpath (R1-C — the exacttier2Reconcilekeep-rule). An HDD mount matching no classified bind is KEPT (fail toward capture).tarDirectoryExcludingskips excluded subtrees in the walk (tarDirectoryis now a thin wrapper). - Estimate split (additive):
ExportEstimategainsHasClassification+BaseBytes+ per-pathMandatoryItems/OptionalItems/ExcludedItems(each with its du size) — existing fields and the fits gate are unchanged. Both estimate pipelines surface it (sharedEstimateExport). - UI (
app_export.html, classified apps only): locked mandatory list, pre-selected optional checkboxes, a collapsed excluded opt-in behind the two-number warning ("Alap mentés: ~X. A kihagyott, nagy méretű tartalommal együtt: ~Y.") + the FileBrowser pointer; totals recompute client-side per toggle. Selections POST through BOTH start endpoints (the two-call-site). - Tests:
ComputeFabBucketsbucket/guard/containment tests;computeFabPlanscenarios A–F + §8 edge cases;tarDirectoryExcludingFS-level; export-level bundle tests (exclude-scoped tar, legacy full-root, all-excluded no-tar); the two-call-site bundle test across both start pipelines. All 6 §10 red-proofs verified. CAMPAIGN-6D Accept #1 (the ≥1 GiB.fabfull circle) now runs against this final capture shape.
v0.135.0 — Tier-2 engine rework: class-driven legs, v2 layout, NAS-target exclusion (Task 3b) (2026-07-15)
Task 3b of the backup-classification-redesign arc — the tier-2 column of the matrix (architecture
§2/§8). Behavior-changing but bounded: every destructive write lands ONLY under
backups/secondary/<stack>/ (fully-derived data), asserted in code.
- Class-driven appdata leg (
tier2_capture.go, new): for a classified app the tier-2 legs are the Task-3-coreTierSecondaryset — per-bind mandatory + optional HDD/userdata paths (paperless's copy legitimately SHRINKS asexportdrops out). Legacy apps keep a byte-identical capture set (the resolver appdata dir(s)) mapped into the same layout. Skipped/missing mandatory paths are loud gaps (English log + the app's Hungarian cross-driveLastWarning), mirroring the offsite pattern. - v2 relpath-mirroring layout:
backups/secondary/<stack>/=.felhom-tier2-layoutmarker (content "2", written LAST) +recovery-unit/+hdd/<relpath>/+userdata/<relpath>/. N>1 appdata dirs and nested binds are represented natively — the v0.131.0 flat-appdata N>1 refusal is gone (errTier2MultiDir/tier2AppDataNamedeleted). Restore is position-derivable. - Migration = delete-and-rebuild (marker absent → remove the old flat
appdata/;recovery-unit/is layout-identical, untouched) + a reconcile step that prunes dest dirs a bind no longer covers (a removed/re-classed bind stops occupying the secondary drive within one run). Allos.RemoveAllgoes throughtier2SafeRemove, which refuses any target not strictly underbackups/secondary/. - SSD fallback is an enforced STATE-ONLY tier (§2.2): headroom is decided on unit + mandatory; the SSD carries unit + mandatory only, optional legs skipped with an honest Hungarian reason.
- NAS-target exclusion (F-6C-1):
selectTier2Targetnever selects a NETWORK storage path — pinned OR auto (metadata-onlyIsNetwork(), no fs probing). NAS-only ⇒ the honest reason ("Hálózati tároló nem lehet a 2. mentés célja…"). Prevents the rsync-og-under-root_squash silently-wrong-owner restore. - Restore reads v2 only: a marker gate refuses a pre-v2 copy ("A 2. mentés régi formátumú…");
the reader merges the
hdd/anduserdata/subtrees missing-only into live (N>1 native). - Part 0 — prefs seed fix: the 3a-fix un-disableable checkbox is fixed —
offbox_enlarge_blockedis now a ONE-TIME persisted seed at settings Load (OffboxEnlargeNoticeSeeded), not a getter append, so a customer's later opt-out sticks. Part 0.5: the offsite restore scratch now prefers a LOCAL path over a network one (a squashed scratch would feedPlaceOffsiteRestorewrong-owner files). - Tests: the v2 suite (
tier2_v2_test.go: A–H + reconcile keep/remove + the safe-remove boundary proof), Part 0 seed tests (idempotent + opt-out-sticks), Part 0.5 scratch-preference tests; obsolete v1 flat-layout / N>1-refusal tests removed. All 10 §10 red-proofs verified (mutate → fail → revert).
v0.134.1 — Placement hardening (F-3a-1..4) + enlarge-blocked notification delivery chain (Task 3a-fix) (2026-07-15)
Follow-up hardening of the (not-yet-live) place-to-live flow from v0.134.0, plus the controller side
of the offbox_enlarge_blocked notification delivery chain (paired with hub v0.55.0). No new
architecture.
- F-3a-1a (
offbox_restore.goPlaceOffsiteRestore): the live target now resolves via the RAWGetStackHDDPath(mirrorsoffboxCaptureSet), NOTAppNamespaceRootwhosesystemDataPathfallback would have merged userdata onto the SSD system namespace. Empty HDD ⇒ undeployed ⇒ refuse ("a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat"). - F-3a-1b: placement headroom gate — refuse before any copy if
offboxFree(liveNs) < offboxSize(scratch)(a missing-only merge copies at most the scratch size). - F-3a-4: the src-existence check is now a
os.StatPRE-PASS over EVERY placement before the first copy — an incomplete scratch (e.g. a unit-only restore) refuses with ZERO copies, making the "no partial writes" guarantee true (was interleaved: the unit could be placed before the refusal). - F-3a-3 (
mapOffsiteRestorePaths): the escape check tightened to!HasPrefix(p, oldNs+"/")so the namespace root itself (p == oldNs) is refused instead of mapping to a junk placement. - F-3a-2: on FULL success the scratch is removed best-effort (logged);
OffboxFullScratchReadythen turns false so the place button disappears. A FAILED placement keeps the scratch for a retry. - Delivery chain (controller side):
settings.DefaultEnabledEventsgainsoffbox_enlarge_blocked;GetNotificationPrefsappend-if-absent migration surfaces it enabled for existing customers (idempotent — a customer couldn't have disabled a type that didn't exist), which the startup prefs sync (main.go:782) carries to the hub; the settings-page checkbox ("Távoli mentés — tárhelykeret-figyelmeztetés") + the handler's single-event slice both gain it (missing either half re-opens the checkbox-drop trap). Hub v0.55.0 allowlists the event; NOcustomerMessagesentry (the raw dynamic two-number message must survive —templates.go:129). - Tests: +5 placement (
offbox_place_test.go: undeployed/headroom/incomplete-pre-pass/lifecycle- mapping namespace-root) + 3 settings (
notif_migration_test.go: default-contains, migration idempotency, no-duplicate). All 6 controller §10 red-proofs verified (mutate → fail → revert).
- mapping namespace-root) + 3 settings (
v0.134.0 — Offsite tier policy engine: mandatory userdata, raw-data quota, restore rework (Task 3a) (2026-07-14)
Task 3a of the backup-classification-redesign arc — the FIRST behavior-changing task
(felhom.eu/documentation/architecture/07-backup-architecture.md §2/§6/§7/§9; restic mechanisms
proven in SPIKE-restic-snapshot-shape-2026-07-14.md). Offsite pushes now carry each app's
mandatory userdata, quota is measured as real Storage Box fill, retention survives the shape
change, and restore is reworked off the rootfs. BEHAVIOR CHANGE.
- Multi-path snapshot (§6): each toggled app's offsite push is now ONE restic snapshot =
recovery unit + the app's TierOffsite mandatory capture set (Task 3-core
ComputeCaptureSet). Optional/excluded never ship offsite. Legacy (no block) / undeployed apps stay unit-only, byte-identical to v0.133.0 (the SQ5 cost guard). Newoffbox_capture.go. - Loud capture gaps (SP-3.4): restic 0.14.0 does NOT error on a missing source path (exit 0,
silent partial snapshot), so a structurally-refused or on-disk-missing MANDATORY path is detected
BEFORE invocation (guard
Skippedlist +os.Statfilter) and surfaced in the English log and the HungarianLastWarning. A restic exit code never proves a path was captured. - Quota = raw-data (§9, SP-1):
offboxRecordStatsnow runsstats --mode raw-data --json(actual deduplicated+compressed repo bytes) instead of the modeless restore-size that multiplied by the retained-snapshot count. The displayed remote-backup size drops once after deploy — it now reflects the customer's true Storage Box fill. - Pre-push enlargement gate (§9, ruling #1): before an app's enlarged push, if last-known
raw-data repo bytes + the mandatory-set
duestimate would cross the soft quota, the ENLARGEMENT is blocked (config+DB unit-only push still proceeds — never a protection regression), the app is recorded inOffboxTarget.EnlargedBlocked,LastWarningnames it, and an edge-triggered notification fires once per new block (offbox_enlarge_blockedevent, warning severity). A per-app "config+DB only" note renders on /backups/remote. - Retention grouping (§6, SP-2): both
forgetcall sites gain--group-by host,tagsso an app's old unit-only-shape snapshots share a group with its enlarged shape and age out naturally (the default host,paths grouping would strand old-shape snapshots in a permanently-retained group). - Restore rework (§7, F-A1): new
offbox_restore.go. Scratch moves off the ~8 GB guest rootfs to a data drive (<nsRoot>/backups/offsite-restore/<app>) behind a headroom gate (full needs size×1.1, unit-only a 2 GiB floor; ID-firstsnapshots latest --tag→stats <ID>; size-unknown fails closed).RestoreOffboxScratch(full)— unit-only DEFAULT via--include <absolute-unit-path>(SP-3.2), full is a size-first two-step.PlaceOffsiteRestoreplaces a completed full scratch into live locations via a missing-only merge (rsync -a --ignore-existing, never--delete), unit only if the live unit is absent; the puremapOffsiteRestorePathsrefuses the whole placement on no-unit / escape / reserved-zone. Legacy rootfs scratch is cleaned best-effort. The oldRestoreOffbox(whole-snapshot to an explicit dest) is retained for existing callers. - UI (Hungarian): /backups/restore offers unit-only ("Visszaállítás ellenőrzéshez"), full
two-step ("Teljes visszaállítás előkészítése" → "…indítása (~méret)"), and place-to-live
("Helyreállítás az élő adatok közé (csak a hiányzó fájlok)"); /backups/remote shows the per-app
quota-blocked note. New route
POST /backup/offbox/place. - Settings:
OffboxTarget.EnlargedBlocked []string(replaced each OK run; preserved across a config edit). HUB FLAG: theoffbox_enlarge_blockedevent needs adding to the hub'sallowedEventTypes+customerMessagesfor delivery — until then the in-dashboardLastWarningand the /backups/remote note carry the message (see REPORT §flags). - Tests: +13 in
internal/backup/offbox_3a_test.go(Scenarios A–G + all-excluded, raw-data, both forget sites, restore argv, size-unknown refusal, scratch cleanup, place-to-live mapping); all 10 §10 red-proofs verified (mutation → fail → revert). No tier-2 / .fab / hub / agent changes.
v0.133.0 — Capture-set computation (INERT; Task 3-core) (2026-07-14)
Task 3-core of the backup-classification-redesign arc
(felhom.eu/documentation/architecture/07-backup-architecture.md §3; matrix §2; SP-1/2/3 spike
verdicts landed in SPIKE-restic-snapshot-shape-2026-07-14.md). Ships the pure capture-set
computation in the appbackup leaf package — deliberately INERT: NO backup tier changes
behavior. 3a (offsite policy engine) and 3b (tier-2 rework) are the consumers.
ComputeCaptureSet(binds, hasClassification, tier, hddPath) CaptureSet(internal/appbackup/captureset.go, new): turns an app'sClassifiedBindsinto a tier-filtered, structurally-guarded, containment-deduped absolute path set. Fixed pipeline (§8): legacy short-circuit → tier filter → structural guards → equal-Abs collapse (mandatory > optional) → containment dedup (keep ancestor) → sort by Abs.CaptureSet{HasClassification, Paths []CapturePath, Skipped []SkippedPath}; eachCapturePathcarries{Abs, Root, RelPath, Class}.- Tier columns (§2):
TierOffsite= mandatory only (optional never ships offsite);TierSecondary= mandatory + optional; excluded is silently dropped at every tier (never in Paths, never in Skipped). A legacy app (hasClassification=false) resolves NOTHING —{HasClassification:false}, nil Paths/Skipped — so the engines' no-block branch stays byte-identical (the SQ5 cost-regression guard: an unmigrated app never resolves a bind into an automatic tier). - Structural guards (security-shaped, load-bearing): the compose parser path.Cleans but does NOT
reject
.., andValidateBackupSpecvets only spec entries, so an unlisted writable${HDD_PATH}/../xbind arrives classed mandatory. Guards (run after the tier filter) move traversal (..segment / absolute), bare HDD drive-root (""— would nest<hddPath>/backups), and reservedbackups/zone captures intoSkippedwith distinct English reasons (a skipped mandatory = a capture GAP the engines log loudly). Bare userdata root is allowed (<hddPath>/userdata). Segment-wise..detection (a legita..bdir passes). CrossAppOverlaps(map[app]CaptureSet) []Overlap: pure §4.2 advisory — same absolute path in ≥2 apps' Paths (exact-Abs only; cross-app containment is legitimate and does NOT report). WARN wiring is deferred to 3a/3b by design — no log call sites here.- Purity: no
os/exec/filepath/logging; slash algebra (path.Join/path.Clean) throughout — resolved paths are in-container Linux paths, andfilepathon the Windows test host would flip separators and break containment prefix checks. - Docs alignment: architecture §3 sketch updated to the as-built API (
UnitOnly→HasClassification,Skippedadded), felhom.eu commit8d85da7. - Tests (all green):
internal/appbackup/captureset_test.go(Groups A–F: per-tier split, legacy inertness, excluded-invisible, structural guards + legita..b, containment/collapse/determinism, cross-app overlap) + the F-S3 no-seam wiring testinternal/stacks/captureset_wiring_test.go(Group G, real Manager → ClassifiedBinds → ComputeCaptureSet end-to-end). All 6 §10 red-proofs verified (mutation → fail → revert). No behavior change; no UI; no engine edits.
v0.132.0 — Backup classification: schema + parser + pure classifier (INERT; Task 2) (2026-07-14)
Task 2 of the backup-classification-redesign arc
(felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md). Ships the
referential-coupling classification as DATA + PARSER + PURE CLASSIFIER — deliberately inert: NO
backup tier changes behavior. Task 3 (tier policy engine) and Task 4 (manual .fab UI) are the
consumers; today only a validation log pass touches it.
- Schema (
internal/appbackup/classify.go, new):BackupSpec/BindSpecmodel the.felhom.ymlbackup:block (userdata:/hdd:lists of{path, class});ComposeBindis a${VAR}-relative host bind carrying the:roflag. Classes: mandatory (COUPLED — restore-without is broken-not-empty, SQ3), optional (DECOUPLED-precious), excluded (DECOUPLED-bulk/transient). - Pure classifier
ClassifyBinds: the SQ5 two-level default — an explicit block entry ALWAYS wins (an explicitoptionalon immich's:roexternal library beats the reader default); an unlisted writable bind defaults mandatory (the C6B-F1 capture direction, never silent-drop); an unlisted:robind defaults excluded (reader rule). ReturnshasClassification— a nil spec (no block) → every bind islegacywith NO class semantics, so an unmigrated app's behavior is byte-identical. - Validation
ValidateBackupSpec(whole-block-reject): ANY defect — unknown/empty class (a typoedclas:key leaves""), empty/absolute/../backslash/non-clean path, duplicate(root, path), or an entry matching NO compose bind (a stale/typoed path must not silently shift the real bind onto the mandatory default) — rejects the ENTIRE block with the first defect named. Never partial. - Parser
ParseComposeClassifiableBinds(internal/stacks): copies theParseComposeUserdataMountsscanner shape but stays in${VAR}-relative space and preserves:ro(why it does NOT reuseParseComposeHDDMounts, which resolves absolutes and drops the mode). Deduped on(root, relpath), first-occurrence:rowins; short-syntax only. - Integration:
MetadatagainsBackup *appbackup.BackupSpec;LoadMetadatais the SINGLE validation choke point (catalog listing, deployed-stack scan, and git-sync all flow through it, so a bad catalog push logs[ERROR] ... backup block rejected in <dir>: <reason>within one sync cycle and the app degrades to legacy).stacks.Manager.ClassifiedBinds+ a newStackDataProvider.GetStackClassifiedBindsseam (delegated bystackAdapter, nil-stubbed in every fake) exist so Task 3 consumes a wired, end-to-end-tested seam — the F-S3 lesson that wiring is where typos hide.
INERT by design: offsite/tier-2/.fab/deploy/sync are byte-identical — proven by the entire
pre-existing test suite staying green with zero test-logic edits (only mandated nil-stub methods
added to fakes). Recovery units already carry .felhom.yml and git-sync already whitelists it, so the
block propagates to deployed stacks + units with zero plumbing changes; no recovery-unit SchemaVersion
bump. The 13 catalog backup: blocks ship in the same app-catalog-felhom.eu change (this controller
must be live first so the parser validates them on first sync). +14 test functions (Groups A–E,
incl. a 9-case validation table); red-proofs RP-1..RP-4 all
confirmed (validation, explicit-beats-ro precedence, capture-default direction, LoadMetadata→validate
wiring). Controller-only; no MinAgent/hub coupling.
v0.131.0 — F-S2 + F-S3: compose-derived appdata dir resolution (paperless-ngx → appdata/paperless) (2026-07-14)
The controller assumed an app's HDD appdata dir is always appdata/<stackName>. paperless-ngx binds
${HDD_PATH}/appdata/paperless/... — stack paperless-ngx, dir paperless — so every consumer that
keyed by stack name silently missed it via a stat-and-skip. One canonical resolver
(appbackup.AppDataDirNames) now derives the real dir name(s) from the app's compose ${HDD_PATH}
binds, and all consumers use it. Task 1 of the backup-classification-redesign arc
(felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md), deliberately independent
of the classification schema.
- F-S2 (spike-proven live) — tier-2 backup/info/restore.
RunTier2now mirrors the resolvedappdata/<name>dir, so paperless documents get their off-drive copy (previously: the appdata leg'sos.Statgate skippedappdata/paperless-ngx, which never existed — silent, no copy).Tier2Info's size + the SSD-headroom guard use the resolved dir.RestoreTier2Filestargets the resolved live dir (was restoring into a wrong/emptyappdata/paperless-ngx). A[WARN]now fires when the compose DECLARES an appdata dir but it is absent on disk (the silence that hid F-S2). Every rsync leg goes through a newtier2Mirrorseam (prod behavior unchanged). - F-S3 (NEW, found this session) — scope="app" migration.
migrate.gokeyed all six per-app appdata legs (collision check, source-size, copy, verify, cleanup, skip-set) by stack name. For scope="app" there is no merge walk, so migrating paperless-ngx copied nothing, "verified" vacuously, flippedHDD_PATH, and the app came up with an empty media dir. (scope="all" was saved by the merge walk — data safe, accounting off.) All six legs now loop the resolved name(s); the copy leg WARNs on a missing declared dir. - Multi-dir refusal (defensive; no catalog app hits it today). An app resolving to N>1 distinct
appdata dirs is refused loudly by tier-2 backup/info (honest
no_targetstatus +"az alkalmazáshoz több adatkönyvtár tartozik — a 2. mentés jelenleg alkalmazásonként egy könyvtárat támogat") and tier-2 file-restore (refused BEFORE the app is stopped). Migrate supports N naturally. This limitation is lifted by the tier-policy engine (Task 3). - Display. The storage-detail page sums the resolved appdata dir(s), so paperless-ngx shows a non-empty size.
- Truth repair. The v0.130.0 entry below states "The scheduled/tier-2 backup path was NOT affected
(it copies the felhom-data namespace wholesale)" — that sentence is false and is left in place
only as the historical record it corrects here: tier-2 copies the recovery unit + the resolved
appdata/<name>dir(s) ONLY (never the userdata tree — F-S1, unaddressed here — and never the namespace wholesale). Themain.goexport-adapter comment that repeated the claim is fixed in code.
Scope guards: destination layout unchanged (<destBase>/appdata stays flat); no userdata copying at
any tier (F-S1 is the classification redesign's, not this task's); ExportDataMounts / .fab /
offbox untouched. Tests: +9 (resolver table incl. dedupe/foreign-drive/whole-root; RunTier2
paperless/legacy/multi-dir; Tier2Info + restore refusals + resolved live dir; scope="app" paperless
migration). Red-proofs RP-1..RP-5 all confirmed (resolver, RunTier2 leg, restore dst, migrate copy
leg, N>1 guard). Controller-only; no agent/hub coupling; MinAgent unchanged.
v0.130.0 — CRITICAL C6B-F1: hollow .fab export (three compounding defects) + C6B-F2 share-removal guard (2026-07-14)
CAMPAIGN-6B surfaced that .fab export produced a config-only, data-free bundle for 12/13
needs_hdd catalog apps, reported success, and passed the v0.125.0 anti-hollow guard (live: sonarr,
4.17 GB / 7 files → a 2308-byte bundle). Cross-box/fresh restore = silent total data loss. The
scheduled/tier-2 backup path was NOT affected (it copies the felhom-data namespace wholesale) and is
untouched. Controller-only.
- C6B-F1 cause 2 (discovery,
cmd/controller/main.goexportAdapter + newstacks.ExportDataMounts): the export adapter resolved only${HDD_PATH}binds (ParseComposeHDDMounts), never the standard${USERDATA_PATH}convention (<HDD_PATH>/userdata, injected at deploy) → 0 mounts → "no HDD mounts — skipping". Fix:stacks.ExportDataMountsunions the HDD binds with the userdata ROOT (one mount, basenameuserdata) when the compose binds${USERDATA_PATH}. Root-not-per-bind is deliberate: the manifest keys HDD tars by basename and the import maps a basename to a resolved mount or<HDD_PATH>/<basename>—userdataround-trips through the UNTOUCHED import exactly, while a per-binduserdata/media/tvwould base totvand restore to the wrong place (the task's literal per-bind union + namespaced tar names would have required import changes, which the task forbade — deviation documented in REPORT). Containment dedupe both directions. The backup-sidestackAdapteris intentionally unchanged. Also fixes the estimate'sdata=0 Bfor these apps. - C6B-F1 cause 1 (either/or,
appexport/export.goexecuteExport):needs_hddapps never ranexportVolumeData, silently dropping named volumes (sonarr_config = the whole app DB). Export is now ADDITIVE (HDD data AND volumes);EstimateExportcounts both so fits-on-dest stays honest. - C6B-F1 cause 3 (guard,
appexport/export.goassertBundleDataComplete): the claimed-tar checks pass trivially on 0 claims. New assertion: aneeds_hddmanifest with neither HDD data nor volume data fails the job ("a mentés nem tartalmaz alkalmazásadatot…") — a future discovery gap can never again ship a silent hollow bundle. - §8 latent collision (
appexport/export.goexportHDDData): two mounts sharing a basename used to silently overwrite the first tar; now a loud Hungarian failure (basename-keyed manifests cannot round-trip a collision; renaming would break the import mapping).exportHDDDatareturns error. - C6B-F2 (share-removal guard,
web/netstorage_handlers.go):POST /api/storage/netstorage/removenow refuses (409, names the apps) while a DEPLOYED stack's HDD_PATH is on the share — the live event removed campaign6 under a running sonarr and the agent's tolerated stop steps deleted the unit files under the busy mount, leaving an unreapable orphaned autofs mount until host reboot. The remove handler resolves the agent via the netAgent seam. Residual (out of scope, flagged for a felhom-agent task): the agent-side tolerate-and-continue stop inRemoveNetworkMount.
Tests (non-hollow, four red-proofs run→fail→revert): stacks/export_mounts_test.go (6 — union,
HDD-direct regression, mixed, covering-root, literal-userdata dedupe, empty; red-proof: pre-fix
HDD-only behavior fails 3), appexport/export_additive_test.go (5 — scenario A both-tars bundle,
scenario E volume-strand fails loud for needs_hdd, §8 collision loud-fail, scenario A' round-trip
placement to <HDD_PATH>/userdata, scenario D zero-data refusal; red-proofs: either/or revert fails
A, collision-check removal fails the collision test, guard removal fails D),
web/netstorage_remove_guard_test.go (2 — refused-while-deployed + proceeds-without; red-proof:
disabled guard returns the live removed:true).
v0.129.0 — CAMPAIGN-4 fixes: rate-limiter key (F-B) + volume-blind estimate (F-A) + no-op claim status (F-C) (2026-07-14)
Three controller-side fixes from CAMPAIGN-4 (2026-07-13). Controller-only.
- F-B (MED, security): the login/escrow-reauth rate-limiter keyed on
r.RemoteAddr(which isIP:PORT) wheneverX-Forwarded-Forwas absent, so every fresh direct connection from one host got a distinct ephemeral port → a distinct key → the failed-attempt counter never accrued. A direct-to-controller path (LAN/guest, bypassing the traefik/CF proxy) therefore had no brute-force protection. Fix: a single sharedclientIP(r)helper (XFF first-hop, elsenet.SplitHostPort(RemoteAddr)host, else raw) — replaces the formerrequestIPand the duplicated inline derivation inhandleLogin, so the escrow re-auth limiter shares the exact same fixed key. Accepted limitation (out of scope, commented): XFF is attacker-controlled on a direct path — the fix closes the port-in-key bug, not XFF trust. Red-proof: revertclientIPto rawRemoteAddr→ the distinct-ports scenario stops limiting and escrow re-auth stays 401 not 429. - F-A (MED, honesty): the export size-estimate's volume branch
du'd the raw host mountpoint fromdocker volume inspect, which is not mounted inside the containerized controller → returned 0, so a >1 GB volume-only app reporteddata_size_bytes:0/ "3.6 KB" /fits_on_dest:true. Fix: avolumeSizerseam whose real impl reads the size from a container view (docker run --rm -v <vol>:/vol:ro alpine du -sb /vol— the same named-volume pattern the export path uses; never a controller-host path, the v0.125.0 strand class). A failed read now setssize_unknownand forcesfits_on_dest:false(never renders as "fits") with the human string "ismeretlen méret". The export pre-flight hard-aborts only on a KNOWN doesn't-fit (an unmeasured size no longer blocks the export — the tar stream + destination FS surface a real ENOSPC). HDD-path branch unchanged. Red-proof: revert the estimate to the host-path read → the >1 GiB scenario reads 0. - F-C (LOW-MED, correctness): a no-op escrow claim (agent
phase:none→ HTTP 404) fell throughescrowClaimAPIHandlerto a generic 502. Fix: relay the agent's 404 as a clean 404 ("Nincs aktív helyreállítási folyamat…") and 409 as 409; 410 (void) and a genuinely-unreachable agent (status 0 → real bad gateway) are unchanged. Red-proof: remove the 404 mapping → the no-ceremony claim returns 502.
Tests (non-hollow, all red-proofed): ratelimit_ip_test.go (F-B: 6 — direct-distinct-ports,
stable-XFF, rotating-XFF, escrow-reauth-shared-key, success-clears, clientIP unit),
estimate_volsize_test.go (F-A: 3 — real-not-zero, failure-never-fits, HDD-unchanged),
TestEscrowClaim_ProxySemantics +3 subtests (F-C: 404-not-502, 409, unreachable-stays-502). Live:
Alpine busybox du -sb verified supported (prod-valid).
v0.128.1 — USB drives never show the rotational class hint (2026-07-13, ruling F5)
storage.html classTag(d): if(d.type==='usb') return ''; ahead of the class branches —
covers both render sites (card badges + metarow, the latter already guards on d.class).
Rationale: only Observe-sourced drives ever carried class, so the demo's legacy PVE
dir:-backed USB drives showed "lassú" while registry-sourced drives never did — a misleading
inconsistency, and the card already carries the USB type tag. Non-USB storages (e.g. a future
internal SATA data drive) keep the hint. The hub-report ClassHint field is UNCHANGED
(documented hint; UI-only suppression). Pinned by TestStorageTemplate_USBClassBadgeSuppressed
(red-proof: guard removed → FAIL "classTag USB guard missing"). Part 1 of the demo
storage-hygiene task (Part 2 = host-side pvesm remove of the two legacy dir storages —
operational, no repo change). Task was numbered v0.127.3 pre-sequencing; ships as v0.128.1
(0.127.3 + 0.128.0 already taken).
v0.128.0 — browser .fab upload on the import page: chunked, tunnel-proof (2026-07-13)
The Restore/import flow no longer requires copying .fab files to {tároló}/exports/ by hand
(the FileBrowser step alpha testers stumble on): /import now has a drag-and-drop/file-picker
upload zone. The binding constraint is the Cloudflare tunnel's request-body cap — step-0 probe
on the REAL tunnel (2026-07-13): 120 MiB POST → edge HTTP 413 from Server: cloudflare before
the origin saw it; 80 MiB → passed to the origin (302 /login) — so the client slices the file
(File.slice, 64 MiB chunks, strictly sequential) and the server appends each chunk to a
.part-<random> file in the DEFAULT drive's exports dir via io.Copy (no RAM proportional to
file size), then finalize fsyncs + atomically renames. The existing bundle scan + validation +
import pipeline take over untouched — the landing dir is exactly what isValidExportPath and
ScanForBundles already cover.
- Endpoints (inside
ServeExportAPI— inherits the main.goRequireAuth(CsrfProtect(...))mount, nothing added at the mux):POST /api/export/upload/{init,chunk,finalize,abort}. Single-flight (second init → 409). Init sanitizes the filename to a[A-Za-z0-9._ -]base name with a mandatory.fabsuffix and gates on free space (declared size + 1 GiB margin, Hungarian error with both numbers). Chunk offset MUST equal bytes received (mismatch → 409 +received_bytesso the client re-syncs one step); per-request body cap 96 MiB. Finalize requires the exact declared size (mismatch → 422,.partdeleted) and lands collisions on the lowest-free"name (N).fab"(a re-run finds its own prior(N)— never(1)(1)). - No client-side sha256 — deliberate: WebCrypto can't stream-hash multi-GB files; the
.fabformat self-validates at import. Transport integrity = sequential offsets + exact final size + the format's own validation. - Crash-safety: upload state is in-memory (a restart loses the
.part; the browser re-uploads). Startup GC removes*.part-*in every registered drive's exports dir; an upload idle ≥15 min is aborted server-side. - UI (
app_import.html): upload zone above the bundle list ("Fájl kiválasztása" / húzza ide), progress "Feltöltés: {pct}% ({done} / {total} GB)" + "Megszakítás"; on success the page reloads (the existing scan renders the new row). One retry per chunk on network error, re-synced from the 409 echo. - Reuse:
appexport.DiskFreeexported (wasdiskFree) for the space gate via theweb.uploadDiskFreetest seam. Scenarios §7 A–F tested; red-proofs run for the traversal sanitize, the out-of-order append and the collision overwrite (all FAILED pre-fix as required).
v0.127.3 — reveal copy states the shown code is ALREADY the live one (2026-07-13, Viktor)
The supersede happens at upload, inside the ceremony job — BEFORE the code is ever displayed. The reveal warning now says so explicitly: "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg." (was only the "nem jeleníthető meg" line). Pre-generation cancel already existed (the "Mégsem" next to "Kód létrehozása" — nothing runs until the primary button); the render test now pins it. The typed-back step stays (proof-of-capture friction + transcription-error catch; Viktor briefed).
v0.127.2 — wizard code hide is MANUAL-only (2026-07-13, Viktor's live finding on v0.127.1)
The v0.127.1 blur-on-verify auto-blurred the code the moment a verification input got focus —
which made typing the two words HARDER, since the customer types them from the screen. Reversed:
the code stays visible; the "Elrejtés"/"Megjelenítés" toggle appears with the reveal and is
manual-only (still useful for a screen share). Render test asserts no onfocus auto-blur remains.
v0.127.1 — escrow wizard polish: CTA visibility + Hungarian preflight + typed-back highlight + blur-on-verify (2026-07-13)
Four findings from Viktor's first supervised wizard passes (drill + demo). Presentation-only — no endpoint, agent call, or state change; §10 red-proofs N/A.
- Escrowed-card CTA (
backups_remote.html): "Új helyreállítási kód készítése" is a realbtn btn-sm btn-outlinesecondary button (was an inline link inside the muted hint — nearly invisible). Outline, NOT primary: escrowed is healthy, the CTA is available-not-urgent (the stale variant stays primary). Render-tested. - Hungarian preflight details (
backups_escrow.htmlpfDetail): the agent's operator-Englishdetailstrings no longer leak into the customer UI. OK rows keep only VALUE details (storage id, age path;staged_secret→ "előkészítve"); boolean-OK rows (dr_tier/hub_upload/sudo_grant) render no detail; not-OK rows get the Hungarian explanation + the raw agent detail as a muted diagnostic span; unknown ids fall back to the raw detail (never blank a failure);staged_secretkeeps its informational dot. Render test pins the not-staged copy + asserts the three English literals never appear in the page source. - Typed-back highlight: the code renders as span-per-word (createElement + textContent +
createTextNode ONLY — R still never flows through innerHTML; no parse context = no injection
surface); the two verification words get
var(--warn)+ 600 weight so they're findable on paper.verifyIdxis now chosen BEFORE rendering.finishWizard'stextContent=''clears the spans (child-node replacement — verified). - Blur-on-verify (Part 4, optional — implemented; trivial to strike): first focus on either
verification input blurs the code (
filter: blur(6px), inline-style toggle — no style.css change, no cache-bust) + abtn-ghost"Megjelenítés"/"Elrejtés" toggle. The typed-back now exercises the WRITTEN copy, not screen transcription. R stays in the JS closure.
JS behavior (spans/blur) is review-covered; the visual leg awaits Viktor's next login. All four UI gates green.
v0.127.0 — customer-facing escrow ceremony wizard + stale-blob re-check (2026-07-13) — MinAgent: 0.88.0 (wizard only; everything else unchanged)
The missing friend-alpha piece: the recovery-code ceremony moves from operator-SSH to a
customer-driveable wizard (/backup/escrow). R is displayed EXACTLY ONCE in the browser
(one-shot claim, typed-back confirm); operator ruling F1 2026-07-13 accepts the single CF-tunnel
transit (same trust class as the claim code — threat model in felhom.eu
RUNBOOK-escrow-ceremony.md). Mechanics validated by SPIKE-controller-escrow-2026-07-13.
- Wizard (
templates/backups_escrow.html+web/escrow_handlers.go): preflight checklist → warning copy (re-ceremony adds the supersede warning) → password re-auth (rides the LOGIN rate limiter) → run (poll 2 s) → one-shot reveal ("Ez a kód többé nem jeleníthető meg.") → typed-back (two random words, client-side only — R never leaves the page's JS scope; no copy-to-clipboard by design) → finish. Void/expired → the honest "újra nem kérhető le" state. Page + claim responseCache-Control: no-store; R is NEVER templated server-side, logged, or persisted. - Start-handler order (load-bearing): re-auth → re-stage-first (offbox configured →
PushOffboxPasswordForEscrow; failure ABORTS — a ceremony without the staged secret mints the forbidden hash-less blob) → agent version gate (AgentVersion()≥ 0.88.0, header absent = older, fail-closed) → trigger. Every refusal exits with the agent untouched (seam-asserted). - agentapi (
agentapi/escrow.go):EscrowPreflight/EscrowCeremonyStart/EscrowCeremonyStatus/EscrowCeremonyClaim(status-aware; 410 = void; claim body never logged) over the existing envelope helpers. - Scenario F — stale-blob re-check (
report/escrow_confirm.go):Reconcileno longer early-returns on non-pending; an ESCROWED box compares the ACK hash every cycle — mismatch OR a present blob with an EMPTY hash (the spike's hash-less supersession) sets an in-memory stale flag (surfaced on the Távoli mentés card: "A letétben lévő helyreállítási csomag nem fedi a jelenlegi távoli mentési jelszót") + ONE warn per distinct hub hash (warnedHashreuse; hash-less dedupes under a sentinel). State NEVER flips; runs NEVER block; a matching hash (or a fresh auto-confirm) clears the flag. NOTE: the live demo's legacy hash-less blob will show this warning honestly — the wizard is the fix. - Card rework (
templates/backups_remote.html): the deprecated manual-confirm BUTTON is gone (the endpoint stays for legacy blobs); states: pending → "Helyreállítási kód szükséges" + CTA; escrowed+stale → warning + "Új helyreállítási kód készítése"; escrowed clean → secondary link; agent < 0.88.0 → "az ügynök frissítése szükséges" note, no CTA. - Tests: call-order (stage BEFORE trigger, from pending AND escrowed), Scenario C no-stage, security gates (wrong password 401 + rate-limit counter, 429 lockout, passwordless 403, stage failure 502 pre-trigger, old agent 409, busy 409 — agent seam call-count 0 in each), claim proxy no-store + 410, §8 stale truth table incl. dedupe + clear, template render states. §10 red-proofs demonstrated (felhom.eu REPORT).
docs — controller.yaml.example: hub api_key literal scrubbed (2026-07-13)
The example carried the REAL hub global bearer key (the manifests/hub.yaml committed literal,
rotation-flagged in two publish runbooks). Replaced with a placeholder — real deployments get a
hub-issued per-customer key baked by configgen; the example was never a live consumer. Part of
the hub v0.53.0 bearer de-git (felhom.eu); the value itself dies with the supervised rotation
(documentation/runbooks/secrets.md §"Operator/global bearer key" in felhom.eu). No code change,
no version bump.
v0.126.4 — edge-safe error statuses + the native-alert ban (2026-07-13)
Two defects surfaced by the agent-0.87.0 wizard leg's decommission attempt (the M1 refusal — correct policy — reached the operator as a JSON SyntaxError popup):
- 502/504 never leave the origin: Cloudflare replaces origin 502/504 bodies with its own
HTML error page, so every
writeDiskJSON(StatusBadGateway…)refusal/error rendered as "<!DOCTYPE … is not valid JSON" in the browser.writeDiskJSONnow maps 502/504 → 500 at the single choke point (JSON body crosses the edge intact); the M1 last-usable-drive refusal became the typederrLastUsableDrivesentinel → 409 (policy verdict, not gateway failure). Unit tests + red-proofs for both. - Native
alert()banned (the F-11 OS-modal class, now complete): the decommission error path'salert()froze browser automation exactly as F-11 predicted. All 29 nativealert(calls across 5 templates swept to the existingshowAlertmodal (layout.html);native_confirm_gate.pyextended to banalert(alongside confirm/prompt.
v0.126.3 — storage wizard on a CLAIMED box: the init/attach POST no longer dies on CSRF (2026-07-13)
First live hit during the agent-0.87.0 drill wizard leg: /api/storage/init → "CSRF token missing
or invalid" (log: token mismatch). Root cause: storageWizardPageHandler rendered via raw
render() instead of executeTemplate(), so /storage/init + /storage/attach shipped an EMPTY
csrf-meta token — and the wizard's fetch() posts that token. LATENT until the claim arc: an
unclaimed box skips CsrfProtect entirely, so the wizard had never run against a password-gated
box before. Fix: executeTemplate (CSRF auto-injection); regression test renders both wizard
pages with a real session and asserts the meta carries the SESSION token (red-proven: swap back
to render() → both cases fail on the empty meta).
v0.126.2 — stylesheet cache-bust (2026-07-13)
0.126.1 live QA: Cloudflare edge-caches /static/style.css for 4h (Cf-Cache-Status: HIT), so
every controller release kept serving the PREVIOUS release's CSS to customers until TTL. The
stylesheet link now carries ?v={{.Version}} — busts automatically on every release (the same
gotcha class as the hub v0.47.0 /style.css finding, now extinct on the controller too).
v0.126.1 — .form-input/.form-row finally have CSS (2026-07-13)
Live QA on 0.126.0 (drill box) showed the .fab password field STILL browser-default: the
.form-input/.form-row classes used across the backups/import/offbox templates had NO
backing rule in style.css at all (only the .form-control twin was styled) — the actual root
cause of the operator's "unstyled clipped placeholder" screenshot. Added the missing rules
(same visual spec as .form-control; label-over-field rows). Presentation only.
v0.126.0 — UI uniformity bundle: shared app-list rows, infra-app metadata, restore-form polish, mojibake gate (2026-07-13)
Operator review (2026-07-13 screenshots): app lists looked designed three different ways across four+ surfaces; infra stacks rendered as bare names; the .fab password input clipped its placeholder; the zero-toggle run-warning went stale. Controller-only, presentation-layer — NO backup/engine/toggle behavior change (render tests assert the action markup is untouched).
- Part A — ONE row grammar, four surfaces:
templates/app_row.htmldefinesapp_list_row/app_list_row_end(the layout_start/_end idiom): icon + name (+ optional one-line secondary) left, caller action block right; compact 44px row. Applied to the Távoli mentés toggle list, the Visszaállítás restore-to-verify + .fab lists and the dashboard Telepített alkalmazások (state edge + data-href preserved); the Alkalmazások collapsed headers ALIGNED to the grammar (icon+name left, status dot moved right before the chevron; expander untouched — the one allowlisted aligned copy). funcmap:dict+appHref;OffboxAppRow/AppBackupRowgainSlug. Old.stack-card/.storage-path-itemrow CSS+markup retired. NEW gatescripts/app_row_dedup_gate.py— row markup single-sourced (red-proven: pasted an old row block back → exit 1). - Part B — infra stacks carry identity:
inframeta.gostatic display-only map — cloudflared → „Cloudflare Tunnel", traefik → „Traefik", filebrowser → „FileBrowser", each with curated Hungarian description; dashboard rows + app cards show icon + description + the existing Védett chip. Generic embedded/static/infra-logo.svgas icon fallback. filebrowser is the ONLY Linked stack (files. Megnyitás); guarded WRONG outcome — no customer link for cloudflared/traefik (render test counts exactly one https:// link; red-proven by flipping Linked on cloudflared → FAIL). - Part C — restore-form polish: the .fab encryption input is a standard form field —
placeholder „Opcionális jelszó" + helper under the field („Üresen hagyva a csomag titkosítás
nélkül készül."); the import-page bundle-password input picks up
.form-input(was a bare browser default). - Part D — mojibake fixed-by-construction: byte-level sweep found ZERO double-encoded
literals in the committed source — the live „Tárhely"-class text on the import page is the
felhom-usb DRIVE-LABEL DATA (settings.json), repaired via the label-edit UI during live
validation. NEW gate
scripts/mojibake_gate.py(Python per the multibyte rule): all templates + Go sources must strict-UTF-8-decode and contain none of Ã Â Ă ă ˘ ˇ; allowlist ZERO. Red-proven (reintroduced „Tárhely" → exit 1 naming file:line). - Part E — the stale zero-toggle line tells the truth:
offboxWarningDisplaydisplay pick (no state mutation): a persisted „nincs mentésre jelölt alkalmazás" run-warning is replaced by „A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza." once ≥1 app is toggled (rendered neutral — reassurance, not deviation); 0 toggled keeps the v0.123.0 line verbatim; quota/partial warnings pass through. Red-proven at unit + render level. - Gate housekeeping (first commit):
scripts/backups_split_move_check.pyretired — the one-shot v0.124.0 migration gate served its purpose (it pinned the split to verbatim moves vsdf7ad37); this release legitimately rewrites those blocks onto the shared row partial. All other template gates stay mandatory (template_id, emoji, native_confirm, offbox_rename, docker_run_volume_path + the two new ones).
v0.125.0 — .fab volume export/import: containerized path-strand data loss FIXED (IA finding 1, HIGH) (2026-07-13) — MinAgent: 0.81.0
Both .fab volume legs streamed via docker run -v <controller-temp-path> host mounts — correct
on bare metal, silently wrong under the golden containerized deployment (the daemon resolves the
-v host side against the GUEST filesystem): the export's tar stranded host-side while the
bundle shipped an EMPTY data/volumes and reported SUCCESS; the import then wiped the app's
volumes and populated them from host-side emptiness. Live-hit on demo ActualBudget (v0.124.0
validation). No .fab format change — but note the ASYMMETRY: any bundle exported by a
containerized controller ≤0.124.0 is suspect (hollow volume data) — re-export; the new
import-side guard refuses such bundles loudly instead of destroying the app.
docker cptar-streaming both legs (appexport/export.go+restore.go, newdockerExecseam): a stopped helper container pins the volume (docker create -v <vol>:/vol alpine true), the tar streams over the docker API (docker cp <cid>:/vol/. -out;docker cp - <cid>:/volin) — ZERO shared paths, correct in both deployment shapes. §3 live probe proved content, subdirs, symlinks, empty files and uid/gid round-trip. Helpers are ALWAYS force-removed, error paths included (test-asserted); 10-min/volume timeouts + truncated stderr preserved.- Export can no longer lie (scenario B): a failed volume export is FATAL (was WARN+continue);
assertBundleDataCompleterefuses to package any bundle whose manifest claims a tar that is missing/empty (volumes AND HDD subdirs — the HDD leg also stopped pre-claiming subdirs before the tar succeeds). Live-proven: an engine-invalid volume name failed the export naming the volume, no bundle staged. - Import validates BEFORE it destroys (scenario C):
validateBundleDatarefuses a claimed-but-absent/empty data tar in step 0 — before the app is stopped and before any volume is removed (the pre-fix order wiped first and discovered later); the refusal names the hollow ≤0.124.0-exporter cause and states the app is untouched.restoreVolumeData/restoreHDDDatamissing-tar soft-skips became hard errors (defense in depth). - The class is extinct (scenario D):
scripts/docker_run_volume_path_gate.py— every"-v"argument in non-test Go code must be allowlisted with its WHY; the Tier-1/2 volume dump/restore entries are documented host-visible (registered-drive namespace paths under the golden deployment's identical/mnt+/opt/dockerbinds), the rest are named-volume/flag usages. - Red-proofs: assertion removed → hollow-success test fails; pre-flight disabled → the
zero-destruction assertions fail (
removedVolumes=1); a violating-vline → gate exits 1. - Live §13: supervised repeat of the exact failed leg on demo — export → download (bundle
now carries the 66048-byte volume tar) → drive placement → import → volume fingerprint
byte-identical (
ec8ea6cb…before == after), app healthy, zero leaked helpers. - NOTE for the next publish train: the golden floor must not advance past 0.124.0 without this fix; floor may advance to 0.125.0 now that it validates.
v0.124.0 — backups IA restructure: four sub-pages, Felhom-offsite status card, .fab browser download (2026-07-13) — MinAgent: 0.81.0
The nine-section backups page split into four sub-pages (operator review: customers got lost); plus the offsite status card and the .fab portability exit. Controller-only; floor untouched. Operator decisions recorded in CONTEXT: single active offsite destination stands; the status card never changes state; .fab is portability, NOT a backup tier.
- IA split (
backups{,_remote,_apps,_restore}.html+backups_shared.htmlpartials): Áttekintés/backups(storage overview, Rendszermentés, stat cards, single-copy warning), Távoli mentés/backups/remote(status card + toggles + manual-target form,#offbox-sectionanchor), Alkalmazások/backups/apps(schedule, Adatbázisok, per-app 1./2./3. rows), Visszaállítás/backups/restore(restore panel, the RELOCATED offbox restore-to-verify, the .fab loop). Sections MOVED verbatim —scripts/backups_split_move_check.pycompares all 15 blocks against the v0.123.0 baseline (red-proven). Shared data builder extracted (backupsCommonData+backupsOffboxData) — no duplicated computation. Old links survive:/backups= Áttekintés; tier-3 row actions →/backups/remote#offbox-section; offbox/restore/tier2 flash redirects + the tier2-config back-link retargeted per page. - Felhom-offsite status card (top of Távoli mentés; local data only, DISPLAY-ONLY — no form, no button, unit-enforced): (1) no target → "Felhom offsite tárhely — igényelhető szolgáltatás. …Érdeklődj az üzemeltetőnél."; (2) applied + zero toggled → "Aktív — nincs kijelölt alkalmazás" (+ the v0.123.0 hint below); (3) applied + toggled → no card (the status block is the state). States 2 and 3 live-proven on the drill box.
- .fab browser download (
handler_export_download.go): the EXISTING async export pipeline with dest = a staging dir under the data dir (fab-downloads/; same producer → byte-identical bundle), estimate shown BEFORE start, then a guarded streaming exit (GET /api/export/download?file=— basename-shape + dir-containment guard, red-proven against prefix-only matching;io.Copy,Content-Disposition: attachment, post-stream removal, 1h TTL sweep on startup + each start). Batch UI downloads apps ONE AT A TIME (no mega-zip). Import stays drive-scan; portability copy on the section ("Hordozható pillanatfelvétel…"). Unit round-trip: real export → import → content equality; a corrupted bundle is REFUSED (gzip CRC — there is no per-file checksum; documented). - FINDINGS from the live §13 run (both pre-existing, recorded for follow-up tasks):
(HIGH) containerized-controller .fab export of Docker-VOLUME apps strands the volume tar on
the GUEST host (
docker run -v <container-tmp>:/outresolves against the host FS) → the bundle ships an EMPTYdata/volumes, export reports success, import brings the app up EMPTY. HDD-data apps unaffected. Live-hit on demo (ActualBudget; data restored from the stranded tar by hand). (MEDIUM) agent-side: on a legacy-boot PVE with LVM root,SystemDisksresolves NO raw system disk →sysKnown=false→ every disk classified system → the drive wizard can never offer a candidate (drill box; hot-added disk invisible).
v0.123.0 — polish batch: F-15 instant reset codes, F-11 inline confirms, Tier-3 "Távoli mentés" rename, zero-toggle honesty (2026-07-13) — MinAgent: 0.81.0
Four independent fixes from the take-two drill + operator review. Live-validated on the drill box (qm 300 guest 9201) and demo 9201; hub v0.52.0 is the F-15 counterpart (an old hub's bare reset-request response is a clean no-op — no coupling gate needed).
- F-15 instant reset codes (
internal/web/claim.go):requestHubResetCodenow parses the hub's reset-request RESPONSE ({claim: {code_hash, generation, issued_at}}, hub ≥0.52.0) and applies it through the SAME generation-guarded consumer as the report ACK (report.ClaimSync.Reconcile) — the emailed code works the moment it lands instead of after the next ACK (~15 min; the take-two "Hibás vagy lejárt kód" failure). Replay/older-generation responses can never downgrade the active hash (guard reused, not reimplemented). Live re-run of the exact failure path: code applied 1 s after the request, accepted on first try. - F-11 inline confirms (
layout.html+ templates): every nativeconfirm()(an OS-modal that freezes browser automation) replaced by the LIGHT inline two-step —felhomConfirm(el, q, onYes)swaps the trigger in place to "kérdés + Igen/Mégse"; form buttons opt in viadata-confirm="…"(submitted withrequestSubmit, so formaction/name-value survive). Converted: offbox restore-to-verify, tier2 restore, whole-guest backup, app data migrate, debug simulate-disconnect + DR trigger, deploy stale-data delete (keeps its DOUBLE acknowledgement, chained inline). New gatescripts/native_confirm_gate.py(zero native confirm/prompt in templates; red-proven). - Tier-3 rename (
backups.html,offbox_handlers.go,backup/offbox.go): customer-facing "NAS-mentés" branding → "Távoli mentés" (the productized target is the Storage Box; the tier concept is offsite). Manual-target form generalized to any SFTP target ("Cél címe (IP vagy hosztnév — NAS vagy SFTP-kiszolgáló)", "Tároló útvonala a célgépen"). The "Hálózati tárhely" NAS network-storage feature keeps its device-truthful wording (different feature). Python sweep (multibyte rule) + committed gatescripts/offbox_rename_gate.py(red-proven). - Zero-toggle honesty (
backup/offbox.go,handlers.go,backups.html): a configured + escrowed offbox with ZERO toggled apps shows "Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet." on the toggle list, and a run in that state reports "Sikeres — nincs mentésre jelölt alkalmazás" via LastWarning instead of bare success (red-proven unit test). - Dev-box fix:
atomicPromoteTarfsyncs via an O_RDWR handle — read-only fsync is refused on Windows, which kept the two F7 atomic-dump tests permanently red on the dev box (Linux behavior unchanged).
v0.122.0 — customer-claim password gate (closes DRILL-day0-vm F-4/F-5) (2026-07-12) — MinAgent: 0.81.0
The customer sets + OWNS the dashboard password; the old "no password → open dashboard" is gone.
An unclaimed box (hub-delivered claim-code hash present, no password) serves ONLY the claim page
— every other route answers the claim page (302 → /claim) or 401 (API), so a Day-0 box is
never open on the public internet (closes F-4; F-5's unauthenticated geo toggle closes with it).
Requires the hub's v0.50.0 claim engine (code generation + email + ACK/config delivery).
internal/web/claim.go— the gate + pages.claimGateActive()(no password + code hash + not claimed),effectiveClaimCode()(ACK-cached settings beats the config bake by generation), the claim page (GET /claim), submit (POST /claim: verify code → set own password → claimed → consume generation → session), and "kérj új kódot / Elfelejtett jelszó" (POST /claim/request-new-code→ hubreset-request). Code checks: bcrypt match AND generation not yet consumed (single-use) AND ≤ 72 h old. Per-source + global brute-force limiter (5 tries → 15-min lockout, fake-clock tested); a lockout raises the allowlistedclaim_lockoutevent. Pre-auth CSRF is an HMAC overweb.session_secret(closes the CTRL-007 bare-double-submit weakness), min password length 12.- Gate wiring (
auth.go,csrf.go,server.go,cmd): the gate sits atopRequireAuth; a SET password disables it entirely (password auth wins — claimed boxes never regress)./claim*/static/*stay reachable pre-auth (the code is the strong factor). Legacy-open (no password, no hash) passes through with a red transition banner (layout.html) until the hub delivers a hash. Login page gains an "Elfelejtett jelszó" link.
internal/report/claim_sync.go— caches the ACK'sclaim{hash, generation} into settings.json IDEMPOTENTLY BY GENERATION (offsite-descriptor one-way shape: newer generation advances; same/older/nil never rewrites, a hub outage never clears). The report carriesclaimed(set-only hub-side).config.web.claim_code_*baked by the hub gates from first boot.internal/settings—Claimed(set-only),ClaimCode*cache,ClaimConsumedGeneration(single-use).--print-reset-coderoot escape hatch: prints a one-time local code (a generation above cached/baked/consumed), the same gate consumes it.- Tests: gate-coverage signature test (every route → claim/401, a deploy POST mutates nothing) + happy-path/reuse-refused/expired/lockout+window-reopen; four §10 red-proofs proven (mutate→FAIL→revert): gate skip-line, single-use generation (hub + controller), reset non-DoS, rate-limiter.
v0.121.0 — backups page truth pass (dead sections removed, real Tier-3 state, SQLite-honest DB) (2026-07-12) — MinAgent: 0.81.0
Pure UI/data-plumbing on /backups; no backup-engine behavior change, no agent-API change, MinAgent
UNCHANGED (0.81.0). The live demo page (v0.120.0) contradicted itself — a dead "Részletek" card
claimed "Nincs 2. szintű mentés konfigurálva" while six Tier-2 runs showed above it; every per-app
"3. mentés" row said "Hamarosan — B2/S3/SFTP" while the off-box (Storage Box) tier was live with 6
snapshots; and an embedded-SQLite-only box rendered "Adatbázis mentve: 0" / "Nem található adatbázis
mentés." as if backups were failing.
- Dead "Részletek" card removed (operator-approved as redundant — per-app rows + the Adatbázisok
section already carry the truth). This deletes the last references to the never-set template fields
Tier2DriveGroupsandResticPassword, therestic-pwelement, and thetoggleTier/toggleResticPw/copyResticPwJS. - Per-app "3. mentés" row now shows real off-box state — a four-state row driven by a new pure
tier3State(configured → toggle → escrow precedence):unconfigured("Nincs beállítva" + Beállítás link),off("Kikapcsolva" + Bekapcsolás link →#offbox-section),escrow_pending("Kulcsletétre vár" — never a false success while the fork-4 escrow gate holds),active(status badge from the global off-boxLastStatus,restic → <host>, relative last-run). The "hamarosan" placeholder is gone everywhere. - SQLite-honest DB messaging — a new pure
dbSectionState(discovered, dumps)picksdumps/pending/embedded. Embedded-only boxes now render "–" + "beágyazott DB-k a kötetmentésben" on the stat card and an explanation ("…beágyazott adatbázist használnak (pl. SQLite)…") instead of a bare "0" / "Nem található adatbázis mentés."; a discovered-but-not-yet-dumped box shows "…az első ütemezett mentés éjjel fut le." - Three dead/raw display fields fixed —
Tier1LastRun/Tier1LastStatus(previously never assigned) are now populated fromListRestorePoints(newest recovery-unit artifact time; correct per-drive resolution — no fabricated time for unit-less apps); Tier-1 and Tier-2 last-run labels now render viatimeAgoStr(relative time) instead of raw RFC3339 (the restore confirm() dialog keeps the precise timestamp by design). - Terminology split — the off-box section is retitled "Távoli mentés (3. mentés) — titkosított,
offsite" (with an
#offbox-sectionanchor); the whole-guest PBS stat card is relabeled "Távoli rendszermentés" so two different features no longer share one customer-facing name on one page. - Deploy page — the app backup card gains a "Mentési beállítások →" link to the tier-2 config panel.
- Tests: +9 in
internal/web(puredbSectionState/tier3Statetruth tables;buildAppBackupRowsoff-box mapping + escrow-pending precedence + Tier-1-from-restore-points wiring; template renders for all four Tier-3 states, embedded/pending DB messaging, and relative-time formatting). Four companion red-proofs run→fail→revert (re-insert "hamarosan"; hardcode OffboxEnabled=false; dbSectionState ignores discovered; drop the Tier1LastRun assignment).
v0.120.0 — dead-app alerting (fix-3) + debug-ring revision (fix-6) — CLOSES CAMPAIGN-3 (2026-07-12) — MinAgent: 0.81.0
The last CAMPAIGN-3 findings (felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md). MinAgent
UNCHANGED (0.81.0). Pairs with hub v0.48.0 (accepts the new app_start_failed event).
- fix-3 (MED) — a dead deployed app is LOUD, not silent. The campaign's CWA sat dead 4 h with no
signal; F11 then produced 4 silently-dead NAS apps per reboot. A new
deadapp-checkjob (every 30 s) scansstackMgr.GetStacks(): a DEPLOYED app whose containers arestopped/exited(stacks.IsDownState;created/deadmap tostopped— the F11 dead-at-boot case) raises a state-based WARN dashboard banner ("Telepített alkalmazás nem fut: "; grouped above 3 to survive a reboot storm) that SELF-CLEARS the moment the app runs again, AND fires anapp_start_failedhub event ONCE per running→down transition (Notifier.NotifyAppStartFailurestracks per-app state — the hub owns the real cooldown; the controller adds no timer and does not spam). A 90 s boot grace skips the controller's own startup settle so apps that legitimately take 30–60 s to come up don't false-alarm; after the grace an app that never came up STILL fires (the whole point). - fix-6 (MED) — the post-incident window survives. The 1000-entry ring wrapped in ~6.5 min under
the campaign's load and died on every restart. Three changes: (a) cap 1000→5000 (viewer +
Entries/handler display cap raised to match — a larger ring is useless if unreadable); (b) periodic-noise policy — the every-cycle scheduler "job finished" +refreshStatusLockedsuccess lines are demoted to a new[TRACE]level the ring DROPS at write-time (failures/transitions are never TRACE, so nothing is lost); (c) spill persistence —LogBuffer.SpillTo/LoadFromatomically (tmp+rename, JSON-lines) spill the ring to<DataDir>/debug-ring.logon the SSD state dir (NEVER a NAS path) every 30 s and on clean shutdown, loading it back on boot so a restart / container recreation preserves the pre-restart window. Corruption-safe (a truncated line is skipped, never fatal). - Live-validated (demo 9201 + hub): fix-3 —
docker stop seerr→ the dashboard banner "Telepített alkalmazás nem fut: Jellyseerr (stopped)" appeared AND the hub received exactly ONEapp_start_failedevent across 3 down-cycles (anti-spam);docker start→ banner self-cleared. fix-6 — the ring showed 0 periodic-spam lines; a controller restart PRESERVED the pre-restart window (oldest entry unchanged across the restart; 63 KB spill on the persistent SSD volume). Tests incl. the fix-3 silent-regression + one-event-per-transition red-proofs, the fix-6 TRACE-drop-keeps-failure- corrupt-spill-safe red-proofs, all green.
v0.119.0 — storage-health coherence (F8) + mapped_uid validation (F4) (2026-07-12) — MinAgent: 0.81.0
Fixes CAMPAIGN-3 (felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md) storage-UI findings.
MinAgent UNCHANGED (0.81.0) — controller-only; the §3 design fork took the recommended option B
(reuse the shipped v0.117.0 classifier), so no agent change.
- F8 (MED) — one classification, two surfaces. The share row's health used to come only from the
agent's SERVER-LEVEL TCP dial (
server:2049/445), which stays green when a single export isexportfs -u'd — so the row showed benign "Készenlét" while the stacks/dashboard already showed the stub reality.networkStorageItemsnow FUSES the agent view with the consuming-namespace classification (fuseNetHealth→ the samesystem.ClassifyPathFSthe stacks stub badge reads): a newstubhealth state wins over a benign idle/ok when the namespace sees local disk atWhere; a whole-serverunreachablestill wins over stub; autofs-healthy / network / inconclusiveunknownleave the agent health untouched (never manufacture a fault, never force-mount an idle trigger). The row badge forstub= "Hibás — az alkalmazások nem a NAS-t látják". The row and the stacks/dashboard badge now derive from ONE classification and can never contradict. - F4 (LOW) — mapped_uid/gid validated at the door.
handleNetStorageAddrange-checks the container uid/gid (1..65533) after the<=0default, BEFORE the job starts. Out of range → an immediate, friendly Hungarian 400 ("Az alkalmazás felhasználói azonosítója (uid) érvénytelen…"), nothing installed — the campaign'smapped_uid:101000(a host-side mapped value) previously slipped past the controller and failed only at the agent with a rawagent_error. - Live-validated (demo 9201): F8 —
exportfs -uwhile idle + drop-mount → the share row flipped tostub/"Hibás — az alkalmazások nem a NAS-t látják" AND the stacks stub badge showed (4), the two surfaces AGREE; re-export → row cleared took/"Elérhető" (healthy idle NOT downgraded). F4 —mapped_uid:101000→ 400 + friendly message, registry unchanged;mapped_uid:1000passed the range check. Tests incl. the F8 fusion companion (revert → row idle → fail), the autofs-not-stub guard, and the F4 boundary (65533 pass / 65534 fail), all green.
v0.118.0 — backup integrity: atomic volume dumps (F7) + no single-copy (F6) + stale-primary sweep (F5) (2026-07-12) — MinAgent: 0.81.0
Fixes CAMPAIGN-3 (felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md) backup findings. MinAgent
UNCHANGED (0.81.0) — all changes are controller-local; no new agent API consumed.
- F7 (HIGH) — atomic volume dumps.
backup.DumpAppVolumesnow writes the tar to<vol>.tar.tmp, fsyncs it, and only atomicallyos.Renames it over the restore point on success — the same crash-safe pattern the DB-dump path already uses (appbackup/dbdump.goDumpOne), extended with a best-effort directory fsync. Before this, tar wrote the.tarIN PLACE, so a mid-write NFS cut left a 0-byte tar REPLACING the last good dump (tier-1 restore is replace-semantics → an empty volume). Now any tar error / timeout / dead-NFS EIO removes ONLY the.tmp; the last good.taris byte-untouched. The.tar.tmpname (ends.tmp, not.tar) is invisible to the restore-point/stale scans; orphan.tar.tmpfrom a killed run is swept. NewtarVolumetest seam. - F6 (LOW) — no single-copy backups. Volume-only apps (no HDD_PATH, backups on sys_drive) now flow
through the tier-2 cross-drive copy (
RunAllTier2no longer skips non-HDD apps) — a second copy on the secondary drive (the 3-2-1 intent). Their restore-point drive label is no longer blank (clear "Belső SSD (rendszer)"). A single-drive box (no off-drive target) surfaces an HONESTSingleCopyWarningbanner on the backup page instead of implying a 3-2-1 guarantee it cannot keep. - F5 (LOW) — stale primary-dir sweep. After each backup cycle,
pruneStalePrimaryDirsremoves an orphanedbackups/primary/<app>dir an app left on an OLD drive when its HDD_PATH moved (invisible disk residue). LOAD-BEARING GUARDS: removes only when the app is deployed AND its current namespace root differs from the dir's drive; NEVER touches the app's current-drive dir (the live restore point) or an undeployed app's dir; only ever operates strictly under abackups/primary/prefix. - Part 4 (operator fork) — backup-target locality: option A (keep locality), document-only. NAS apps' tier-1 artifacts stay beside the data on the NAS; tier-2's cross-drive copy is the off-NAS leg. Documented plainly (backup feature doc) so the NAS-outage window is never a surprise; no code change (option B, retarget-to-local, was not selected).
- Live-validated (demo 9201): F7 money-shot — a mid-write
exportfs -uduring a volume dump left all 5 nas-media volume tars BYTE-IDENTICAL (sha unchanged), no 0-byte, no leftover.tar.tmp, runsuccess:false; next run produced fresh good tars. F6 — actualbudget/seerr now on felhom-usb/secondary. F5 — a seeded stale dir on the wrong drive swept, current dirs kept. Restore round-trip byte-identical. Tests incl. the F7 truncation red-proof + F5 guard red-proofs, all green.
v0.117.0 — consuming-namespace NAS verification + deploy-view truth (RCA fixes 2+4) (2026-07-11) — MinAgent: 0.81.0
Controller half of the RCA fix pair (agent v0.84.0 ReassertNetworkMounts). Source:
felhom.eu/documentation/audits/AUDIT-nas-cwa-rca-2026-07-11.md. MinAgent UNCHANGED (0.81.0) —
every new check is controller-namespace-local; no new agent API is consumed.
internal/system/fsclass*.go— statfs f_type classifier for THIS process's namespace:network(NFS 0x6969 / CIFS 0xFF534D42 / SMB2 0xFE534D42) |autofs(0x0187 — the HEALTHY idle trigger; NEVER force-mounted) |stub(anything local — the RCA's silent guest-reboot state) |unknown(statfs error/3 s timeout — fail open). Seams:statfsFn+ per-caller injectables.- Probe fstype assertion (fix 2a): the
--netprobechild creates the probe file FIRST (the create legitimately triggers the automount), THEN requires a MOUNTED network fs — exit 5 → categorynot_network_fs(new §3.2 Hungarian message), full rollback, nothing registered. A writable local stub can never verify again. Red-proof: assertion disabled → the stub VERIFIED (exit 0 / job phasedone) → FAIL. - Deploy-time refusal (fix 2b):
POST /api/stacks/{name}/deployrefuses (409, Hungarian) whenHDD_PATHis a registered network path classifying as a stub (Router.refuseNetworkStubDeploy,classifyFSPathseam). Idle autofs / live / unknown / local / unregistered / empty all proceed. Red-proof: a mounted-only gate wrongly refuses the healthy idle trigger → FAIL. - Stub badge (fix 2c):
networkStorageWarningsreturns (warnings, stubs); the controller-side classification runs even when the agent is unreachable. Dashboard + stacks cards render the new distinct badge "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja"; stub WINS over the recoverable unreachable badge (never both); the unreachable line stays byte-identical (template-asserted). Pure mapping corenetworkStorageWarningsIn(appsUsingPathIn pattern). - Deploy-view truth (fix 4, the RCA S-C symptom): the deployed-app storage select marks
selectedby the STOREDHDD_PATH(CurrentHDDPath); a stored path absent from the schedulable list renders an extra disabled<path> (nem elérhető)option;IsDefaultselects only for NEW deploys. Red-proof: IsDefault-only revert → the default drive shows selected → FAIL. - Gates: template_id_gate + emoji_gate OK; full
go build/vet/test ./...green.
v0.116.1 — debug surface ungated from logging.level (2026-07-11) — MinAgent: 0.81.0
Live validation of v0.116.0 caught the last blind spot: /debug + /api/debug/* (and the nav
item) 404'd/hid unless logging.level=debug — the EXACT failure mode of the motivating incident,
still standing in front of the new always-on ring. The debug surface is now available at ANY
logging level (still session-authed via RequireAuth + CSRF); the nav link always renders.
isDebug() keeps gating only legacy log EMISSION sites, as designed.
v0.116.0 — observability pass: always-on debug ring + leveled sweep + agent tab + self-log pull (2026-07-11) — MinAgent: 0.81.0
Controller half of the cross-repo observability task (agent v0.83.0 + hub v0.46.0). Motivating
incident: a live NAS-verify refusal on an info box showed NOTHING in the debug view — the ring
only existed at logging.level=debug, so the detail never existed.
- Capture layer:
setupLoggernow ALWAYS builds the 1000-entryLogBuffer; the logger isMultiWriter(LevelFilterWriter(stdout, logging.level), ring)— DEBUG always reaches the ring, stdout/docker-logs keep respectinglogging.levelexactly as before (red-proof: filter disabled → the capture test fails on the stdout assertion). Newinternal/logxleveled helpers (Debugf/Infof/Warnf/Errorf, caller-attributed viaOutput(3,…)); legacyisDebug()sites untouched (observation, not refactor). - Report self-log pull (
report/selftail.go): ACK gainscontroller_log_requested(additive); the NEXT report carriescontroller_log_tail(ring newest-kept, 128 KB, consume-once — the v0.111.0 logtail.go shape copied exactly; red-proof: drain removed → ships every cycle → FAIL). The app-tail wire is byte-compatible (schema test asserts steady-state omission + unchanged keys). Serving a pull logs the customer-visibleoperator log pull servedINFO (rides IN the tail). - Debug page agent tab: Naplóviewer gains
Vezérlő | Ügynöktabs; the agent tab proxiesGET /api/debug/agent-logs→ agentGET /debug/logs(clientDebugLogs, 10 s budget). A pre-0.83 agent (typed 404 StatusError) renders "Az ügynök naplónézete az ügynök következő frissítése után érhető el." — ok-response, no error spam, nothing else gated (S6 tested both polarities). Template gates green. - Gap-fill sweep (all new lines via logx; entry/decisions/outcome+duration/errors):
netstorage_job (start, per-phase transitions with elapsed, agent add/verify/probe verdicts,
rollback start+outcome, terminal WARN/INFO with duration), netprobe (exec start + result),
netstorage_handlers (per-check validation refusals, orphan-share WARN, capability-gate line now
carries the decision SOURCE via new
SupportsWithSource— version vs probe vs cache), agentapi client (per-call DEBUG method/path/status/duration + agent-version-change line;SetLoggerwired on the memoized client), migrate engine (run start, per-phase DEBUG, complete line with duration), tier2/offbox (run-start INFO + previously SWALLOWED status-persist errors now WARN). - S7 log-sequence smoke: a full fake NAS add at level info must leave the 8 ordered phase markers in the ring (red-proof: dropped probe-verdict line → FAIL naming the marker).
- MinAgent: 0.81.0 unchanged — the agent tab degrades to the notice on older agents; nothing else is coupled. Demo-deploy only; Peti untouched (his visibility arrives with the next train).
v0.115.0 — version-aware Supports (agent version channel) + DSM-validated guidance (2026-07-11) — MinAgent: 0.81.0
Capability detection upgrades from route-probing to explicit version comparison, riding agent
v0.82.0's X-Felhom-Agent-Version response header. The probe FALLS BACK cleanly — agent 0.82 is
NOT required (MinAgent stays 0.81.0: the coupled NAS semantics; Peti's 0.81.0 box exercises the
fallback in production).
- agentapi: every response path passively captures the header (
noteAgentVersionon all fourDosites — even on 404s/errors); STRICT bare-semver validation at capture (the publish-agent.sh shape; garbage never overwrites);Client.AgentVersion()exposes the last-seen value. - features.go: per-feature
featureMinAgenttable (netstorage_verify: 0.81.0) + optionalAgentVersionReporteron the prober.Supportsorder: version known → semver compare → Yes/No with ZERO probe traffic; version unknown/garbage/table-gap → the v0.114.0 probe path byte-identical (SupportCache stays probe-only). Gate/banner/UI unchanged — same three verdicts, better source. - THE one comparator:
selfupdate.ParseVersion/Version.Comparemoved verbatim tointernal/util/version.go(selfupdate keeps type aliases — call sites + tests byte-unchanged); agentapi shares it (no import cycle, no second comparator). - DSM-validated NAS guidance (SPIKE-nas-dsm-2026-07-11, real DSM 7.2 via virtual-dsm): the NFS
guidance gains the verified Synology steps — File Services → NFS → enable + Maximum NFS
protocol: NFSv4.1 (the v3 default refuses our mount), NFS Permissions rule with Squash
„Map all users to admin”, the
/volume1/<mappa>path hint; the "útmutató készül" caveat narrows to QNAP only (Synology now validated end-to-end incl. SMB hardlink). - Tests + red-proofs: version-known compares without probing (mutant: short-circuit dropped → probes=1); garbage/absent header → exactly-one-probe fallback + cached (mutant: trusting an unparseable header as "too old" → fails); non-reporter probers byte-unchanged; comparator table incl. pre-release rejection + numeric-vs-lexicographic; wire-level: header wins over a routeless agent through the real pinned client, garbage header ignored at capture.
v0.114.0 — agent-capability gate for coupled features (2026-07-11) — MinAgent: —
Box-level backstop for the publish-train ordering discipline (incident: the 0.81/0.113 train's
9-minute controller-before-agent skew on Peti's box — RUNBOOK-publish-0.81-0.113-2026-07-11): the
controller now detects whether its agent supports a coupled feature and refuses that feature up
front, instead of failing mid-pipeline with a misleading rollback. No agent or hub changes; works
against agents 0.79–0.81 as they exist. (Retroactive note: v0.113.0's effective MinAgent was
0.81.0 for the NAS add — this release is the machinery that makes such coupling self-protecting.
Header convention from here on: coupled releases declare MinAgent: X.Y.Z on this line.)
- agentapi typed status (1.1): non-2xx GETs surface as typed
*StatusError{Path,Code}(same message text as the old formatted error) — the probe keys onCode==404viaerrors.As, never string matching. internal/agentapi/features.go:Feature/SupportState+featureProbestable (one row:netstorage_verify→GET /netstorage/verify-status, the route that shipped WITH the coupled add semantics in agent v0.81.0) +SupportCache(TTL 5 min, Yes/No cached, Unknown NEVER cached or refused) +Client.Supports. 2xx ⇒ Yes; 404 ⇒ No; transport/timeout/401/5xx ⇒ Unknown — an agent problem is never claimed as "too old".- Add gate:
handleNetStorageAddrefuses onSupportNoBEFORE the single-flight claim — HTTP 412, machine codeagent_outdated, message "Az ügynök frissítése szükséges ehhez a funkcióhoz — a frissítés megérkezése után próbáld újra."SupportUnknownpasses through to the existing agent-error paths.remove/list/health are NOT gated — old shares stay manageable. - Settings page:
NetAddSupport(yes/no/unknown, short 2 s probe budget + cache) —noswaps the add form for the honest banner; the share list + remove render in every state. - Tests: T1 gate refusal (job never starts, slot never claimed, zero agent calls), T2 unchanged happy path + warm-cache NEGATIVE assertion (probe count stays 1 across two adds), T3 indeterminate-never-refuses, T4 classification incl. the string-match trap case, T5 banner render, T6 TTL re-fire, wire-level 404-typing through the pinned client. Red-proofs RP1–RP5 run + reverted (recorded in REPORT.md).
- Also: fixed a scheduling flake in
TestBackupTier2Restore_DoubleClickRefused(pre-existing). - Docs: publish-train rules codified at
felhom.eu/documentation/runbooks/publish-train-rules.md(manifest-before-floor; floor field LAST — the DB row overrides env and acts immediately; MinAgent fleet gate; this gate as backstop).
v0.113.0 — NAS verify-before-commit + page redesign + protocol-honest guidance (2026-07-11)
Kills the "bogus share sits at Készenlét forever" bug: POST /api/storage/netstorage/add now
verifies the share END-TO-END before anything is registered, and rolls everything back on failure.
Built on SPIKE-nas-verify-2026-07-11 (b57f6ca) with agent v0.81.0; live-validated A–E on demo 9201
against an isolated sim NAS.
- Orchestration job (
internal/web/netstorage_job.go, the migrate.go shape): sync validation → detached single-flight job oncontext.Background()(~150 s budget; a closed tab can't abort a rollback) with phasesagent_add → verifying → probing → registering → done|failed, polled on NEWGET /api/storage/netstorage/add/status. Registration is the LAST step — the worst crash outcome is an agent-side orphan, never a registered-but-broken path. Verify-lost after an agent restart (phase:none) ⇒ controller rollback (Scenario F). - In-guest uid-1000 write probe (
netprobe*.go+ hidden--netprobe <dir>re-exec mode in main.go):SysProcAttr.Credential{1000,1000}, no shell; dot-file + nonce + readback + delete; exit codes →not_writable(the squash trap — an export that mounts but denies uid-1000 writes can no longer register) /probe_io; cleanup-fail = WARN on success, not a failure. - agentapi:
AddNetStorageresult gainsverify/job_id/code; typedNetAddRefusedError(categorized sync refusals — unreachable pre-probe); newNetVerifyStatus(short GET, the 15 s global client timeout is untouched — the long wait lives in the poll loop). - §3.2 Hungarian error map server-side (
netAddMessage): unreachable / nfs_export (MERGED not-found+not-permitted — NFSv4 returns identical strings) / smb_auth / smb_share / timeout / not_writable (the Route-A guidance with the computed uid+100000) / probe_io / generic. - Orphan surfacing: any agent-configured share NOT in the registry renders as a remove-only "Árva megosztás" row (closes the crash-window gap visibly; re-add with the same name = repair).
- storage_network.html full redesign on the canonical
storage_attachpattern — kills the<details>/<summary>-as-button hack and the NONEXISTENTform-row/form-inputclasses (the unstyled-look root cause). SMB listed FIRST (SMB (Synology, QNAP — a legtöbb NAS)), NFS two-recipe guidance (map-all-users simple recipe + full-fidelityanonuid=<uid+100000>with a live computed host-id), staged poll progress (Kapcsolódás → Csatolási teszt → Írásteszt → Regisztrálás), categorized errors + collapsible raw detail. Gates green; C8 render smoke guards the class regression. - Feature doc:
felhom.eu/documentation/controller/network-storage-nas.md(authoritative). Companion: agent v0.81.0 (retry=0, journal classifier, agent-side auto-rollback), host-install v1.13.0 (systemd-journalgroup). Red-proof outcomes: REPORT.md.
v0.112.0 — self-update without credentials: anonymous registry mode (2026-07-10)
Root cause (live on Peti's box): the updater piggybacked on the Git Sync credentials and REFUSED when they were absent — but the registry serves the public package anonymously (Docker v2 token dance, verified empirically). A fresh customer without a private catalog silently lost version discovery + self-update for no reason. Credentials become what they were meant to be: optional, private-catalog only.
queryRegistry(internal/selfupdate): both creds empty → anonymous mode — plain GET; on 401 parseWWW-Authenticate(realm + service FROM THE HEADER — never hardcoded, quoted/bare/any-order/ comma-in-quotes handled); GET the realm withservice+repository:<image>:pullscope and NO credentials; retry tags/list with the Bearer. Creds present → the BasicAuth path unchanged. Half-configured pair → loud "hiányos registry hitelesítő adatok". A genuinely-denying registry → "registry denied anonymous access — a private registry requires Git Sync credentials" (never the old "credentials missing"). The registry base URL now derives from the image ref (was hardcoded host).pullImage: no creds → thedocker loginstep is skipped entirely (docker's native anonymous flow covers public packages); creds → login/pull/logout unchanged (token still stdin-only).- Settings page truthfulness: "Verzió és frissítés" gains a mode line — "Registry: nyilvános
(hitelesítés nélkül)" vs "Registry: hitelesített"; credential-less is no longer an error state; the
Hiba row appears only on a real failure.
DryRun.PullCapablecounts anonymous as capable. - Tests (
registry_anon_test.go, httptest fake registry + fake CLI runner): full anonymous dance with ZERO creds (token request auth-free, correct scope, highest semver); creds path byte-shape unchanged (BasicAuth, no dance); both denial paths (token 401 / tags-with-Bearer 401) → the new clear error; WWW-Authenticate parser table; pull with no creds → no login invocation recorded, pull still invoked; creds → login/pull/logout order + stdin token; partial creds refuse everywhere. Red-proof: old creds-required guard restored → all three anonymous tests FAIL with "registry hitelesítő adatok hiányoznak" visible. Restored green. - Pairs with hub v0.43.1 (Git Sync form hint: "Opcionális — csak privát alkalmazás-katalógushoz…").
v0.111.0 — remote app-log diagnostics: error context + on-demand log tails (2026-07-10)
Extends the app-telemetry pipeline with what the live Peti support session lacked: readable error context and a way to pull an app's logs WITHOUT any access to the customer box. Pairs with hub v0.43.0.
- Error context (Part B) —
internal/metrics: the log scraper now attachesLogIssue.Context— up to ±5 raw lines around the FIRST occurrence of each error-severity issue in the scrape window (never on repeats; warns carry none). Caps: ≤11 lines, ≤400 chars/line (…), and a HARD 16KB per-report budget enforced ininternal/report(context dropped from the lowest-count issues first). The scan loop was extracted into the pureanalyzeLogLines(first unit tests for the scanner). Additivecontextfield on the report'sissues— old hubs ignore it. - Sanitization (Part E) —
metrics.RedactLine: authoritative controller-side redaction applied to every context + tail line before it leaves the box:password|passwd|secret|token|api[_-]?key| authorization|bearervalues →[REDACTED](incl.Authorization: Bearer <tok>in one pass) + 64-hex strings →[REDACTED-HEX64](repo-password shape). - On-demand log tails (Part D) — pull-based, ACK-flag pattern (same as escrow/config-refresh):
the report ACK gains
log_tail_requests: [app…]; the NEXT report shipslog_tails: [{app, collected_at, lines[]}]— 200 lines via the existing plumbing (stacks.GetLogscompose-logs for stacks, scanner-styledocker logsfor the controller container), ordered as emitted, ≤400 chars/line, ≤64KB/app head-truncated (newest kept), redacted. Consume-once: drained at build; a failed push re-arms from the hub's still-pending request. NO hub→controller push channel — the guest listens to no one. - Tests + red-proofs (all three failed exactly as designed, then restored green): context capture
dropped → "context has 0 lines, want 11" FAIL; redaction gutted →
password=hunter2shipped visibly → FAIL; consume-once clear removed → "second drain = [gokapi cwa]" (tails every cycle) → FAIL. Plus: exact ±5 ordered window, first-occurrence-only context, warn-no-context, truncation, budget drop order, byte-budget newest-kept, fetch-error skip, empty-ACK clears stale pending.
v0.110.0 — offbox stale-lock self-heal (campaign C2) + crash-truthful status (C1) (2026-07-10)
Fixes the overnight campaign's HIGH finding: a crash mid-prune left a restic EXCLUSIVE lock the controller
couldn't clear, failing every subsequent offsite run until manual restic unlock. Root nuance from the
evidence: plain restic unlock (stale-only) does NOT clear it — the recreated container has a new hostname,
so restic can't verify the dead PID and won't treat the lock as stale for ~30 min.
- C2 —
internal/backup:resticStepwraps the backup/prune/restore restic calls: on a lock error (repository is already locked) it escalates tounlock --remove-alland retries the step ONCE, justified by the ARCHITECTURAL single-writer guarantee (one controller per repo via per-customer sub-account isolation + the in-process single-flight mutex every caller holds → no live sibling). A second lock failure surfaces the error (never loops). Plus cheap pre-rununlock(stale-only) hygiene before every run + restore. Boundary (documented): a DR-cloned second controller writing the same repo would defeat the single-writer premise — operator-supervised territory. - C1 —
NewManager.reconcileCrashedRun: on startup, a persistedLastStatus="running"(a controller that died mid-run) flips toerror+ the Hungarian "megszakadt futás (a vezérlő újraindult futás közben)" — truthful after a crash; the next successful run clears it. - Tests + red-proofs: self-heal-and-retry (A, red-proof: neuter the escalation → the exact campaign
failure
offbox backup rallly: exit status 1→ FAIL); persistent-lock → one--remove-all+ one retry, error surfaced, no loop (B); pre-run stale unlock issued every run (C); no lock →--remove-allnever fires (E); crash-status flip (D, red-proof: drop the flip → status lies "running" → FAIL).
v0.109.1 — re-apply must preserve escrow custody + runtime status (live finding) (2026-07-10)
Found deploying v0.109.0: including QuotaGB in the bridge's descriptor hash triggered a one-time
re-apply on the demo — key-auth-first re-pinned cleanly (proven live, no password consumed) but
ApplyOffsiteTarget REPLACED the target with the freshly-built struct: the escrowed demo was demoted to
pending and its runtime status (last_run/size/snapshots) wiped — which would also false-trigger the new
staleness alert after re-confirming.
ApplyOffsiteTargetnow carries over the EXISTING target'sEscrowState+ runtime status fields on a re-apply: EscrowState tracks the REPO PASSWORD's custody (preserved byWriteOffboxSecrets, never rotated by this path), not the target coords; the status belongs to the runner. A fresh guest (no existing target) still landspending. Companion red-proof: dropped the EscrowState carry-over → "a re-apply must NOT demote an escrowed target, got pending" → FAIL. Reverted.- Demo repair: one manual confirm-escrow (the deprecated fallback — truthful: the same already-escrowed
password) restored
escrowed; a manual run restored the runtime status.
v0.109.0 — SLICE 4: soft-quota gate + usage bar + offsite report status (2026-07-09)
The shared-model soft quota (quota_gb) enforced controller-side (pairs with hub v0.41.0's
OffsiteChecker + freeze lever). No secrets anywhere in this slice — sizes/timestamps only.
internal/settings:OffboxTarget.QuotaGB(mapped from the hub descriptor by the apply-bridge —OffboxEnablerseam gainsquotaGB; 0 = no soft limit, dedicated boxes are Hetzner-enforced) +RepoSizeBytes(machine-readable size persisted fromrestic statsalongside the human string; a failed stats call keeps the last-known value — stale-but-safe).- Pre-run soft-quota gate (
RunOffboxBackup): at ≥100% NEW backup runs are refused —LastStatus="error"with the Hungarian notice ("A NAS-mentés túllépte a tárhelykeretet (X/Y GB) — törölj régi mentéseket vagy kérj nagyobb keretet."), operator alert via the existingoffboxNotifypath — but the retention/prune step STILL RUNS (offboxPruneOnly; pruning is the customer's only way back under quota — gating it would deadlock them) and restore is never gated. Companion red-proof: gated the prune too → "prune MUST still run over quota, got 0" → test FAILED. Reverted. The gate is pre-run: a run crossing 100% mid-flight finishes; the next refuses. At ≥80% (<100%) an OK run sets the Hungarian usageLastWarning("A NAS-mentés a keret X%-át használja (A/B GB)."). - UI:
/backupsgains a soft-quota usage bar (used/quota + %, green/amber/red) — rendered only whenQuotaGB > 0. Template gates green. internal/report: the hub report gainsoffsite:{enabled, escrow_state, last_run, last_status, snapshot_count, repo_size_bytes, quota_gb}(backup.OffboxReportStatus; absent when no offbox target — the hub checker is nil-safe on old controllers).- Tests: over-quota refusal (backup 0 calls, prune 1 call, Hungarian status, restore ungated); 84% warn + bytes persisted; quota-0 no gate; report object + nil when unconfigured; bridge quota mapping.
v0.108.0 — SLICE 3: hub-verified escrow auto-confirm (current-password hash match) (2026-07-09)
Replaces operator trust with a verified fact (pairs with agent v0.79.0 + hub v0.40.0): the report ACK now
carries escrow:{identity_blob_present, restic_pw_sha256, created_at} and the controller flips offbox
EscrowState pending→escrowed ONLY when sha256(local repo_password) == restic_pw_sha256 — i.e. the
stored escrow provably covers the CURRENT key, not merely "a blob exists" (a stale blob would re-open the
un-recoverable-ciphertext gap fork-4 closed).
internal/report:PushResponse.Escrow+EscrowAutoConfirmer(long-lived; runs on every ACK): match → flip (UpdateOffboxStatus) + wipe the agent-staged secret (the v0.107.0 DELETE path, best-effort loud); mismatch → stays pending + a LOUD warn naming the fix ("run the escrow ceremony"), deduped per distinct hash (not per 15-min cycle); no row / NULL hash / hash-without-identity-blob / no local password file → stays pending silently (fail-closed); non-pending → total no-op (never un-confirms). Companion red-proof: modeled the blob-present-only check → the stale-blob and hash-less scenarios flipped when they must not → tests FAILED. Reverted — hash-match is the load-bearing core.internal/backup:HashResticPassword(canonical: sha256 hex over the TRIMMED string — pinned cross-repo test vector, same vector asserted in felhom-agent) +Manager.OffboxRepoPasswordHash.internal/web: the manualPOST /backup/offbox/confirm-escrowis now a documented deprecated fallback for legacy hash-less blobs (e.g. the demo's) — auto-confirm is primary.- Hashes are safe to log (non-reversible over a 256-bit random secret); passwords never appear in logs.
v0.107.0 — offsite hardening: key-auth-first bridge + staged-secret wipe on confirm (2026-07-09)
Part of the offsite-provisioning hardening bundle (pairs with hub v0.39.0 + agent v0.78.0).
- Key-auth-first (
internal/offsiteapply): newKeyAuthProberseam (SFTPKeyAuthProber— probes the ALREADY-INSTALLED key against the descriptor target, pinned to the freshly-verified known_hosts). On a descriptor change where the existing key still authenticates, the bridge re-pins + reconfigures WITHOUT consuming a one-time password — kills the stale-descriptor consume-404 loop seen twice in the live e2e, and shrinks the re-issue blast radius to genuinely-fresh guests. The probe NEVER bypasses the fingerprint verify (scan+verify still precedes it; a mismatch refuses before any probe). Fresh guests (no key / auth refused) fall through to the full verify→consume→install path unchanged. Tests + red-proofs: probe-success with a panicking consumer (drop the skip → panic → FAIL); fresh-guest fallthrough (early-return on probe-fail → nothing applies → FAIL); mismatch now also asserts the probe never runs on a failed identity check. - Staged-secret wipe (
internal/web+internal/agentapi):WipeStagedEscrowSecret(DELETE/escrow/stage-secret, agent ≥ v0.78.0); the confirm-escrow handler wipes the agent-staged repo password wheneverEscrowStateflips toescrowed— best-effort (a wipe failure logs a loud ERROR but never fails the confirm; re-confirm retries). Closes the fork-4 hygiene gap where a confirm without a fresh ceremony left the staged 0600 file behind (observed live in the e2e's Option-A close). Test: confirm wipes exactly once; a failing wipe still confirms + logs "NOT wiped".
v0.106.1 — offsite apply-bridge: ssh-copy-id -s needs ~/.ssh to exist (live finding F3) (2026-07-09)
First supervised live apply: scan+verify passed, the one-time password was consumed, then ssh-copy-id -s
died locally — SFTP mode mktemp's its batch file under ~/.ssh, and the container image ships without
/root/.ssh. The fail-safe held (loud "password is spent" signal, no marker, no offbox config) and the
password never left the box, but the install could never succeed.
internal/offsiteapply.SSHCopyIDInstaller: ensure~/.ssh(0700) exists before runningssh-copy-id.- Live diagnosis (container, no secrets): with
~/.sshpresent, the pinned single-line known_hosts +StrictHostKeyChecking=yesverifies cleanly and a wrong password fails asPermission denied(sshpass exit 5) — the TOCTOU-hardened pin mechanics are sound end-to-end against the real box.
v0.106.0 — offsite provisioning SLICE 2: controller apply-bridge (2026-07-09)
Pairs with hub v0.38.0. On startup the controller reconciles the hub-served offsite: descriptor into a
working key-only offbox target — closing the loop to a hands-off, hub-driven offsite target. (Auto-confirm =
SLICE 3; soft-quota = SLICE 4.)
internal/config:OffsiteConfig(offsite:section) mirroring the hub descriptor (enabled/type/host/user/port/repo_path/quota_gb/box_type/host_fingerprint) — deep-merged fromcontroller.yaml.internal/offsiteapply(the apply-bridge):Bridge.Reconcile— idempotent (a descriptor-hash marker at<dataDir>/offbox/applied_markerprevents re-consuming a spent password) and fail-safe (any step fails → nothing persisted, retried next cycle). Flow: scan + VERIFY the box host key againsthost_fingerprint(no blind TOFU) → generate the controller keypair → consume the one-time password (POST /api/v1/offsite/consume-password/{id}, Bearer APIKey, single-use, never logged) → install the pubkey (sshpass -e ssh-copy-id -p 23 -s -f, pinning the scanner-verifiedknown_hostswithStrictHostKeyChecking=yes— noaccept-new/TOFU on the install or verify session, so a MITM cannot substitute a key in the gap between the scan and the install) + verify key auth → configure the offbox target →EscrowState="pending"(fork-4 enable path viaManager.ApplyOffsiteTarget) → persist the marker LAST. Seams (consume/scan/keygen/install/enable) so unit tests fake all I/O. A consumed-but-failed install logs a loud "password is spent — reset on the hub" signal.internal/backup:Manager.ApplyOffsiteTargetreusesWriteOffboxSecrets/SetOffboxTarget/PushOffboxPasswordForEscrow→EscrowState="pending"; the escrow stage-push is best-effort (agent-down ≠ apply failure).cmd/controller: wires the bridge (real seams — HTTP consumer, x/crypto/ssh host-key scanner, ed25519 keygen, sshpass installer) and runsReconcileasync at startup (non-blocking; the config-refresh restart re-runs it).Dockerfile: +sshpass.- Tests (faked seams): apply-end-to-end (pinned known_hosts + key + pending + marker + pw-not-logged); host-key mismatch → refuse + companion red-proof (drop the verify → wrong key pinned → test fails); idempotent (marker match → no re-consume); install-fail → fail-safe + companion red-proof (persist marker early → failed apply looks done → test fails).
- NOT yet live-applied — the supervised end-to-end (hub provisions on the new pool box → controller
consumes + installs + configures) is the next runbook, gated on the hub's new scoped
HETZNER_TOKEN.
v0.105.0 — fork-4: offsite password custody hand-off + atomicity gate + DR inject + DR coord (2026-07-09)
Pairs with agent v0.77.0 to make the restic-offsite repo password recoverable at DR (rides the customer-R
escrow) and forbids an un-escrowed offsite copy from existing. Validated design: custody spike febdc56.
- Hand-off (
internal/agentapi/client.go):StageEscrowSecretpushes the repo password to the agent'sPOST /escrow/stage-secretover the authenticated pinned local-API channel (value never logged). The enable flow (internal/web/offbox_handlers.go) reads the 0600 password via a newManager.PushOffboxPasswordForEscrow(the handler never sees the value) and marksEscrowState="pending". - Atomicity gate (
internal/backup/offbox.go):OffboxRunnable()/offboxEscrowed()—RunOffboxBackup(and thus the daily scheduler + the run handler) refuses to run untilEscrowState=="escrowed", so no un-recoverable offsite ciphertext can exist.OffboxConfigured()is unchanged (config/UI still work). Newsettings.OffboxTarget.EscrowState(""|"pending"|"escrowed", additive, preserved across edits). - Confirm + DR inject (
internal/web):POST /backup/offbox/confirm-escrow(operator, after the escrow ceremony) → escrowed;POST /backup/offbox/inject-password(DR) →Manager.InjectOffboxPasswordpre-places a recovered 64-hex password 0600 (tmp+rename), refusing to clobber withoutforce— a subsequentWriteOffboxSecretsthen uses it (the pre-place seam)./backupsshows a pending-escrow notice + confirm button. - DR recipe (
internal/report/dr_recipe.go):DRRecipeAppHalf.OffsiteRestic *DRResticCoord{host,user,port,repo_path} — coordinates ONLY (the password is escrowed, the SFTP key is regenerable); populated fromManager.OffboxCoord(); clears the_NoSecretsregex. - Tests: atomicity (pending blocks run; confirm enables) + companion red-proof (gate disabled → runs
while pending → FAIL); DR inject pre-place honored + refuse-clobber + companion (no-inject generates a
DIFFERENT password);
OffboxCoord; agent stage endpoint (0600 + non-secret ack + cross-guest 403 + value-not-in-log);DRResticCoordno-secrets. Web: run-gate + confirm + inject endpoints. - NOT yet live-validated — the supervised escrow ceremony (enable→stage→escrow-create→confirm→gated run) is the operator-run follow-up.
v0.104.0 — off-box unit discovery (durable, deployment-independent) + no-silent-success (2026-07-09)
Fixes the off-box mis-resolution + silent-success landmine surfaced by the Storage-Box spike and pinned by
the DIAG report (felhom.eu/documentation/audits/SPIKE-storagebox-restic-direct-2026-07-09.md). Root cause:
runOffboxInternal located each toggled app's recovery unit via RecoveryUnitPath(AppNamespaceRoot(stack), stack); AppNamespaceRoot→GetAppDrivePath reads the app's live app.yaml HDD_PATH and returns ""
for a not-currently-deployed app, silently falling back to systemDataPath. So a toggled-but-undeployed
app was looked for on the wrong drive → os.Stat failed → skipped → the run returned nil → status ok
with 0 snapshots (no alert).
- Discovery over inference (
internal/backup/offbox.go): newdiscoverOffboxUnit/offboxCandidateNSRootsscan the durable storage registry — every registered schedulable, non-decommissioned path (GetSchedulableStoragePaths) ∪systemDataPath, deduped by resolved nsRoot — forbackups/primary/<app>, independent of live deploy state. Multiple copies of the same unit (drive churn) → the newest by manifestCreatedAt(mtime fallback) is backed up, the stale one WARN-logged.AppNamespaceRootand the primary WRITE paths (CaptureRecoveryUnit/dumps) are unchanged. - No silent success (
RunOffboxBackup):runOffboxInternalnow returns(backedUp, missing, err). ≥1 toggled butbackedUp==0→ a hard error (LastStatus="error"+offboxNotifyfires with a non-nil err → operator alert). A partial run staysokbut sets a new customer-visibleOffboxTarget.LastWarning(internal/settings/settings.go,last_warning,omitempty) naming the skipped apps; rendered on/backupsin the--warnstyle (internal/web/templates/backups.html), preserved across a config edit (internal/web/offbox_handlers.go). - Tests (
internal/backup/offbox_test.go): six non-hollow cases (A discovery-on-registered-drive, B 0/N hard-error+alert, C partial→warning, D newest-of-two-copies, E happy path, edge 0-toggled), asserting the exact discoveredsrc+LastStatus/LastError/LastWarning+ notify-err. Companion red-proofs run: (A) reverting toAppNamespaceRootresolution → 0 backups → FAIL; (B)if falseon the 0/N promotion → silentok→ FAIL; both reverted. - NOT yet live-validated against the Storage Box — awaiting supervised re-provision + endpoint round-trip (box repos/creds were torn down with the spike). Unit suite fully covers the discovery + status logic.
v0.103.0 — F-C2-1: config loader no longer corrupts a bcrypt password_hash (silent auth bug) (2026-07-07)
Fixes campaign-2 finding F-C2-1 (felhom.eu/documentation/tests/CAMPAIGN-2-2026-07-07.md).
loadAndParse and LoadFromBytes ran os.ExpandEnv over the entire YAML before parse. A bcrypt
hash ($2a$10$…) is full of $word sequences, so ExpandEnv silently replaced each with its (usually
empty) env value — corrupting web.password_hash on load (proven: $2a$10$N9qo8uL… → "a0"). A silent
auth-integrity bug.
- Fix: removed both
os.ExpandEnvcalls (config.go:234 loadAndParse, :249 LoadFromBytes) — parse the raw bytes directly. The sanctioned, typed env path (applyEnvOverrides→FELHOM_WEB_PASSWORD_HASH, applied after parse) is unchanged; no shippedcontroller.yamlrelies on file-level${VAR}interpolation (onlydocker-compose.ymluses${DOMAIN}, which is compose-level). - Behavior change: a literal
${VAR}in a controller.yaml value is now preserved verbatim (was expanded). No repo config depends on the old behavior. - Tests (
config_test.go): bcrypt hash loads byte-identical (file + bytes paths; red-proof: pre-fixExpandEnvmangles it to"a0"→ FAIL, demonstrated + reverted);FELHOM_WEB_PASSWORD_HASHoverride still wins; literal${VAR}preserved.
v0.102.0 — async restore family: no more proxy-timeout error page on a succeeding restore (2026-07-06)
Re-adjudicates campaign F4 (felhom.eu/documentation/audits/RERUN-p1p3-2026-07-06.md): all three
restore surfaces (/backup/restore, /backup/tier2/restore, /backup/offbox/restore) blocked the
HTTP request until completion. Through cloudflared's hard 100s cap + traefik, a real customer got an
error page while the restore silently succeeded; the off-box one was worse — it bounded on
r.Context(), so a proxy read-timeout canceled the SFTP restore mid-flight.
- Async family (mirrors the existing
offboxRunHandlershape): each handler fast-path refuses a concurrent op (IsRunning()→ "Egy mentési/visszaállítási művelet már fut."), then runs the restore in a background goroutine and redirects immediately with a "Visszaállítás elindult…" flash. The offbox restore's context moved fromr.Context()tocontext.Background()+30m(fixes the mid-flight cancel). The restore functions' internal single-flight acquire is unchanged. - Op-status surface (
internal/backup/opstatus.go): mutex-guarded in-memory current-op + terminallast{op,stack,ok,message,finished_at}, deep-copy getter; newGET /api/backup/restore-status(distinct from/backup/status, which proxies the agent's PBS status). In-memory, lost on restart (same precedent as notification cooldowns). - UI (
backups.html): a progress banner polls the status every 3s — neutral while running (shown even on a fresh page load mid-op), success on completion, red only on failure. - Tests:
opstatus_test.go(begin→running→terminal, deep-copy, failure);async_restore_test.go(handler returns <500ms while the restore parks in a blocking provider + op-status transitions; double-click refused with no second launch). Red-proof: the pre-fix synchronous handler blocks the request indefinitely (test killed at 30s) vs <500ms async.
v0.101.0 — campaign findings F3 (sync deadline) + F2 evidence gap (agent refusal surfacing) (2026-07-06)
From the no-mercy campaign (felhom.eu/documentation/audits/CAMPAIGN-nomercy-2026-07-06.md).
No behavior change for the happy paths; two robustness/diagnosability fixes.
- F3 — sync git subprocess deadline (
internal/sync/sync.go):runGitInDirhad no context, so a hung remote parked the sync goroutine incmd.Run()— thedoSyncdefer never ran,syncingstayed true, and every manual + periodic sync was refused with "Szinkronizálás már folyamatban" until a controller restart. Each git command now runs underexec.CommandContextwith a fresh per-commandgitCmdTimeout(120s); the deadline error names the timeout and the masked git args (no silent hang). Debounce + failed-sync-arms-debounce unchanged. Tests:TestRunGitInDir_CancelledContextKillsSubprocess(red-proof: pre-fixexec.Commandruns to completion → FAILs),TestTriggerSync_FailureReleasesSyncingAndAllowsRetry. - F2 evidence gap — agent refusal surfacing (
internal/agentapi/client.go):EjectDiskandDecommissionusedc.post, which discards a non-2xx body — so the agent's informative refusal ("…decommission refused (role: system)") was flattened to a bareHTTP 403(the exact campaign evidence). Both now usepostWithStatus+ a sharedrefusalErrorthat carries the agent's reason (truncated ~300, no bodies/secrets) through to the controller's Hungarian error and the UI/API response. The genericpostand all other callers are untouched. Tests: T-D1/T-D2 (fake-agent 403 → reason surfaced; red-proof: pre-fixc.postyields bareHTTP 403→ FAILs), T-D3 success unchanged, plus an ok:false 2xx-business-refusal case.
v0.100.0 — one-click class-C file restore from the Tier-2 copy (2026-07-05)
TASK C2 — closes drill finding F2 (DRILL-appdata-restore-2026-07-04.md §4): HDD bind-mount
user files (appdata/<stack>) had no customer recovery path — Tier-2 protected them nightly, but
getting deleted files back was an operator copy-back by hand.
- Engine (
internal/backup/tier2_restore.go):Manager.RestoreTier2Files(stack)— in-place, additive-only restore from the RECORDED Tier-2 copy (CrossDriveBackup.DestinationPath, never a freshselectTier2Target). Semantics =rsync -a --ignore-existing: files missing live are copied back; existing live files are NEVER overwritten (a customer edit after the last copy wins); nothing is EVER deleted (thersyncMirror --deletetrap in this direction would erase every file created since last night — the newrsyncRestoreMissingcopies the mirror's exec shape with the opposite-direction flags). Single-flight with backup/restore; all refusals (no copy / LastRun empty / copy dir gone / either drive disconnected / live drive decommissioned) happen BEFORE the stop, with customer-readable Hungarian reasons; stop → copy → start → health; copy/restart errors surface (F17). File count from--itemize-changes(>flines); file names never logged at INFO. - Endpoint + UI:
POST /backup/tier2/restore(internal/web/server.go+handlers.go, backupRestoreHandler-shaped guards) + a "Fájlok visszaállítása" button on the healthy Tier-2 layer row (templates/backups.html; hidden when unconfigured / never ran / target drive disconnected/inactive) with a confirm dialog stating the additive-only contract + last-copy time. Zero files copied = success ("Nincs hiányzó fájl — minden fájl megvan a helyén."), not an error. - Out of scope by design: overwrite/point-in-time restore (offbox + operator paths), per-file
selection,
recovery-unit/(backup artifacts are not user files). Apps that index their data dir (e.g. Nextcloud) may need a rescan before restored files appear in their own UI — noted infelhom.eu/documentation/controller/backup-architecture.md. - Tests: orchestration via a
restoreFilesCopierseam (stop→copy→start order, src/dst contract, refusal NON-effects: never stopped, copier never invoked), Scenario-D zero-copy success, itemize parsing, handler guards, and an FS-level semantics test of the real rsync (LookPath-skipped where rsync is absent). Companion red-proof: swapping the flags forrsyncMirror's mirrors the backup over live — the differing live file gets clobbered AND the live-only file gets deleted (both assertions red; verified on the build server, reverted).
v0.99.0 — restore-path fixes: dead restore UI + volume dumps + blank-secret redeploy (2026-07-05)
TASK C1 — fixes F1/F3/O4 from the 2026-07-04 restore drill
(felhom.eu/documentation/audits/DRILL-appdata-restore-2026-07-04.md). F2 (one-click in-place
class-C restore) deliberately NOT included — product-design work (C2).
- F1 (HIGH — the restore panel was dead):
GET /api/backup/snapshots?stack=now exists (internal/api/router.go+backup.Manager.ListRestorePoints,internal/backup/restore_points.go). The backups.html restore panel fetched this restic-era route, got the catch-all 404, so the snapshot dropdown never populated and "Visszaállítás indítása" could never enable. Returns the ONE honest keep-side restore point (the current recovery unit):time= newest artifact mtime (manifest / db-dumps / volume-dumps),short_id:"helyi",tier:1always (Tier-2 copies are NOT restorable via POST /backup/restore — never listed),drive_labelfrom the storage registry. Guards: traversal/empty → 400 (validStackParam), unknown stack → 404, no unit yet →ok:true, data:[]. No template change needed — the JS payload contract was honoured server-side. - F3 — named-volume data was never backed up:
DumpAppVolumesSafehad no production caller. NewrunVolumeDumpsloop inrunDBDumpsInternal(internal/backup/backup.go), running BEFOREcaptureAllRecoveryUnitsso manifests enumerate the fresh tars. Gate order is load-bearing: protected-stack + has-volumes checks precede the Safe call (which stops the stack before its own check — unconditional calls would bounce every volume-less app nightly); disconnected/decommissioned drives skip like the DB loop. Failures land in the run summary and fail the run (no silent partial). Zero-DB early return removed (volume-only apps still get dumps + unit refresh). Test seam:dumpVolumesSafefunc field (F17-style). - O4 — missing resettable secret redeployed blank: the restore proceed-path now generates a
replacement credential via the catalog field's
generatespec (stacks.Manager.GenerateSecretForField→backup.SetSecretGeneratorseam, wired in main.go), persisted encrypted through the existingRecreateStackFromUnit→SaveAppConfigpath. Data-keys are NEVER generated (gate untouched + generator refusesdata_keyfields); values never logged. No-generator fields keep proceeding with an upgraded "may fail to start" WARN. Residual case documented: a restored volume tar carrying the OLD internal credential hash may still need a manual in-DB reset.
Tests: 272 → 286 top-level test funcs (+14: api snapshots ×3, backup restore-points ×4, volume-dump gating ×3, backup secret-gen ×3, stacks secret-gen ×1);
all three fixes companion-red-proofed (hollow [] endpoint / removed volume gate / no-generation
each fail their test). Full go build && go vet && go test ./... green.
docs — CLAUDE.md refresh: slim-down to stable orientation (2026-07-03)
No code change, no version bump. CLAUDE.md 338 → ~160 lines: full 30-package layout map (was 7);
stale bare-metal /opt/docker deploy steps replaced with the verified 9201 bootstrap deploy
(/etc/felhom-controller-image + felhom-controller-bootstrap.service); embedded hub build section
deleted (points to felhom.eu); deep runbooks/design/testing content moved to the new skills
(felhom-build-deploy, felhom-ui-design, felhom-testing — source felhom.eu/skills/); "Key
patterns"/"lessons" pruned to session-critical invariants (rest live in REUSE.md). Standing rule
adopted: CLAUDE.md carries no version-pinned current state — that lives in CONTEXT/CHANGELOG/REUSE.
docs — REUSE.md introduced (2026-07-03)
Cross-repo reuse-map rollout (docs-only, no code change, no version bump). New REUSE.md at the
repo root: curated map of canonical helpers (62 rows), patterns, dangerous lookalikes (rsyncMirror
--delete, raw os.RemoveAll on drive paths, fresh agentapi.New per request…), test seams, extension
points, and observed duplication (12 clusters, NOT fixed). Every entry code-verified at file+symbol;
cited paths machine-checked by felhom.eu/scripts/reuse_refs_check.py (green). CLAUDE.md gains the
"See REUSE.md before writing new code" pointer + the same-commit maintenance rule.
v0.98.3 — hide "Eltávolítás a listából" on wizard-enrolled drives (2026-07-02)
User feedback follow-up: list-removal (registry-entry delete; data + mount untouched) is only
meaningful as the undo of a MANUAL path add. On a wizard-enrolled drive (/mnt/felhom-drives/) the
resulting de-registered-but-still-agent-bound limbo is never what the customer wants — its real
lifecycle is Biztonságos leválasztás / Végleges leszerelés. New StoragePathView.IsEnrolled
(path-prefix check) gates the button; manually added paths keep it, and the decommissioned-branch
"Eltávolítás a rendszerből" (final cleanup) is unchanged. Endpoint untouched.
Test: enrolled card must not render the remove form, manual card must (TestListRemovalHiddenForEnrolledDrives).
v0.98.2 — drive-card action clarity: dedupe + self-documenting labels (2026-07-02)
User feedback: two "Leválasztás" buttons per drive, and four near-synonymous labels (Letiltás / Leválasztás / Eltávolítás / Leszerelés) for very different operations. storage.html only:
- Dedupe: the agent-level eject no longer renders on a card that already offers the registry
safe-disconnect (enrichCard passes
hasSafeDisconnect, detected from the card's own storageDisconnect button) — one detach affordance per card. Non-USB registered drives (no safe-disconnect) and unregistered drives keep the agent eject. - Labels + tooltips (endpoints unchanged): Letiltás/Engedélyezés → "Új telepítések
letiltása/engedélyezése"; registry Leválasztás → "Biztonságos leválasztás" (apps stop, safe
unplug, reconnectable); Eltávolítás → "Eltávolítás a listából" (registry-entry removal only,
data untouched); Leszerelés → "Végleges leszerelés" (permanent, optional migrate-first); agent
Törlés… → "Formázás…" (that's what it does). Every action button carries an explanatory
title.
v0.98.1 — drive-card spacing: enrichment rows no longer touch (2026-07-02)
User feedback: on the Meghajtók cards the agent tag row ("Felhasználói adat", "lassú", uuid) and the
agent action row ("Leválasztás", "Törlés…") rendered with zero vertical gap. The
.drive-agent-extra slot is now a flex column with a .6rem gap (+ .6rem top margin, hidden when
empty); the inline margin in enrichCard dropped in favor of the slot styles; metarow horizontal
gap tightened to .75rem. CSS + one JS-string line only (storage.html, style.css).
v0.98.0 — storage IA follow-up: Meghajtók / Hálózati tárhely subpages (2026-07-02)
User feedback on the D1 Tárhely page: the NAS-add button rendered directly next to the local-drive enrollment buttons ("Új meghajtó inicializálása" / "Meglévő meghajtó csatolása") — two different storage classes confusingly interleaved. The page splits into two subpages under Tárhely:
/storage— Tárhely — Meghajtók (storage.html): physical drive registry + unified agent view- migrate + wizard entry points + manual add. The enrollment buttons now live unambiguously in the local-drive context.
/storage/network— Tárhely — Hálózati tárhely (NAS) (storage_network.html, new): the NAS share list ("NAS-megosztások") + add form + its JS moved verbatim (incl. its ownopenDialogcopy for the remove overlay).- layout.html: the Tárhely main-nav item gains two always-visible nested sub-links (Meghajtók /
Hálózati tárhely;
.nav-links-nestedCSS); the parent stays highlighted on both subpages. - handlers.go / server.go:
NetworkStoragePathsmoved fromstoragePageDatainto the newnetworkStoragePageData(page keystorage-network) +storageNetworkPageHandler;GET /storage/networkroute. No/api/storage/*change. - Tests updated:
/storagemust NOT render the NAS section,/storage/networkrenders it and nothing drive-related; the section inventory + no-native-confirm scans cover the new template. Both template gates green;go build/vet/test ./...green (18 pkgs). - Live-validated on 9201: both subpages render with correct sidebar active states; the NAS add-form
toggle +
nsToggleSmb+openDialogexercised on the new page.
v0.97.0 — TASK-D1: settings split + Tárhely page (unified drive view) (2026-07-02)
The 1451-line settings monolith becomes four pages; storage is promoted to a first-class main-nav
page with a unified (registry + agent) drive view; native browser dialogs migrate to the overlay
pattern. IA/appearance only — no /api/storage/* payload, storage semantics, or agent-client
change. Four commits (d50a919, f8e18a9, cb6f04c, 622d932).
- Routing (server.go): new
GET /storage(Tárhely),GET /settings/notifications(GET→page / POST→save split on the same path),GET /settings/security; the enrollment wizards move to/storage/init+/storage/attach, with 301 permanent redirects from the old/settings/storage/{init,attach}. - Data builders (handlers.go):
settingsData()decomposed intosettingsBaseData+systemPageData/storagePageData/notificationsPageData/securityPageData; each GET handler and each error-re-rendering POST handler uses exactly its page's builder + template. All five storage action redirects now land on/storage?storage_msg=…. - Template split:
settings.htmldeleted; sections moved verbatim intosettings_system.html(Rendszer konfiguráció, Verzió és frissítés, Vezérlő/Kiszolgáló újraindítása),settings_notifications.html(Értesítések, Alkalmazás-email),settings_security.html(Jelszó módosítás, Földrajzi korlátozás, Vészhelyzeti információk — misspelled heading + section copy accents fixed), andstorage.html. The NAS + migrate sections (previously nested inside{{if .StoragePaths}}and invisible with zero drives) are now unconditional on/storage. - Sidebar (layout.html): Tárhely main-nav item (hard-drive icon) + a "Beállítások" group with
Rendszer / Értesítések / Biztonság és hozzáférés sub-links (active state per page); orphaned
.sidebar-settings-linkCSS deleted,.nav-group-label/.nav-links-subadded. - Unified drive view (storage.html): registry cards render server-side as before; the agent
/api/diskslist ENRICHES each connected user-data card in place (role tag viai-lock, drive class, durable-id mono line, agent-only register/eject/wipe actions) joined on mount path — one card per drive. Two extra groups: Rendszermeghajtók (system/backup, read-only, lock tag, no actions) and Nem regisztrált meghajtók (register action only). Agent-down → one warn note (Az ügynök nem elérhető…), all registry cards still render (graceful degradation). Agent-view helpers emit design-system.tagmarkup (no.badge); the 🔒 emoji is gone. - Overlay migration: every native
confirm()/prompt()on the four pages routes through a light.confirm-overlaydialog (openDialog; texts verbatim) — storage remove forms, netStorageRemove, storageMigrateAll, storageDisconnect, storageDecommission (migrate + the type-to-confirm anyway branch preserved like-for-like), storageReEnroll, triggerUpdate, controller/server restart, and the two geo Hungary-removal confirms. One froze a browser tab during D0 validation; none remain. - D0 leftovers: the D0 grep gate false-negatived multibyte emoji on Windows — a Python
codepoint gate (
scripts/emoji_gate.py) found and removed 8 survivors (📁🔄🔒📦 across backups/debug/deploy/storage; the ★ default-marker → „(alapértelmezett)"). Orphaned.badge-lock/.lock-icoCSS deleted (grep-zero first). - Gates & tests (+8):
scripts/template_id_gate.py(JS element-ID integrity — everygetElementById/querySelector('#…')resolves in its own template; red-proven by a misplaced function),scripts/emoji_gate.py(0), Go tests for the four-page render + cross-leak, the h3 section inventory (all 11 old headings accounted for), 301s, storage redirect + flash, password inline re-render, no-native-confirm scan, agent-down warn-note, and codepoint emoji scan. Redirect + inventory tests red-proven against pre-split code.go build/vet/test ./...green. - Live-validated on 9201 via claude-in-chrome: all four pages + the 301 redirect, the unified view (3 enriched cards with role tags + durable-ids, Rendszermeghajtók group read-only with 0 action buttons), the Leválasztás overlay opened + cancelled (drive untouched), and a full label-rename round-trip through the real UI (flash on /storage, renamed back). NOT live-validated: agent-down degradation (static/unit only — the agent must not be stopped on the live host); destructive storage ops (endpoints unchanged; the moved UI paths await a supervised session).
v0.96.0 — TASK-D0: design system v2 re-skin (appearance only) (2026-07-02)
Full customer-UI re-skin to the approved Felhom design system v2 — navy token palette, exception-based
status color, vendored fonts/icons, flat metadata. Appearance only: no route/handler/IA changes;
every page keeps its URL, sections, forms and behavior. Canonical reference:
felhom.eu/documentation/design/design-system.md. Four commits (b073cc4, 5dc277f, f100cef, 7df061c)
- a bug-fix (
4906524).
- Vendored assets (
internal/web/static/fonts/,templates/icons.html,embed.go,server.go): Plus Jakarta Sans + JetBrains Mono as variable woff2 (latin + latin-ext — ő/ű), embedded and served from/static/fonts/(font/woff2, immutable cache); Google Fonts@importremoved (CDN silently broke offline nodes). Vendored 30-icon Lucide sprite included at top of<body>; all emoji replaced by sprite icons or plain text (templates AND JS-built strings). - Setup CSS fix (
internal/setup/handlers.go):handleCSSserved a dataDir-derived filesystem path that never exists in the container — production setup mode silently fell back tominimalCSS. Now serves the embeddedweb.StyleCSS()(new accessor); minimalCSS only if the embedded read errors (logged).minimalCSSretokened to v2. templates/style.css(rewritten in place): v2:roottokens; single 2px radius; everybox-shadow+ the bg grid overlay deleted; new components —.meter(3px hairline track, blue nominal fill, neutral 70/85 ticks, warn/crit.meter-flag„Fogyóban a hely" / „Kritikusan kevés hely"),.tag(square state chip + dot, pulse on progress, reduced-motion respected),.metarow,.panel/.list/.section-h, boxless.stats, buttons (danger = crit outline until confirm),:focus-visibleoutlines.- funcmap (
internal/web/funcmap.go):stateColor→run/progress/warn/neutral/off(stopped/exited is neutral, NOT red — operator-approved exception-color change; restarting = warn);usageColor/tempColor→nominal/warn/crit(thresholds unchanged);stateLabelHungarian copy untouched (byte-identity guarded by test). NewtimeAgoStr(see fix below). - All 19 web templates + setup templates: bars → meters (template + JS-generated markup),
badges/pills → tags, informational pills → metarows with icons, legacy
var(--*)names in inline styles/JS renamed to v2 tokens, monitoring Chart.js palette (cpu#2EA8F5, memory#8E7CE8, temp#E0A93E, load#5EC4B6; v2 tooltip/grid/tick literals), deploy 3-step progress → sprite icons, catchall page (standalone) fully retokened with inline SVGs, login two-tone H1. - fix(backups)
4906524:OffboxTarget.LastRunis an RFC3339 string; backups.html passed it totimeAgo(expectstime.Time) → GET /backups 500'd on any node where an off-box backup had ever run. Pre-existing since v0.93.0, exposed by the D0 click-through; fixed withtimeAgoStr. - Tests (+7): §8 truth tables for stateColor/usageColor/tempColor + stateLabel guard (red-proven
vs the old funcmap), font route +
StyleCSS()accessor, setup embedded-CSS (Scenario E, red-proven vs the old handler). Grep gate: 34 banned patterns (old hexes, 999px, box-shadow, CDN import, legacy class names, emoji) at zero ininternal/{web,setup}(baseline: 143 hits). - Live-validated on guest 9201 via claude-in-chrome: full click-through, no Google Fonts requests,
document.fonts.checktrue, ő/ű render in PJS latin-ext, dashboard Scenario-A assertions (0 green fills, 0 shadows, 0 radii >2px) DOM-verified. NOT live-validated: setup wizard rendering (unit-tested only), warn/crit meter states on real hardware (demo node healthy; unit-tested).
v0.95.0 — enrollment wizards use the raw-device scan /disks/candidates (Impl-2b) (2026-07-01)
Final drive-enrollment piece: both enrollment wizards now source candidates from the agent's Impl-2a
raw-device scan instead of the Observe()-based /api/disks list — so a brand-new (non-PVE-storage)
drive is finally visible + enrollable end-to-end. The enroll flow (runStorageInit/runStorageAttach)
and the Impl-1 guarded mkfs are UNCHANGED; the wizards just get the right candidate list.
internal/agentapi/client.go:ListCandidates(ctx) (CandidatesResult, error)→ agentGET /disks/candidates; typesCandidatesResult{Initialize,Attach []DiskCandidate}+DiskCandidate{Device,SizeBytes,Model,FSType,DataBearing,Mountable,MountSource,DurableID}mirroring the agent'scandidates.go.internal/web/agent_disk_handlers.go:GET /api/disks/candidatesproxy (agentDiskCandidatesHandler, copy ofagentDisksListHandler) — passthrough, NO controller-side filtering (the agent's unclaimed-disk filter is authoritative + fail-safe).templates/storage_init.html: fetch/api/disks/candidates→ render theinitializelist (model/size/current-FS + a data-bearing marker); dropped the client-side "already-managed" filter (the server list already excludes OS/enrolled/claimed disks). Data-bearing → the existing wipe-confirm.templates/storage_attach.html: fetch/api/disks/candidates→ render theattachlist (mountable-FS disks); selecting posts the FS-bearing node + its fstype to the existing/api/storage/attach(mount + bind, NO format).- TOCTOU: the wizard trusts the agent's Impl-1
Formatguard as the backstop (re-checks unclaimed at format time), not the list's freshness — a device claimed between scan and enroll is refused. - Tests:
agentapiTestListCandidates+_Error.go build/vet/test ./...clean. Live end-to-end raw enrollment of/dev/sddvalidated through the real UI (see REPORT).
v0.94.0 — pull-based config-refresh (re-pull controller.yaml + self-restart on a config change) (2026-06-30)
Config delivery is now pull-based, riding the report ACK exactly like the Phase 2 version floor — the hub never connects into the box. This replaces the hub's retired "Push Config" (companion hub change v0.26.0) and is the mechanism by which an operator config edit reaches a running box.
internal/report/pusher.go:PushResponsegainsConfigVersion int(json:"config_version"). 0 = the hub didn't advertise it (old hub / report-only customer) → no action.internal/report/config_refresh.go(NEW) —ConfigRefresher.Reconcile. The testable reconcile (all side effects injected): on a config_version change vs. the last-applied version, Refresh (re-pullcontroller.yaml) → Record → Restart. First-ever ACK (nothing recorded) records the baseline WITHOUT restarting (the first-boot pull already has the current config); an unchanged version is a no-op (no restart storm); a failed pull keeps the current config and does NOT record/restart (retried next cycle); record-before-restart so the restarted process sees it applied and doesn't loop.internal/bootstrap/bootstrap.go—RefreshConfig. Re-pullscontroller.yamlfrom the hub and rewrites it, re-merginglocal_apifrom the same read-onlybootstrap.jsonmount (no secret stashed elsewhere). Reuses the existingpullWithRetry/mergeLocalAPI/writeFileAtomic. Overwritescontroller.yaml(hub = source of truth); NEVER touchessettings.json; fail-safe (any failure leaves the current config unchanged + returns an error). NOT first-boot-gated (unlikeMaybeIngest).internal/settings/settings.go:applied_config_version+GetAppliedConfigVersion/SetAppliedConfigVersion(persisted so the version survives the restart).internal/api/selfrestart.go: exportedGracefulSelfRestart(the unexported one now calls it) so the main.go reconcile reuses the one graceful-restart mechanism instead of reinventing anos.Exit.cmd/controller/main.go: wires the reconcile intoOnPushResponsebeside the floor reconcile — same report cycle, no new timer, no agent involvement. The first-bootMaybeIngestnever-clobber is untouched (the refresh is a separate explicit re-pull).- Tests:
Reconcile(change→refresh+record+restart; same-version no-op RED-PROOF; baseline-no-restart; failed-pull no-record/no-restart; zero-version no-op; record-fail skips restart) +RefreshConfig(re-pull overwrites + re-merges local_api; failed pull leaves config unchanged; absent bootstrap errors without writing).go build/vet/test ./...green.
v0.93.0 — NAS Part B: off-box backup target (restic-over-SFTP) (2026-06-30)
Closes the NAS arc: back the app-data tier (each off-box app's recovery unit + DB dumps + volume tars) up to the customer's NAS as an encrypted restic repo over SFTP — the "1 off-site" leg of 3-2-1, distinct from the local cross-drive rsync copy and the agent's PBS whole-CT DR. No kernel mount; restic talks SFTP to the NAS directly. Spike-validated (SPIKE-nas-storage Q8).
Dockerfile: restic was dropped when cross-drive migrated restic→rsync — re-addedrestic+openssh-client(restic's sftp backend shells out tossh); version pinned by the Debian release.internal/backup/offbox.go(NEW): the restic-SFTP backend + orchestration.- Fail-fast (the load-bearing spike Q8 lesson): every restic call carries
-o sftp.args=…-oConnectTimeout=10…so a dead NAS errors in ~10 s instead of a multi-minute TCP hang. Also-oStrictHostKeyChecking=yes -oUserKnownHostsFile=<pinned>(no blind TOFU) +-oBatchMode=yes. - init-if-absent (idempotent — a present repo is reused, never re-init), per-app
restic backup --tag,forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --pruneretention, single-flight (sharesm.running) + migration-guard, restic's own exit code checked (never pipe-swallowed), restore viarestic restore latest --tag <app> --target <scratch>(non-destructive). - Secrets: the SSH private key + the auto-generated repo password are 0600 files in the data dir — never logged, never in a committed/non-0600 file; the repo is encrypted so the NAS sees only ciphertext. They ride DR via the PBS whole-CT snapshot of the rootfs (the data dir), so a rebuilt box can reach the off-box repo (the recovery-unit/dr-recipe stay secret-free).
- Fail-fast (the load-bearing spike Q8 lesson): every restic call carries
internal/settings/settings.go:OffboxTarget(host/port/user/repo path/schedule + runtime status, no secrets) + per-appAppBackupPrefs.Offboxtoggle + helpers.cmd/controller/main.go: dailyoffbox-backupschedule (04:15) gated on enabled+configured; a failure (incl. fail-fast dead-NAS) alerts the operator via the allowlistedbackup_failedevent.- UI (
backups.html): "Külső (NAS) mentés" section — target config (host/port/user/repo + out-of-band SSH key + known_hosts textareas), status (last run / repo size / snapshots), per-app toggles, run-now, restore-to-scratch. - Tests: ConnectTimeout present in base args + the fail-fast companion red-proof (a fake SSH transport
hangs to the ctx deadline WITHOUT the arg, fails fast WITH it); dead-NAS run fails fast + alerts + records
status; restore round-trip byte-identical (SFTP-shaped seam); single-flight skip; repo idempotency;
secrets are 0600.
go build/vet/test ./...green.
v0.92.0 — NAS network storage Part A2: registry + UI + per-share health (2026-06-30)
The controller side of NAS network storage, proxying to the validated agent foundation (felhom-agent
v0.50.0 /netstorage/*). An operator can add a customer's NAS share and point a media app at it — all via
the UI. A NAS is a distinct storage kind (NOT a drive): no enroll/eject/decommission/migrate/wipe/SMART.
internal/agentapi/client.go:AddNetStorage/ListNetStorage/RemoveNetStorage+ theNetworkMountStatusmirror (healthok|idle|unreachable;idleis benign, onlyunreachabledegraded). The SMB credential is passed STRAIGHT THROUGH to the agent (which writes the 0600 file) — never persisted by the controller.internal/settings/settings.go:StoragePath.Kinddiscriminator (""/drive|network) + the network descriptors (Protocol/Server/Export/MappedUID/MappedGID — no password);IsNetwork()+IsNetworkStoragePath();NetworkMountRoot.internal/web/netstorage_handlers.go(NEW):POST /api/storage/netstorage/{add,remove}+GET /api/storage/netstorage; registers/deregisters a Kind=networkStoragePath; merges the agent's live per-share health for the UI.- Kind-gating (the safety centerpiece):
refuseNetworkLifecycleblocks the drive ops (eject/decommission/migrate/wipe) on a network path server-side; the drive-absent gate (planDriveGates) and the missing-storage surface now SKIP network paths — so anunreachableNAS is a recoverable warning, never the drive "missing → stopped" cascade (Scenario C).networkStorageWarningsdrives a distinct "Hálózati tárhely nem elérhető" app-card badge. - UI (
settings.html): a "Hálózati tárhely (NAS)" section — add form (NFS/SMB, server/export, uid, SMB creds), per-share health badges, remove. Network shares are auto-selectable as a media app'sHDD_PATH(they register Schedulable). Hungarian, minimal emoji. - Tests: agentapi round-trip (creds forwarded, health states); registry Kind-gate companion (drive
lifecycle refuses a network path; a drive path is not gate-refused);
unreachable≠missingcompanion (the drive gate stops an absent drive but NOT a network path).go build/vet/test ./...green.
v0.91.0 — F2: alert on born/persistent-down channel (not only transitions) (2026-06-29)
- What: closes F2 from the full-stack testrun — a channel failure present at startup/reseed
(e.g. the controller boots right after a leaf regen → first observation is
pin_mismatch) was dashboard-only, no operator email, forever. Now a born-down non-transient reason alerts on cycle 1. internal/channelhealth/checker.go: added analertedflag (have we emitted for the CURRENT down-spell?). A confirmed down that comes from up/unseeded OR changes reason re-arms (alerted=false) then emits once; a steady down that already alerted does not re-fire; recovery (up) re-arms. Removed theprev==""silent-seed-for-down branch (a born-down IS a real down-spell). Debounce stays intact: a transient born-down (refused) still needs N≥2 (the cold-boot agent-not-yet-up race), and a healthy first-obs still seeds silently.- Tests: F2 born-down non-transient red-proof (one alert cycle 1) + companion showing the old
seed-silent path would not have alerted; born-down transient still debounced; recovery re-arms the
spell. Version
0.90.0 → 0.91.0.
v0.90.0 — Controller→agent channel health-check (periodic probe + classified operator alert) (2026-06-29)
- What: the next self-health slice — a ~60s scheduler job that proves the controller↔agent
local-API channel, classifies failures, and alerts the operator + dashboard on a state change.
Closes the gap the R1 pin-mismatch incident exposed (the channel was only checked once at startup and
only logged). Spike-proven:
felhom.eu/documentation/audits/SPIKE-controller-agent-channel-health-2026-06-29.md. internal/channelhealth(NEW): aCheckerover two seams — aProbe(the channel call) and aSink(dashboard + operator notify). Each run classifies the result intoup | down:<reason>by the spike Q1 error-substring map (pin_mismatch/unauthorized/unreachable/timeout/misconfigured/construction_error/unknown). Debounce: transient reasons (refused/timeout) require N≥2 consecutive down-probes before alerting — so the ~1s agent-restart socket gap (spike Q2) does NOT page; pin/401/DNS/construction alert on the first down observation. First scheduler observation seeds state without notifying (mirrors host_staleness/host_capability). A construction error (agentClient()can't build — latches viasync.Once) is surfaced distinctly.- Probe via the PRODUCTION memoized client (
Server.ProbeAgentChannel): GET /storage throughs.agentClient()— NOT a freshagentapi.Newper probe. Per the spike, the memoized client self-heals after an agent restart, reflects exactly what the disk UI sees (zero divergence), and avoids the per-call transport leak the singleton fixed. - Operator alert + dashboard:
Notifier.NotifyAgentChannelDown/Recoveredrelay an English, operator-only event (the customer can't act on "the agent re-keyed"; the event type isn't a customer toggle, same as the host_* events) on a transition (up→down, down→up, or reason-change), with the existing hub 1h cooldown.AlertManager.SetAgentChannelAlertshows a short Hungarian banner whenever the channel is down (state-based, idempotent — a born-down channel shows even though it's seeded silently). No customer email. No agent or hub change (the hub relays the new event types generically). - Tests: classifier per-reason, transitions (up→down once, no duplicate, recovered, reason-change
re-alerts), first-obs seed, and the debounce red-proof (one refused → no alert; two consecutive →
exactly one). Version
0.89.0 → 0.90.0.
v0.89.0 — App-email: plaintext-only listener (:2526) + split-From mapping (2026-06-29)
- What: closes the two relay gaps from
FINDING-app-email-rollout-2026-06-29.mdso the opportunistic-STARTTLS clients (cal.com, nextcloud) can use the relay. - Gap 1 —
internal/mailrelay/server.go: a third listener:2526that is plaintext and does NOT advertise STARTTLS (TLSConfigleft nil ⇒ go-smtp omits the STARTTLS capability from EHLO). Clients that opportunistically upgrade to STARTTLS and then validate the cert with no skip-verify knob (Nodemailer/Symfony Mailer) never attempt TLS against it. Accepted posture: plaintext on the single-tenant app Docker bridge only (never host/internet).:2525(STARTTLS) and:2465(implicit-TLS) unchanged. New configmail_relay.plain_no_tls_listen(default:2526). - Gap 2 —
internal/stacks/metadata.go+mailenv.go:SMTPMappinggainstls_mode(""/starttls→2525 default;plaintext→2526;implicit-tls→2465 —smtpEnvnow picks the port from it instead of the hardcoded 2525) andfrom_domain_var(split-From: when set, injectFromVar=<local>+FromDomainVar=<domain>separately, for nextcloud'sMAIL_FROM_ADDRESS+MAIL_DOMAIN; unset = the current<local>@<domain>). - No regression: default
tls_modekeeps vaultwarden/gitea/rallly on 2525 and mealie's plaintext path unchanged; the hub is untouched (it relays whatever raw MIME the shim sends). - Tests:
smtpEnvport-by-tls_mode (+ companion that plaintext≠starttls port), split-From (+ companion single-From), and the:2526listener hasTLSConfig==nil& a real EHLO showing it does NOT advertise STARTTLS while:2525does.
v0.88.0 — App-email SMTP relay: in-process shim + per-app injection (2026-06-29)
- What: deployed apps can now send outbound email (password resets, invites, confirmations) through one
managed path — app → in-controller SMTP shim → hub → Resend — with the Resend key staying hub-side.
Implements
SPIKE-smtp-app-relay-2026-06-28.md(verdict READY). Architecture: Shape 1, the shim runs in-process inside the controller (operator-confirmed), reusing the controller's existing hub client. - New
internal/mailrelay/: ago-smtpserver with two listeners —:2525plaintext+STARTTLS and:2465implicit-TLS (self-signed cert generated at boot, CN/SAN = the shim service name). Advertises AUTH PLAIN+LOGIN and accepts any credentials, ignoring them (apps send none; some require the offer; a ~15-line LOGIN sasl server fills go-sasl's gap).policy.govalidates the From header domain against an allowlist (defaultfelhom.eu) and rejects with a clean 5xx before any hub call.forward.goPOSTs the raw MIME to the hubPOST /api/v1/mailwith the controller's hub Bearer key — single-shot (no retry, no spool in v1); the hub HTTP status maps to an SMTP reply (2xx→250, 4xx→451, 5xx→554) so the app surfaces the real outcome.lifecycle.gostarts/stops the shim at runtime so the global toggle takes effect without a controller restart. Listeners bind to the app Docker network only — never host/internet. - Settings + injection: new global app-email toggle (
settings.AppEmail{Enabled,FromName}); new.felhom.ymlsmtp_mappingblock (renames host/port/security/from/from-name to an app's env keys, plus fixedextravars); per-app toggle persisted inapp.yaml(AppConfig.EmailEnabled). The relay env is injected at compose time instackEnv(host=shim, port=2525, security/from per mapping) only when global ON + per-app ON + the app has a mapping — derived each compose, never persisted. Newconfig.MailRelayConfig(listeners, shim host, From allowlist; kill-switch). - UI (Hungarian): Settings page "Alkalmazás-email" card (global toggle + optional household From-name);
per-app "Email-küldés" toggle on the deployed app's config page (only for apps with
smtp_mapping), save → recreate the stack to apply. - Tests:
mailrelay(happy-path passthrough byte-equality, From-reject-before-forward + companion, single-shot-on-hub-failure + companion, status mapping, LOGIN lifecycle, real-socket STARTTLS end-to-end);stacks(mapping parse, both-toggles-on injection, per-app/global-off no-injection, no-mapping, Mealie-style mapping, household From-name). New depgithub.com/emersion/go-smtpv0.24.0 + go-sasl.
v0.86.0 — Phase 2 managed updates: floor-driven auto-update (2026-06-27)
- What: the controller now honors an operator-enforced minimum version (FLOOR) delivered on the hub report ACK and auto-updates to the floor when below it — the managed default (no customer click). The customer "update to latest" button is unchanged (latest, opt-in); the floor is the auto-target, never latest.
internal/report/pusher.go:PushResponsegainsmin_controller_version+latest_version(the pusher already parsed the ACK forcustomer_blocked— extended, not a new path). (The task pointed atnotify/notifier.go's response-discards, but the actual report sender ispusher.go, which already had anOnPushResponseseam — used here.)cmd/controller/main.go: the existingOnPushResponsecallback now also callsupdater.SetFloor(resp.MinControllerVersion)+updater.MaybeAutoUpdate()— riding the existing report cycle; no new timer/endpoint.internal/selfupdate/updater.go:SetFloor/GetFloor+MaybeAutoUpdate()which reuses the Phase 1performUpdate(in-guest pull → agentSwapController→ rollback on failure) with the floor as target (initiatedBy="auto-floor"). Strict no-op unless: floor set, current parses, current < floor (at/above = nothing — does NOT chase latest), agent wired, no backup running, no swap in flight, not already attempted this floor (in-memory + persisted-state guard = no flapping/storm), and the floor is pullable (floor ≤ latest available; floor > latest → warn + do nothing).- UI (settings, Hungarian): shows "Minimális verzió (üzemeltető): X" when a floor is set, and during
an auto-update surfaces the same restart-poll panel as the button (auto-polls
/api/healthon load). - Tests (
internal/selfupdate/floor_test.go): below-floor→floor (not latest); at/above→no-op; no-floor inert; floor>latest→no chase + warn; no-flap (one swap across repeated reconciles); raised-floor honored (Scenario C/E); dev/no-agent→no-op. Companion red-proof (verified): makingMaybeAutoUpdatealways no-op fails the below-floor test → restored → green. - No agent change (reuses Phase 1 swap). Live (demo 9201): dogfood-deployed 0.86.0 via the Phase 1
self-update (the exact endpoint the Settings button invokes), then global floor set to 0.87.0 → the box
auto-updated 0.86.0 → 0.87.0 with no click (
last_state.initiated_by="auto-floor", success); at/above-floor produced no second update (no flap). Hubcontroller_version=0.87.0. See REPORT.md.
v0.85.1 — version-only build (live self-update validation target) (2026-06-26)
- No code change vs v0.85.0. Pushed as the registry "latest" so the live e2e self-update path could be
validated via the real Settings button (demo
0.85.0 → 0.85.1: in-guest pull → agent swap → reload).
v0.85.0 — Self-update reworked: in-guest pull + agent swap (Phase 1) (2026-06-26)
- Problem: the self-update button was dead in the LXC architecture —
selfupdate/updater.godrove the old bare-metal flow (docker compose -f /opt/docker/felhom-controller/docker-compose.yml up -d), a path that doesn't exist in the guest ("docker-compose.yml nem elérhető"). The stranded 0.77.0 demo could detect 0.84 but not install it. - Fix (Phase 1): the controller now pulls the target image in-guest (its existing registry token
via
docker login --password-stdin→docker pull→docker logout, over the shared docker socket), then delegates the swap to the host agent (agentapi.Client.SwapController→ agentPOST /controller/swap). The agent — external to the controller container — rewrites/etc/felhom-controller-image, restarts the bootstrap unit, verifies health, and rolls back if the new controller doesn't come up. The controller neverdocker rm/recreates itself. - Removed the dead compose path:
performUpdate/updateComposeFile/composePathand thedocker compose up -dflow are gone.DryRunnow reportsagent_reachable+pull_capableinstead ofcompose_writable. - Success/failure is detected on the next boot by the existing
VerifyStartup(running version vs target) — a rollback lands the previous version → "failed (version mismatch)". The UI button + poll (triggerUpdate/pollUntilBack) are unchanged. Latest-only (no version picker — Phase 2). agentapi: newSwapController(202) +SwapStatus.NewUpdatertakes anAgentSwapper(nil on an un-provisioned guest → update unavailable) instead of a compose path.- Tests (
internal/selfupdate/updater_test.go): up-to-date → no pull/no agent; pull-fails → agent never called; happy → pull then oneSwapControllerwith the right ref; no-agent → unavailable.
v0.84.0 — Show an app's auto-generated initial login on its page (catalog-driven) (2026-06-26)
- Problem: some apps generate a random first-login password into a file at first boot (Crafty →
/crafty/app/config/default-creds.txt) instead of taking it from a deploy field. Customers had to read the container logs to find it — the staticapp_info.default_credshint can't carry a per-install secret. - General, catalog-driven mechanism (not Crafty-specific):
.felhom.ymlgains an optionalinitial_credentialsblock:{file, format: json|regex|plain, container?, username_key/password_key (json), username_pattern/password_pattern (regex), note}.internal/stacks/metadata.go: newInitialCredentialsstruct +Metadata.InitialCreds(deep-copied indeepCopyStack).internal/stacks/initialcreds.go:ReadInitialCredentials(stack)reads the file live from the running container (docker exec <c> cat <file>— path passed as a single arg, no shell) and parses it via the pure, unit-testedparseInitialCreds(json/regex/plain). Never persists the secret toapp.yaml; returns a non-Available result (card hidden) when the container is down / file missing / parse fails. Container defaults to the stack's main container (findProbeContainer).internal/web/handlers.go:appDetailHandlerpopulatesInitialCredsfor deployed apps with the spec;app_info.htmlrenders a "Kezdeti belépési adatok" card with username + masked password (Megjelenítés/Másolás, value read from a hidden element — never inlined into JS), labelled clearly as the initial password (stays valid only until the customer changes it in-app).- Tests:
parseInitialCredsjson/regex/plain + error paths.
- Security note: this surfaces a live working credential on the app page — same exposure class as the
existing post-deploy password reveal and
default_credscard. It relies on the dashboard being auth-gated in production (the demo's public-unauth dashboard is a separate, pre-existing tracked issue). - Paired with
app-catalog-felhom.euadding theinitial_credentialsblock to crafty-controller.
v0.83.0 — Traefik scoped serversTransport for self-signed HTTPS backends (fixes crafty 502) (2026-06-26)
- Problem: the crafty-controller healthcheck fix (catalog
68ce009) un-withheld its Traefik route, exposing a pre-existing 502 — Traefik proxied HTTP to Crafty's HTTPS-only self-signed backend on:8443. Crafty is the first/only catalog app with an HTTPS backend; all others serve plain HTTP, so Traefik's default HTTP transport works for them. - Fix (scoped, Option B — verification stays ON by default): the controller now renders a Traefik
file-provider dynamic config defining a named
insecure-skip-verifyserversTransport (http.serversTransports.insecure-skip-verify.insecureSkipVerify: true). A service opts out of backend TLS verification only by referencing it (serverstransport=insecure-skip-verify@file) — no globalinsecureSkipVerify(the rejected Option A).insecureSkipVerifyis not settable via Docker labels in Traefik v3, so it must live in file/static config; the matchingscheme=https+@filereference labels go on the app (catalog repo).internal/infra/infra.go: new pureRenderServersTransports()+ exportedServersTransportInsecureconstant.internal/stacks/infra.go: newensureServersTransports(traefikDir)writesdynamic/serverstransports.yml(0644) idempotently (write-only-on-change, likewireController, so the traefik file-watcher doesn't reload each self-heal tick). Called fromEnsureBaseStackoutsideensureTraefik(which early-returns when traefik is already running) so an established node still materializes the file on the next self-heal tick; the watcher hot-loads it (no traefik restart).- Rationale for skip-verify: a per-container self-signed cert has no CA to verify against and the hop never leaves the host's internal docker bridge.
- Paired with
app-catalog-felhom.euaddingscheme=https+serverstransport=insecure-skip-verify@fileto the crafty service. Tests:TestServersTransports+ the YAML-parse matrix.
v0.82.0 — FileBrowser sync no longer bounces the file UI on no-op; drop dead restic binary (2026-06-24)
- F2 — gate the FileBrowser recreate on an actual change.
syncFileBrowserMounts(internal/web/handlers.go) previously randocker compose up -d --force-recreate --remove-orphansunconditionally, so every controller restart and every storage sync force-recreated the FileBrowser container even when itsconfig.yaml/compose were byte-identical — bouncing the customer's file-access UI and contradicting the "Vezérlő újraindítása → apps keep running" promise. Now it captures the on-diskconfig.yaml+compose before the writes and re-reads the final content after them (so the integrations'ReapplyConfigForTargetedits are included), and recreates only when something actually changed via the new pure helperfbNeedsRecreate(oldCfg,newCfg,oldCompose,newCompose); otherwise a plainup -densures it's running without a bounce. The restore-mode DB reset (sourcesChanged && resetDBOnChange→down -v) is preserved and forceschanged=true(a reset removes the container). First-ever run (empty old files) still recreates. Unit-tested (filebrowser_gate_test.goTestFbNeedsRecreate, incl. red-proof against the old unconditional behaviour). - F1 — dropped the unused
resticbinary from the image (controller/Dockerfile). The disk-tier restic work moved to the host agent; no controller code execs the binary (the only"restic"references are a backup-dir-name comparison and theMethodconfig string, both unaffected). Removed theresticapt line and its comment. TheResticSchedule/migrateResticToRsyncconfig+settings paths are untouched (still live in the dashboard).
v0.81.0 — retire the drive-activation banner; add a standalone "Kiszolgáló újraindítása" button (2026-06-23)
- Removed the obsolete drive-activation banner. In the intermediary-mount model an enrolled drive
binds live into the running guest (agent
disks.go— nopct set -mpN, no slot, no reboot), so the "… meghajtó aktiválásra vár / Újraindítás most (~30 mp)" banner was a relic of the old per-drive reboot model. It was also effectively dead since v0.78 (pendingActivationDriveskeyedattachedby the agent's RAWMountPathbut compared it to the now-STABLEsp.Path). Removed: the{{if .PendingDrives}}banner block +window.activatePendingDrivesJS (settings.html), thedata["PendingDrives"]feed (handlers.go), and the deadpendingActivationDriveshelper + its now-unusedinternal/systemimport (storage_handlers.go). - Repointed the reboot endpoint to a non-storage route. Renamed
handleStorageActivate→HandleServerRebootand split out a testableserverRebootcore (mirrorsrunStorageInit); removed the/api/storage/activatecase fromServeStorageAPI; mounted the handler at the new/api/server/reboot(sameRequireAuth+CsrfProtect) incmd/controller/main.go. The agentGuestRebootprimitive is reused unchanged. (/api/storage/activatenow returns 404.) Note: the handler is exported (HandleServerReboot) becausecmd/controller/main.gowires it cross-package — same convention as every other web handler mounted there. - Added the standalone "Kiszolgáló újraindítása" settings card. A deliberate full-server (guest)
restart affordance, a sibling to the existing "Vezérlő újraindítása" controller-only restart.
New
settings-card+restartServer()JS (reuses the existingpollRestart()loop) insettings.html; posts to/api/server/reboot. - Test:
TestHandleServerReboot_CallsGuestReboot(storage_handlers_test.go) — a fakediskAgentassertsGuestRebootis invoked exactly once and the response is 202{ok:true, rebooting:true}.diskAgent/mockAgentgainedGuestReboot. Green gate:go build ./... && go vet ./... && go test ./....
v0.80.0 — disk card: show + act on the stable path, not the raw host mount (2026-06-23)
- Follow-up to v0.78/0.79. The storage disk card displayed the drive's raw host PVE mount
(
/mnt/<name>) — which doesn't exist inside the guest — instead of the stable in-guest path (/mnt/felhom-drives/<name>, theguest_path) the registry, appHDD_PATH, and FileBrowser use. - It also passed the raw path to the Leválasztás/Törlés buttons, so those would unmount the drive
but leave its stable registry entry orphaned (
RemoveStoragePathis keyed on the stable path), and the impact warning (/api/storage/impact?where=) found no affected apps (HDD_PATH is the stable path). - Fix (
settings.html): the card sub-line + the eject/wipe buttons now use the stable path (regKey(d)); the type-to-confirm name is derived from the basename so it still matches the server check; register keeps posting the raw path (its agent guest-attach operates on raw).handleStorageWipenow maps the registered path to raw viaagentWhere()for the agent eject (matchinghandleStorageEject), so the drive deregisters cleanly. Agent-facing ops are unchanged (same raw paths); only display + the controller's own registry bookkeeping are corrected.
v0.79.0 — disk view: key the "registered" check on the stable path (2026-06-23)
- Follow-up to v0.78.0. The storage disk-view JS (
settings.htmlregBadge/actions) decided whether a drive was registered by looking up its rawmount_path(/mnt/<name>) in the registry — but since v0.78.0 the registry correctly stores the stable path (/mnt/felhom-drives/<name>), so an enrolled, working drive showed a spurious "Nem regisztrált" badge + "Regisztrálás" button. - Fix: new
regKey(d)=d.guest_path || d.mount_path(the agent already reports the stableguest_pathper disk);regBadge/actionsnow key on it.registerDrive()still posts the RAWmount_path(the agent operates on raw;handleStorageRegistermaps it to stable). Display-only.
v0.78.0 — storage register: use the STABLE intermediary path, not the raw path (2026-06-23)
- Bug:
handleStorageRegister(the "Regisztrálás" action for an already-mounted, unregistered drive) registered the raw/mnt/<name>host path verbatim, unlike its siblingsrunStorageInit/runStorageAttachwhich map throughstablePathForNameto the stable intermediary path/mnt/felhom-drives/<name>. The agent binds the drive at the stable path (intermediary model), so the controller ended up watching an empty placeholder dir on the guest rootfs → the drive showed as on the system drive ("Rendszermeghajtón", ~31 GB),0 connected / N disconnected, and the "… meghajtó aktiválásra vár" banner never cleared (registerStoragePath→EnsureUserdataSkeletonevenmkdir'd those rootfs placeholders). Surfaced after a destroy+re-provision, where surviving host mounts make "Regisztrálás" the natural action. Diagnosis:felhom.eu/documentation/audits/DIAGNOSE-drive-bind-after-reprovision-2026-06-23.md. - Fix (
internal/web/storage_handlers.gohandleStorageRegister): registerstablePathForName(path.Base(req.Where)), matching init/attach.attachIntoGueststill receives the raw path (the agent operates on raw); the success log + JSON now report the stable path (+ raw). Test:TestHandleStorageRegister_RegistersStablePath(+ red-proof). No other behavior changed.
v0.77.0 — per-app open_path for the "Megnyitás" link (2026-06-23)
- The dashboard/deploy/app-info "Megnyitás" (open) button was hardcoded to the bare subdomain root
https://{sub}.{domain}for every app. Apps whose UI isn't at/(e.g. Gokapi redirects/away; Ghost's admin is at/ghost/) opened to the wrong place. - New optional
open_pathfield onMetadata(internal/stacks/metadata.go,.felhom.yml) appended to the URL in all three link sites (dashboard.html,deploy.html,app_info.htmlvia.Meta.OpenPath). Empty = bare root (unchanged for the other 50 apps). No handler changes (all three templates already carry.Meta). Catalog:gokapi→/admin,ghost→/ghost/.
v0.76.0 — campaign-#3 hardening: settings recovery, restore-name validation, quiesce-marker quarantine (2026-06-22)
Three controller findings from chaos campaign #3, all small, all controller-side.
- S1 [MEDIUM] — no more crash-loop on a corrupt
settings.json.internal/settings/settings.go:save()now writes a last-known-good<path>.bakafter the primary rename succeeds (best-effort);Load()on a JSON-parse error recovers from.bak(re-promotes it to primary) and, failing that, preserves the corrupt file as*.corrupt-<ts>and starts on safe defaults — never returns the error that mademain.goFatalf/crash-loop. NewSettings.LoadWarningsurfaced as a dashboard banner. (main.go'sFatalfstays — now only the genuine IO-unreadable path is fatal.) Recovery is safe: an emptyPasswordHashfalls back tocontroller.yaml, the storage registry re-discovers. - F2 [MEDIUM, defense-in-depth] — validate
stack_nameagainst path traversal. Newweb/validate.govalidStackName(single segment; rejects/,\,.., NUL). Gated inbackupRestoreHandler(handlers.go) andapiExportStart(handler_export.go) before any restore/export work. (Storagewhere=was already validated bygateWhere.) - S3 [LOW] — quarantine a corrupt quiesce marker.
quiesce/quiesce.goreadMarkernow logs a[WARN]+ renames a bad-JSON marker to*.corrupt-<ts>instead of silently dropping it (still returns "no marker" → no recovery, the correct contract). - Tests: T-S1a-d (settings recovery), T-F2a-c (validation + both handlers), T-S3a/b (quarantine), all red-proofed against the pre-fix code. Agent/hub untouched.
v0.75.0 — gate userdata MkdirAll on a live mountpoint (no writes into an absent drive) (2026-06-22)
Bugfix — two MkdirAll-into-<drive>/userdata sites fired without checking the drive was mounted,
producing mkdir …/userdata: permission denied + transient Created flapping during a drive-absent
window (campaign-#2 findings #2/#3). Worse than noise: writing into an unmounted mountpoint lands app
data on the guest rootfs, shadowed when the drive returns (data-integrity + rootfs-fill hazard).
internal/stacks/manager.go—ensureUserdataMounts(the deploy belt) now skips when theHDD_PATHdrive root is an external path (notsysDataPath) that is not a live mountpoint; the app is held byplanDriveGatesinstead. New injectableManager.isMountPointseam (defaults tosystem.IsMountPoint) for testability. The system/local path is never gated (it's legitimately not a mountpoint).internal/web/handlers.go— the FileBrowser sync loop skips (and does not mount) a registered path underStableParentDirthat isn't a live mountpoint, via a new pureskipFileBrowserPathhelper. MatchesplanDriveGates' external-only rule.EnsureUserdataDir/EnsureUserdataSkeleton/planDriveGatesunchanged (gated the callers).- Tests:
TestEnsureUserdataMounts_{SkipsAbsentExternalDrive,EnsuresWhenMounted,SystemPathNeverSkipped}TestSkipFileBrowserPath(both red-proofed against the pre-fix code).
- Boot-time occurrence (docker boot-restore starting drive-backed apps before the agent mounts the drives) is a separate cause — documented as a design note (CONTEXT.md), not changed here.
v0.74.0 — fix the controller→agent connection leak (per-call agentapi client) (2026-06-22)
Bugfix — agent local-API socket leak that took down the whole agent-backed feature set after ~5 days.
Server.agentClient() built a fresh agentapi.Client (hence a fresh bare http.Transport with
IdleConnTimeout:0) on every call and discarded it without closing idle connections. The agent's
keep-alive left one idle ESTABLISHED socket per call to 192.168.0.162:8443; these accumulated
(~5.8k/day, measured 206 in 47 min) until the ephemeral source-port range for that tuple exhausted →
connect: cannot assign requested address (EADDRNOTAVAIL), killing storage UI, host-metrics, and
whole-guest backup. (:8006/pveproxy was immune — the controller never dials it.) Diagnosis:
felhom.eu/documentation/tests/unattended-test-campaign-2026-06-22-8443-diagnosis.md.
internal/web/server.go—ServergainsagentCli *agentapi.Client+agentCliErr error+agentCliOnce sync.Once(and theagentapiimport).internal/web/agent_disk_handlers.go—agentClient()now memoizes the build viaagentCliOnceand reuses one shared client (cfg.LocalAPI is static per process — a config-apply self-restarts). The empty-endpoint "not configured" guard stays OUTSIDE the Once. All 19 call sites unchanged.internal/agentapi/client.go—NewTransport hardened:MaxIdleConns:4,MaxIdleConnsPerHost:2,IdleConnTimeout:90s(was a bare Transport,IdleConnTimeout:0). Added optionalClient.Close()(CloseIdleConnections) hygiene helper.- Tests:
TestAgentClient_ReusesSameInstance(+TestAgentClient_UnconfiguredErrors) andTestNew_TransportIdlePoolBounded— both red-proofed against the pre-fix code. - Agent, its bridge-IP bind, and firewall rules were not touched (controller-only fix).
Separate open item: the defense-in-depth host firewall rule scoping
:8443to the guest bridge subnet is still absent (pve-firewall disabled) — to be closed independently.
v0.73.0 — DR recipe: emit the secret-free customer+apps half in the hub report (2026-06-16)
DR recipe slice (controller half). Additive dr_recipe section on the controller's hub report — the
customer + apps half of the secret-free reconstruction recipe (SPIKE-dr-recipe-2026-06-16.md). The hub
assembles it with the agent's storage/guest/PBS half into one customer recipe.
internal/report/dr_recipe.go—DRRecipeAppHalf{recipe_version, customer{id,display,domain}, apps[]}built by the pureBuildDRRecipeAppHalf(...)over the DEPLOYED, non-protected stacks. Per app:AppRecipe{catalog_ref (Meta.Slug, falls back to name), enabled, storage_bindings[]}. Storage bindings are parsed from the compose (appStorageBindings) — each${HDD_PATH}/${USERDATA_PATH}volume bind becomes{container_path, drive (basename of HDD_PATH), subpath}(e.g. romm → felhom-flash:userdata/roms); named volumes are excluded. Wired intoBuildReport.- THE BOUNDARY (the emitter is the enforcement point). v1 ships an explicit ALLOWLIST — only the three
fields above — and the emitter reads NOTHING from
AppConfig.Env, so noENC:value / token / password can ride along. Allowlist, not denylist → a new field is excluded by default. - Tests (the load-bearing no-secrets boundary test + companion):
TestBuildAppRecipe_NoSecretsfeeds an app whoseEnvcarries synthetic secrets (anENC:value + a token-shaped value) and asserts the emitted recipe contains NONE of those values and NO credential-shaped key;TestBuildAppRecipe_AllowlistIsLoadBearingis the red-proof (a guard-removed shape leaks the token, the production emitter does not);TestAppStorageBindings(+_NoHDD) pins the compose parse; andTestBuildDRRecipeAppHalfchecks assemble-correctness (deployed/non-protected only) with a whole-half secret sweep. Red-proofed live: forcing the emitter to dumpEnvmakes the boundary test fail.recipe_version=1, ignore-unknown on read.
v0.72.0 — FileBrowser converges on boot-recreate (2026-06-16)
Follow-up to v0.71.0: a host-reboot test found processGuestBootChange recreated the drive-backed app
stacks but never re-synced FileBrowser, so its drive mounts went stale after a reboot (FileBrowser
binds each drive's userdata but is base-infra with no HDD_PATH, so it is not in the recreate set).
Now, after pollLiveBinds confirms the live binds and the apps are recreated, the boot-recreate path
triggers go s.SyncFileBrowserMounts() so FileBrowser converges against the now-live drives (the sync
runs unconditionally so FileBrowser reflects the current bind state even if no app needed recreating).
Refactored the recreate loop into a pure, testable recreateDriveBackedApps(stacks, present, recreate, syncFB). Tests: FileBrowser sync invoked once, AFTER every recreate (red-proofed companion: pre-fix path
never synced); and synced even when nothing was recreated. Pairs with felhom-agent v0.37.0's host-reboot
remount-by-UUID fix. Live-accepted with TWO real felhom-pve reboots: both fired the boot-recreate
(live bind confirmed — recreating … → re-syncing FileBrowser mounts → FileBrowser mounts synced — 3 storage path(s)), all drive-backed apps recovered, FileBrowser non-stale — while the agent tolerated a
/dev/sdb↔/dev/sdc letter swap by mounting each drive by UUID.
v0.71.0 — fix guest-reboot recovery of drive-backed apps (boot-race + the agent-path blocker) (2026-06-16)
A pct reboot of the guest left drive-backed apps (audiobookshelf, calibre-web, immich-server,
jellyfin, komga, radarr, romm, paperless-webserver) stuck Exited forever. On guest boot the in-guest
dockerd auto-starts the unless-stopped apps ~18s before the agent re-binds the drive under the
stable parent; the create-time volume bind fails (mkdir /mnt/felhom-drives/<drive>/userdata: permission denied on the empty fail-closed placeholder) and, being a create-time failure
(RestartCount=0), is never retried. The intended recovery (processGuestBootChange) did not fire.
Live diagnosis pinned three sub-causes, fixed together (harden the existing mechanism — no parallel
one):
- The agent-path blocker (the live root cause).
agentClient()returned "agent not configured" —cfg.LocalAPI.Endpointwas empty — soprocessGuestBootChange(and the entire drive gate) bailed at its first guard, never reaching any boot-id/bind logic.bootstrap.jsonhad a completelocal_apiblock, butMaybeIngestreturned immediately on "already configured" (customer.id set), so a controller.yaml seeded beforelocal_apiexisted never got the agent path merged. Fix:MaybeIngestnow calls newensureLocalAPIon the already-configured path — it mergeslocal_apifrom bootstrap.json into the existing controller.yaml when missing (no hub re-pull, config preserved), idempotent + fail-safe. - The boot-race readiness gate.
processGuestBootChangesampled the agent'sBoundUnderParentonce during fast startup, racing the ~18s rebind, recreated nothing, and burned its boot-id one-shot. Fix: it now gates on the REAL live in-guest bind — newdriveBindLivechecks whether/mnt/felhom-drives/<drive>is an actual mountpoint in the controller's own/mnt(rslave)/proc/self/mountinfo(true only once the agent's bind propagated, exactly when docker can recreate the app), and newpollLiveBindswaits for it (bounded ~120s, poll 2s) before recreating via the normal pipeline (compose down→up -d).shouldRecreateOnBootis unchanged and state-independent, so a stuck-Exitedcreate-time-failure app is included. - The single-shot fragility.
processGuestBootChangeran only once at startup; right after a guest reboot the agent's local API can be briefly unreachable/stale, so the one attempt bailed and never retried. Fix:driveGateLoopnow runs it on every periodic tick too — idempotent (boot-id gated), so it retries until the agent is reachable.
Apps on a drive that never goes live in the window are left to the normal gate. The host-reboot path the earlier sweep validated is unaffected (same code path, strictly more robust); the guest-only reboot path (never exercised by host-reboot sweeps) is now covered.
Tests (non-hollow, with pre-fix companions, red-proofed): pollLiveBinds waits through the rebind then
reports live (recreate fires) / never-live stays absent / a single early sample misses the not-yet-live
bind; ensureLocalAPI merges local_api into an already-configured controller.yaml that lacks it
(companion: pre-fix MaybeIngest left it empty) and no-ops when already present. Live-accepted with
guest reboots ×2 AND host (felhom-pve) reboots ×2 — all 8 drive-backed apps recover automatically,
zero manual starts; the persisted boot-id now advances per boot (it had been frozen at the first-boot
value). Note: the agent path worked at the v0.68 acceptance (it surfaced a real bug there) and regressed
afterward — controller.yaml is reset to the golden's no-local_api baseline on each container recreate
and the old MaybeIngest never re-merged it; ensureLocalAPI closes that. The boot-race manifests on
host reboots too (not just guest), so both paths needed this fix.
v0.70.0 — config-apply self-restart + geo-restriction UX fixes (2026-06-16)
Fixes found during live geo testing (rotating the Cloudflare API token).
- Config-apply now self-restarts (core fix).
POST /api/config/applypreviously wrote the newcontroller.yamlbut logged "restart needed" and left stale in-process singletons — the Cloudflare client is built once at startup, so a rotated CF token kept 403'ing until a manual LXC restart. Now: if the pushed config is byte-identical to the current one, do nothing (no flap on idempotent re-push); otherwise write, respond 200 (flushed), then gracefully self-restart (os.Exit(0)after ~500ms; the container isrestart: unless-stopped, so it comes back with fresh config). The exit is behind an injectableRestarterseam (Router.restart/SetRestarter) for unit testing. Removed the stale "restart needed" wording and the deadOnConfigAppliedhook (Phase-1-retired infra-backup push). - Manual "Vezérlő újraindítása" button on the settings page →
POST /api/selfrestart(auth + CSRF via the/api/mount) using the same helper. Confirm dialog → POST → pollsGET /every 2s until the controller answers → reloads. Self-serve restart without rebooting the whole guest. - Immediate hub report push on geo change. A successful geo settings save and a successful manual
geo sync now fire an out-of-band, non-blocking report push (
Router.reportPushNow), so the hub reflects the new geo state / clears a stalelast_sync_errorwithin seconds instead of after the next ~15-min cycle. (Pattern can extend to other settings later; scoped to geo handlers for now.) - Always report
geo_restriction.BuildReportnow always populates the field (Enabled=false, empty countries when never configured) instead of omitting it when nil — so the hub always renders the geo section ("Inaktív" when off) rather than hiding it. - Country autocomplete fixed. Root cause (diagnosed live):
filterCountriespopulated the list correctly but revealed it withstyle.display = ''; the.geo-country-listCSS default isdisplay:none, so clearing the inline style kept the populated dropdown hidden — no console error, just an invisible list. Latent since the geo feature's first commit (not the hypothesised JS throw). Fix: reveal withdisplay = 'block'.
v0.69.0 — remove dead infra-backup stubs + the unused restic-password report field (2026-06-16)
Controller half of the Phase-1 Infra Backup retirement (hub v0.12.0; see
felhom.eu/documentation/audits/SPIKE-infra-backup-2026-06-15.md). Pure dead-code removal — no
behaviour change (everything removed was already caller-less).
- Removed
report.Pusher.PushInfraBackup— pushed the infra-backup payload to the now-removed hub endpointPOST /api/v1/infra-backup. Dead since slice 8C; no callers. - Removed
notify.Notifier.NotifyBackupCompleted(thebackup_completedevent) — no callers since whole-guest backup moved to the agent in slice 8C. The hub's backup-deadline check now reads the agent host-report's PBS snapshots instead of this event.NotifyBackupFailedand the DB-dump notifiers are untouched and still used. - Removed
report.BackupReport.ResticPassword(json:"restic_password") — the live report builder (buildBackupReport) has left it empty since slice 8C, but the field historically leaked the restic password into the hub's plaintextreportsstore. Confirmed via pushed source (builder never sets it) and live data (current reports carry norestic_password) before removal.
v0.68.3 — fix Beállítások page endless-refresh loop after a migration (2026-06-15)
Found while live-validating the M3 migration: once any data migration finished, the Beállítások
(settings) page reloaded itself every ~1.5 s, forever. The migration journal keeps returning the
last completed job indefinitely (MigrationStatus is not cleared on done); the page's resume-view
IIFE called migWatch() for any returned job, and migWatch's done branch does
setTimeout(location.reload, 1500). So every load saw the persisted done job → watched it →
reloaded → saw it again → looped endlessly.
Fix (settings.html): the resume-view now starts the watcher only for an in-progress job
(phase !== 'done' && phase !== 'aborted'). The one-time post-completion reload still fires from the
active watcher started by storageMigrateAll, so a real migration still refreshes drive state once
when it finishes — but a stale terminal job in the journal no longer triggers the loop. Template-only
change.
v0.68.2 — fix stack-card state-badge clipping on unhealthy apps (CSS) (2026-06-15)
The .stack-detail-header is a flex / space-between row holding the .stack-title-row (logo +
title + subdomain link + the route-unpublished warning) and the .stack-state-badge. On an
unhealthy app the long "⚠ URL nem elérhető – útvonal nincs publikálva" warning inflated the
title-row; because the title-row had no min-width:0 it refused to shrink below its content, and
because the white-space:nowrap badge had no flex-shrink:0 the flexbox compressed the BADGE
instead — clipping "Nem egészséges" to "Ner…". Healthy / not-deployed cards don't render that
warning, so only unhealthy cards clipped.
.stack-title-row→flex: 1; min-width: 0;(allowed to shrink + wrap its own content)..stack-state-badge→flex-shrink: 0;(never compressed).
Pure CSS; no behavior change. Browser-verified on /stacks: komga's badge now reads the full "Nem egészséges" and the warning wraps within the title column; healthy ("Fut") and not-deployed cards unchanged.
v0.68.1 — boot-id recreate ALL deployed drive-backed apps (state-independent) (2026-06-15)
Fix caught live in the E1 host-reboot test: shouldRecreateOnBoot filtered on container state
(State != stopped), so apps docker hadn't auto-restarted yet at the one-shot boot-id instant were
MISSED (5 apps stayed exited after a host reboot). The boot-id recreate now recreates EVERY deployed
drive-backed app whose drive is present, independent of current state (app.yaml deployed = should run)
— truly deterministic. Test updated.
v0.68.0 — storage lifecycle on the intermediary model: H2/H3/M1/M3 + deterministic boot-id (2026-06-15)
Pairs with agent v0.36.0. Finishes the storage lifecycle on the new mount model.
- Boot-id determinism (kills the 1/8 race).
processGuestBootChangereplaces the fragile container-uptime sample: the agent reportsguest_boot_id(changes per guest boot, stable across a controller-only restart), persisted in settings (LastGuestBootID). On a change, every deployed drive-backed app whose drive is present and that docker brought back (shouldRecreateOnBoot: state not stopped/not_deployed) is DETERMINISTICALLY recreated onto the populated path. Respects user-stop; gate-stopped apps stay the gate's job. - H2 — decommission UI button (settings.html): a "Leszerelés" button on every connected drive → migrate-then-decommission (uses the inline target select) OR decommission-anyway (type-to-confirm the drive name). Both modes were already server-side; the new model never touches the parent mp.
- H3 — one-click re-enroll/reconnect.
handleStorageReconnectnow also handles a DECOMMISSIONED drive: clears the soft marker + schedulable, re-attaches under the parent, restarts apps (re-discovered viaappsOnStoragePathsince decommission-anyway doesn't persist StoppedStacks). New "Visszacsatlakoztatás" button on decommissioned drives. - M1 — default reassignment.
defaultPromotionTarget+finalizeDecommissionWith: decommissioning the DEFAULT auto-promotes another schedulable drive (preferring the migrate target); if NONE exists the decommission is BLOCKED with a clear message (never zero default). - M3 — userdata setgid on migrate. The merge-walk now RE-ASSERTS 2775-setgid/gid-1000
(
EnsureUserdataDir) on the userdata tree (isUserdataDir) instead of merely preserving the source mode — so a pre-existing stale 755 target dir (e.g. import/calibre) is corrected. - Fix: the H1 disconnect/reconnect/restart-apps JS sent
{path}but the handler decodes{where}(always 400); response keys realigned (restarted). New buttons use{where}.
Tests (non-hollow + companions): TestShouldRecreateOnBoot (old sample missed a healthy-stale app),
TestDefaultPromotionTarget, TestIsUserdataDir.
v0.67.5 — gate: startup recreate waits for stack scan + handles exited apps (2026-06-15)
Adds a bounded wait for the stack scan (GetStacks is empty at NewServer time, so the recreate found no apps) before the one-time boot-stale recreate, and recovers exited/restarting/unhealthy drive-backed apps (not only recently-started). The deterministic guest-reboot convergence.
Refines v0.67.3's recreateBootStaleApps: recreate a present drive-backed app when it is boot-stale
(recently started) OR currently exited/restarting/unhealthy (came up wrong on the empty bind and
bailed) — the recency-only gate missed apps that had already exited. Still skips healthy long-running
apps (no bounce on a controller-only restart) and cleanly user-stopped apps.
v0.67.3 — gate: startup recreate of boot-stale drive-backed apps (2026-06-15)
Completes guest-reboot convergence (caught in the live migration). On a guest reboot docker auto-starts
the app containers (restart:unless-stopped) potentially BEFORE the agent re-propagates the drive under
the parent, so they bind the empty fail-closed stable dir and (leaf-bind pinning) never pick up the
later propagation. driveGateLoop now runs a one-time recreateBootStaleApps at startup (the controller
restarts with the guest): for each deployed drive-backed app whose drive is NOW present
(BoundUnderParent) and whose containers started recently (a fresh boot, not a controller-only restart —
stackStartedRecently), it recreates the app (down+up) onto the populated path. Apps whose drive is
still absent are left to the normal stop→return→restart gate. Paired with agent v0.35.0 (the drive
re-propagation).
v0.67.2 — gate: key "present" on BoundUnderParent (reboot convergence) (2026-06-15)
The drive-absent gate now treats a stable path as usable only when the agent reports it BOUND UNDER THE
PARENT (BoundUnderParent), not merely host-mounted (State==attached). This makes a host reboot
converge correctly: at boot the raw drive mounts early but the agent binds it under the parent slightly
later, so until then the apps' stable-path binds are empty — the gate keeps the apps stopped and
restarts (recreates) them once the bind is live. Legacy raw paths still use the host-mount signal.
v0.67.1 — gate: only act on external drives under /mnt/felhom-drives/ (2026-06-15)
Fix (caught live on the v0.67.0 deploy): planDriveGates marked the internal SSD path
/mnt/sys_drive/felhom-data "disconnected" because the agent never reports it as a drive — which would
have blocked starting SSD-resident apps. The gate now only considers EXTERNAL drives registered under
the stable parent /mnt/felhom-drives/<name>; always-present SSD/system paths are skipped. Regression
case added to TestPlanDriveGates. (No apps were stopped — no app depended on the SSD path.)
v0.67.0 — intermediary-mount: HDD_PATH repoint + drive-absent gate + H1 routes (2026-06-15)
Controller half of the intermediary-mount re-architecture (pairs with agent v0.34.0). Drives are now
visible in the guest ONLY at the STABLE path /mnt/felhom-drives/<name> (the host swaps the backing
drive underneath it; no per-drive pct mp, no guest reboot).
- Repoint (
internal/web/intermediary.go): the registered storage path + every app's HDD_PATH + FileBrowser source = the stable/mnt/felhom-drives/<name>(stablePathForName); the AGENT still operates on the raw/mnt/<name>host mount, so controller→agentwhereis mapped back viaagentWhere()at the assign/attach/eject/decommission call sites. Enroll now binds-under-the-parent BEFORE register/skeleton (the controller can only see/write the drive at the stable path post-attach).agentapi.DiskInfogainsGuestPath+BoundUnderParent. Newsettings.RepointStoragePathfor the migration. FileBrowser + monitoring followsp.Pathautomatically. - Drive-absent GATE:
ReconcileDriveGates(pure decisionplanDriveGates+ executor) on a 30s loop (driveGateLoop, replacing the retired slice-8C watchdog) — an ABSENT drive's apps are STOPPED + recorded (StoppedStacks= the gate-stopped set, distinct from a user stop); a RETURNED drive is re-attached under the parent and its gate-stopped apps AUTO-RESTARTED. Start-gate inactionStack: refuses to start an app whose drive is disconnected/decommissioned (clear "tárhely nem elérhető" message) — so it can't write to the empty fail-closed stable path. - H1 endpoints routed (were 404):
POST /api/storage/{disconnect,reconnect,restart-apps}→ host-side eject/reconnect (stop→agent-detach→fail-close / agent-attach→restart→clear) — no guest reboot.
Tests (non-hollow + companions): TestPlanDriveGates (4 states; trivial impls fail), TestAgentWhere
(stable↔raw idempotent mapping), TestRunStorageInit_Success (agent gets RAW, registry gets STABLE).
v0.66.2 — FileBrowser umask 002 (customer folders group-writable) (2026-06-15)
FileBrowser (uid 1000) created folders with umask 022 → mode 2755 (setgid from the parent, but
group-READ only), so a folder a customer made in FileBrowser could not be written by the content apps
in group 1000. The gtstef/filebrowser image is a single Go binary (entrypoint ./filebrowser) and does
NOT honor a UMASK env (verified live: -e UMASK=002 leaves PID1 at 0022), so RenderFileBrowserCompose
(internal/infra/infra.go) now wraps the entrypoint:
["sh","-c","umask 002; exec /home/filebrowser/filebrowser"]. Customer-created folders now come out
2775 (group-writable) so all group-1000 apps can use them. Test asserts the rendered compose carries
the wrapper. (Pre-existing pre-fix folders stay 2755 — recreated on the demo; no data.)
v0.66.1 — fix USERDATA_PATH on first deploy (2026-06-14)
The initial deploy path (DeployStack → composeExecWithEnv) builds its compose env from the deploy
values, not from app.yaml via stackEnv — so v0.66.0 injected USERDATA_PATH only on start/redeploy,
NOT on the FIRST deploy. A freshly-deployed app resolved ${USERDATA_PATH} to "" and Docker bound a
bogus root-owned dir at the container root (e.g. /media/movies) instead of <drive>/userdata/...
(found live: radarr's media mount was 0:0 755 at the container root). Fix: a shared withUserdataPath
injector used by BOTH stackEnv and composeExecWithEnv. Regression test asserts injection on/off by
HDD_PATH presence.
v0.66.0 — userdata layout + shared-storage ownership convention (2026-06-14)
Customer-facing userdata/ tree (sibling of appdata/backups under each drive's felhom-data namespace)
with a shared-ownership convention so FileBrowser + content apps collaborate without permission
collisions. Spike: felhom.eu/documentation/audits/SPIKE-userdata-layout-2026-06-14.md. Pairs with the
app-catalog commit that repoints media mounts to ${USERDATA_PATH}.
- Convention helper (
internal/appbackup/userdata.go):EnsureUserdataDir/EnsureDirOwned= MkdirAll → explicitChmod(ModeSetgid|0775)(MkdirAll's mode is umask-masked AND drops setgid) → chown group toSharedContentGID(1000).UserdataDir,UserdataSkeleton(media/{movies,tv,music, audiobooks,books,comics,photos}, downloads, import/{paperless,calibre}, roms, documents),EnsureUserdataSkeleton. Linux chown viachownGID/StatGID(userdata_linux.go); no-op stub off-Linux (userdata_other.go). - USERDATA_PATH injection (
stackEnv, manager.go): injectsUSERDATA_PATH = <HDD_PATH>/userdata(HDD_PATH is the namespace root) alongside HDD_PATH, so the catalog's${USERDATA_PATH}/...mounts resolve. - Skeleton pre-create:
registerStoragePath+syncFileBrowserMountsensure the full skeleton on every storage path (system + additional drives) with the convention. - Deploy belt:
composeExecCustomEnv(gated onup) pre-creates every${USERDATA_PATH}/...bind source the stack declares (ParseComposeUserdataMounts+ensureUserdataMounts) so Docker never auto-creates a userdata dir as guest-root — covers apps not in the skeleton. - FileBrowser mount switch (
syncFileBrowserMounts): mounts<drive>/userdata(wasappdata) →/srv/<name>. FileBrowser runs as uid 1000 → can now create folders + upload into the 2775 setgid userdata (fixes the permission-denied); app internals (appdata/) are no longer browsable. - #8 migration fix (
migrate.go): the non-app merge walk now preserves the SOURCE dir's full mode (incl. setgid viapreserveDirOwnership) + group, andcopyFilepreserves the full file mode (fi.Mode(), not.Perm()) + group — so the ownership convention survives a whole-driveMigrateAll. - Non-hollow tests:
EnsureDirOwnedproduces 02775+setgid+gid (Linux companion proves a plain MkdirAll has NO setgid); skeleton structure;ParseComposeUserdataMountsselectivity; deploy belt creates the declared dirs; migration preserves setgid+group (Linux; mutation-proven against the pre-fix 0755/.Perm() path).
v0.65.0 — data migration + self-serve decommission (B1+B2) (2026-06-14)
Customer-self-serve storage migration (move app data between drives) and decommission (retire
a drive), implemented trunk-based with the locked spike design
(felhom.eu/documentation/audits/SPIKE-decommission-migration-2026-06-14.md). Pairs with agent
v0.32.0 (the self-serve /disks/decommission endpoint + intent-aware re-assert). Built + deployed to
demo guest 9201. Live decommission/migration of real data is NOT yet validated — that is the
supervised B3 session.
- B1 — migration engine (
internal/stacks/migrate.go). In-process over the controller's/mnt:/mnt:rslaveRW mount; crash-safe + resumable via a single journal (<dataDir>/migration.json). Two entry points share one pipeline:MigrateAll(whole namespace — every app + a conflict-merge walk for non-app/customer content) andMigrateApp(one app subtree; handles drive→drive AND SSD→drive). Pipeline: validate → stop → copy (rsync -a --checksum, additive, NO--delete) → verify (rsync -ani --checksum, zero pending) → flip+redeploy (RedeployFromEnv, one idempotent unit) → cleanup. CLEANUP is the only destructive step and is gated on every unit verified AND every app redeployed. Conflict-merge: skip-identical (checksum vs the target file AND its(N)siblings), rename-on-differ to the lowest-free<base>(N)<ext>, never overwrite; idempotent (no(1)(1)). Single-flight; mutual exclusion with the backup orchestrator (Change 3 — migration refuses while a backup runs; the scheduled DB-dump/Tier-2 skip while a migration runs). - B1 UI —
POST /api/storage/migrate(whole-namespace),POST /api/storage/migrate-app(per-app),GET /api/storage/migrate/status(poll). The greyed migrate-all<span>in settings.html is now a real target-select + button; app_info.html gains a per-app "Áthelyezés másik tárhelyre" control; both share a Hungarian progress panel. - B2b — decommission orchestration (
handleStorageDecommission,POST /api/storage/decommission). Two choices, no partial (Change 2): migrate-all-then-decommission (runsMigrateAll; the migration done-hook soft-marks the source + calls the agent once every app has moved) or decommission-anyway (type-to-confirm; stops the apps but KEEPS theirHDD_PATHso they show "missing storage").agentapi.Decommissionadded; both branches end atSetDecommissioned(soft marker retained — blocks A1 resurrection) + agentDecommission. - "Hiányzó tárhely" indicator — a deployed app whose
HDD_PATHresolves to a decommissioned/ disconnected/absent registry path now shows a distinct warning badge on the dashboard, stacks page, and app card (label viaGetStorageLabel); persists until re-enroll or migrate. - Change 4 — re-enroll clears the marker.
registerStoragePathnow un-retires a re-plugged decommissioned drive (ClearDecommissioned+ restoreSchedulable) — previouslyAddStoragePathdeduped the re-register into a no-op and the soft marker (and the apps' missing-storage badge) would persist forever. (ClearDecommissionedhad zero callers before this.) - Non-hollow tests across
internal/stacks(engine: collision-refuse, merge dedup/idempotency, cleanup-only-after-redeploy, verify-catches-corruption, resume, single-flight, SSD→drive, backup exclusion),internal/backup(scheduled backup skipped while migrating), andinternal/web(finalize soft-mark+agent, re-enroll clears marker, missing-storage label). Companions for the collision guard, cleanup gate, and Change-4 clearing were mutation-proven to fail on the pre-fix code.
v0.64.0 — storage-lifecycle cleanups (2026-06-14)
Two settings-layer cleanups from the F9 storage-registration diagnosis
(felhom.eu/documentation/backlog/DIAGNOSIS-f9-storage-registration-gap-2026-06-14.md), trunk-based on
main, each with table-driven tests that fail on the pre-fix code.
- A1 —
AutoDiscoverStoragePathsis now ADDITIVE (internal/settings/settings.go). It previously bailed early (if len(s.StoragePaths) > 0 { return }), so a drive a deployed app referenced but that was missing from the registry was never picked up after first run. It now registers only the discovered paths NOT already present, while honouring strict invariants: never removes/modifies a manually-added path; SKIPS any path already in the registry IN ANY STATE — including aDecommissionedsoft-marked entry — so it can't re-add or reactivate it; never flipsIsDefault(a newly-discovered path becomes default ONLY if the registry currently has no default at all, and only the first such new path). NOT auto-register-on-attach — it only picks up paths deployed apps already reference (that recommendation was rejected; manual enrollment is by design). Newinternal/settings/storage_discovery_test.gocovers it, incl. a companion test that FAILS if the skip-by-presence guard is removed (verified: removing the guard re-adds the decommissioned path). - A2 — internal-SSD label disambiguation (
InferStorageLabel,internal/settings/settings.go). A path whose basename is thefelhom-datanamespace dir (the internal system volume, e.g./mnt/sys_drive/felhom-data) previously labelled asTárhely (felhom-data), colliding with the per-drive felhom-data namespace. It now readsBelső SSD (rendszer). Discriminator isbase == appbackup.FelhomDataDir; Model-A user drives register their MOUNT ROOT (e.g./mnt/felhom-usb), never.../felhom-data, so this can't mislabel a user drive. Still overridable viaSetStorageLabel. The demo's already-seededsettings.jsonlabel for that path on guest 9201 was updated out-of-band (the seeded value doesn't auto-change). Separate host-metrics label inweb/agent_host_metrics_handler.gowas intentionally left untouched.
v0.63.0 — reflect agent F9/F20-BUG2 disk fields (2026-06-14)
Pass through two new fields the host agent (v0.31.0) now returns on /disks, so they reach
/api/disks and the dashboard (the controller previously dropped them when re-marshalling the agent
response). Additive only — agentapi.DiskInfo gains:
wipe_durable_id(F20-BUG2) — the device's wipe-binding id in the gate's scheme (byid:/byuuid:), distinct fromdurable_id(uuid:, used for assign). A customer-confirmed data-bearing wipe must carry THIS id; confirming with theuuid:id was rejected (binding_mismatch).guest_attached(F9) — whether the drive is actually bound into this guest (usable in-guest) vs merely present on the host — the signal whose absence let an unattached HDD look available.
No behaviour change in the controller itself; the agent owns the fix. (Agent v0.31.0: F9 startup bind re-assert, F20-BUG2 single wipe-id scheme, F20-BUG3 detached/restart-surviving format.)
v0.62.0 — M18 + M19 backlog fixes (2026-06-14)
Two verified-LIVE backlog bugs (preserved fix-plans in felhom.eu/documentation/backlog/), implemented
trunk-based on main, each with a regression test that fails on the pre-fix code. Built, deployed to demo
guest 9201, and both verified live.
- M19 —
deriveStackNameDB-container misattribution (correctness) — commit6bab68b.deriveStackNamepure-suffix-stripped on-(postgres/db/mariadb/mysql/database/redis/cache), so a stack whose slug ends in a role token (e.g.my-cache) was misattributed (stripped tomy), filing its DB dump under the wrong/nonexistent stack. Now threads the set of deployed stack names (m.knownStackNames()←ListDeployedStacks) intoDiscoverDatabasesand cross-references: use the suffix-strip candidate if it's a known stack, else the container name if it IS a known stack (don't strip), else the longest known stack that is a-/_-bounded prefix (handles<stack>_postgres,<stack>-1), else the legacy strip.nil/empty known = legacy behaviour (appexport passes nil). Live: romm-db →romm-mariadb.sql(correct). Table test incl. themy-cachecase (fails pre-fix). - M18 — DB-dump validation re-run every cycle (performance) — commit
f8afe5c.ListDumpFilesranValidateDump(line-by-line scan) for every dump on every ~5-minRefreshCachecycle — wasted I/O+CPU on large customer dumps.ListDumpFilesnow takes an optionalcached(name,size,mod)lookup; on a size+modtime match it reuses the prior result and skipsValidateDump.settings.DBValidationCachegainsSize+ModTime;listAllDumpFilesbuilds the lookup from the persisted cache and writes back only fresh validations (cache miss) — so an unchanged dump triggers neither a re-validation nor asettings.jsonwrite each cycle.nilcached = legacy validate-always (back-compat). Live: the cache now persistssize+mod_time. Tests: cache-hit skips validate (sentinel), cache-miss validates, nil validates.
v0.61.0 — live-drive Batch 1 (+F17) fixes (2026-06-14)
Controller-side fixes triaged in LIVE-DRIVE-FIXSPEC-2026-06-14.md from the 2026-06-14 live-drive
findings. Each fix has a regression test that fails on the pre-fix code. Built, deployed to demo guest
9201, and the key fixes live-verified. (F9, F20-BUG2, F20-BUG3 are the SUPERVISED agent/golden next
session — not in this batch.)
- F17 (CRITICAL) — per-app restore now replays the captured
.sqlDB dump.RestoreFromRecoveryUnit(and theRestoreAppfallback) repopulated Docker volume tars but NEVER replayed the captured<stack>-<dbtype>.sql, so DB-resident data did not come back. Newappbackup.ImportDump(read-side counterpart toDumpOne, reusesDiscoveredDB's own discovered credentials) +backup.reimportDBDumpsreplay the dump AFTER volume restore + stack bring-up, so the logical dump wins over any volume-tar copy of the DB (operator-chosen precedence). Volume-restore and DB-import failures now surface (the restore returns an error) instead of a swallowed WARN. Live-validated on guest 9201: a marker row dropped after backup was restored by/backup/restore(log: "replayed 1 DB dump(s)"). Reuse note:ImportDumplives inappbackup(the DB-domain package) —appexport→appbackupalready exists so reusing appexport's unexported copies would cycle; appbackup is the clean shared home. - F1 (HIGH) — guest RAM cap read from the Docker daemon; deploy guard uses committed memory. The
controller container reported the Proxmox host's 16 GB (no lxcfs in the container; its own cgroup is
unlimited — the 2 GB cap is on the LXC ancestor), defeating the deploy memory-headroom hard-block.
systemnow sources the cap fromdocker infoMemTotal (the daemon runs in the LXC → reports the guest's real RAM; cgroup limit still preferred when present). The deploy guard now uses the controller's own committed-app memory (sum of running mem requests) for "used" — accurate and cheap — instead of host RSS./api/system/inforeports the guest cap + committed used. Live-verified:total_mem_mb2048 (was 15771). - F20-BUG1 (HIGH) —
agentapi.FormatDisksurfaces the agent's error. A failed format (agent 502 "device is mounted",ok:false,data:null) fell through toreturn out, nil, so the web layer reported a zero-value result asok:true— a failed DESTRUCTIVE op read as success. Now returns a non-nil error on any non-2xx/ok:falsethat is not a recognized refusal (403/needs-confirmation). - F5 (HIGH) — broken healthcheck → 404, two parts. (catalog,
app-catalog-felhom.eu) uptime-kuma's healthcheck pointed at a v1-eranode /app/extra/healthcheck.mjsabsent in:2, so the container stayed unhealthy and Traefik withheld the route (404 though running) — fixed to the v2 compiledextra/healthcheckbinary + 180s start_period. (dashboard) newrouteUnpublishedhelper + a distinct "URL nem elérhető – útvonal nincs publikálva" indicator on the dashboard/stacks cards for unhealthy/restarting deployed apps (operator decision: keep gating the route, surface it distinctly). Live-verified: uptime-kuma healthy → route publishes → status URL 302 (was 404). - F8 (LOW-MED) —
controller.yamlpersisted 0600. It holds infra credentials (cf/hub tokens) in plaintext; the Hub config-apply handler wrote 0644. NewwriteConfig0600enforces 0600 even on a pre-existing 0644 file. - F6 (LOW) — deploy POST reports "started", not "deployed". The deploy runs async (UI polls); the POST now returns 202 Accepted + "Telepítés elindítva…" so API/script consumers aren't told a deploy finished before it has.
- F7 (LOW) — dashboard state lag.
status-refreshtightened 30s → 10s (cheap docker-ps refresh). - F4 (TRIVIAL) —
GET /api/stacks/rescannow returns 405 +Allow: POSTinstead of the misleading "stack not found: rescan" fall-through.
v0.60.0 — M25 data-race fix (backlog-Medium cleanup) (2026-06-13)
Backlog-Medium reconciliation from the 2026-06-13 BUGHUNT reconcile. M4/M5/M6 verified already FIXED
(no action). M18 (dump re-validation every 5 min — perf) and M19 (naive deriveStackName misattribution
— low-incidence correctness) verified LIVE but cross-package-entangled; prepared on branches
fix/m18-dump-validation-cache / fix/m19-stackname-crossref (notes + fix plan, pending review, not
deployed).
- M25 (Server.integrationMgr data race) — FIXED.
NewServerlaunches theSyncFileBrowserMountsgoroutine (which readsintegrationMgr) from the constructor, beforemain.gocallsSetIntegrationManager— so the init-only happens-before that covers the otherSet*fields did not hold, making it a genuine data race (reads athandlers.go:358/360/1433vs the unsynchronized write). Converted the field toatomic.Pointer[integrations.Manager]; setterStores, all readersLoad(). Regression test reproduces the concurrent access and is clean under-race(verified on the build server); it flags on the pre-fix plain-pointer field.
v0.59.0 — security/crash-safety fixes from the 2026-06-13 audit (2026-06-13)
Fixes the validated findings from the deep-sweep audit + BUGHUNT reconciliation
(records under felhom.eu/documentation/audits/). All shipped with permanent
regression tests.
- CTRL-001 (path traversal on
.fabimport) — High.appexport.UnmarshalManifestdid zero validation; the attacker-controlledmanifest.AppName/HDDSubdirs/VolumeNamesreachedfilepath.Join+MkdirAll/extractTar(restore.go:339/606/678), so../..in any escaped the stacks / HDD destination dir (arbitrary write as the controller). Newappexport.ValidateSegment+validateManifestPaths;UnmarshalManifestnow fails the parse on a traversal segment, with defence-in-depth guards at the HDD-subdir and volume-name join loops.ConfigFilesintentionally not validated (holds dotfiles, never used in a restore join). - CTRL-T2-1 (ghost-deployed stack on crash) — High.
DeployStackwroteapp.yamldeployed:trueto disk before the asyncdocker compose up -d; a crash during the image-pull window left a ghost-deployed stack with no containers that the app then refused to redeploy. The env is now persisteddeployed:false(transitional) and flipped todeployed:truebyrunComposeDeployonly afterup -dsucceeds. The in-memory flag still goes true during the pull (no stale "Telepítés" button). - H10 (plaintext secret on encrypt failure) — fail-closed.
SaveAppConfiglogged a WARN then fell through to persist the secret in plaintext on acrypto.Encrypterror. Now returns an error instead — never writes plaintext. - M2 (misleading lock).
backup.Manager.SetStackProviderwas mutex-guarded while all reads were unlocked; it is init-only (one call before any goroutine), so the lock was removed and the contract documented. No behaviour change. - AGENT-001 (wrong-disk wipe race) is fixed on the agent branch
fix/agent-001-wipe-durable-reresolve(PENDING REVIEW — not deployed; stored out-of-band per the supervised-merge rule).
v0.58.0 — infra-protection prevention layer for the OS/Docker-data split (2026-06-13)
Phase 2 of the storage-split slice (Phase 1 = felhom-agent golden + provision). The OS rootfs and Docker data are split onto separate volumes for resilience; infra (controller/traefik/cloudflared/ filebrowser) shares the one Docker data-root and is protected by prevention, not placement.
- Reserved-buffer headroom guard (
internal/system/dockervol.go):GetDockerVolumeHeadroom()measures the Docker-data volume viastatfs("/")(the controller container's root overlay is backed by the guest's/var/lib/dockervolume) and computes a reserved floorDockerVolumeReserveGB=max(5 GB, 10% of total). Fail-open on a measurement error (the buffer is a safety net, not a security control). - Deploy-time hard gate (
internal/api/router.godeployStack): a new deploy is refused (HTTP 507 + Hungarian message) when free space on the Docker-data volume is at/under the reserved buffer, so customer apps can't fill the volume the infra containers depend on. - Deploy-page surfacing (
deploy.html): for a new deploy, when below the buffer the page shows a clear Hungarian warning and disables the "Telepítés indítása" button (mirrors the memory-blocked pattern) — the customer sees it before clicking; the API gate is the hard backstop. - Runtime monitoring (2C): confirmed
monitor/healthcheck.goalready watchessysInfo.DiskPercent= the Docker-data volume post-split (statfs/); warn 80% / crit 90% used trip ABOVE the 10%-free reserved buffer, so the customer is warned before the deploy gate engages. Comment added to make the "SSD disk" alert's target explicit. - Log rotation (2D): baked into the golden's
daemon.json(max-size 10m,max-file 3) in the felhom-agent golden build — every guest inherits it. Per-app xfs-project-quota caps deferred. - Tests:
DockerVolumeReserveGBfloor/scale.
v0.57.0 — UI fixes: stable host-storage list + per-app Tier-2 config panel (2026-06-13)
Part A of the UI-fixes/storage-spike spec (Part B is a build-nothing findings report).
- A1 — host storage list no longer reorders (item 2): the monitoring page's
#host-storage-barslist (the client-side one filled from the agent's PVE-storage list —local,local-lvm,felhom-pbs,felhom-usbwith thin-pool % + temperature) reordered on every 8 s poll because the agent enumeratespvesmin a non-deterministic order and the list never passed through a Go sort. NowenrichHostStorageTargets(agent_host_metrics_handler.go) sorts the/api/host-metricsresponse server-side (user-data → system+apps → backup → other; alphabetical by id within a tier) and attaches a friendly Hungarian label + one-line purpose per entry (e.g.local-lvm→ "Belső SSD – rendszer és alkalmazások"). The raw PVE id is kept and shown muted — display labels only; PVE storage ids are never renamed (vzdump/PBS configs reference them by name). The monitoring JS renders the friendly label + the purpose sub-line. (Note: this is the JS-driven list, NOT the server-rendered user-databuildStorageBarslist that v0.56.0's 4C already sorted.) - A2 — per-app Tier-2 config panel (item 4): the "2. mentés" row's Beállítás button used to
link to the app's deploy page, which has no backup-location setting (a dead end). New route
GET/POST /stacks/{name}/backup(tier2_config_handler.go+tier2_config.html) is the real surface: it shows the current/effective off-drive target, whether it's the size-limited internal SSD, the last-run status, and lets the customer pin a different registered drive or turn Tier 2 off. The control is always visible — even when only the internal SSD qualifies (shows "automatikus: belső SSD — csak DB/konfiguráció" + the rootfs-headroom note) and for non-HDD apps (shows honest "already in the PBS whole-guest snapshot; the off-drive copy is supplementary" context). The button is repointed on every "2. mentés" branch (incl. the unconfigured + disabled states).- Persistence: two preference fields on
settings.CrossDriveBackup—UserDisabledandPreferredTarget— set viaSetTier2Preferenceand preserved across the runner's status writes (withTier2Prefs).selectTier2Targetnow honors a valid pinned target (off-disk, registered) before the auto-pick; an invalid pin silently falls back to auto.RunTier2skips a customer-disabled app. Saving with Tier 2 on for an HDD app triggers an immediate run so the result shows on return.
- Persistence: two preference fields on
- Tests:
enrichHostStorageTargetsorder/labels/determinism;selectTier2Targethonors/falls-back on a pin; status writes preserve the preference.
v0.56.0 — Phase 4: FileBrowser scoping + deploy DB-on-SSD note + monitoring storage descriptions (2026-06-13)
Polish layer closing the slice.
- 4A FileBrowser scoping (safety): the FileBrowser bind mount is now scoped to each drive's
appdata/subtree (<drive>/appdata:/srv/<name>) instead of the whole drive root. The recovery units + Tier 2 copies underbackups/are therefore not mounted into FileBrowser at all — the customer browses their userdata but cannot reach (or even see) the thing that restores them. The appdata dir ismkdir-ed before the bind so the source exists. (syncFileBrowserMounts.) - 4B Deploy-UI communication: the storage-selection step now states plainly (Hungarian) that the
chosen drive holds the app's files, while its database runs on the fast internal SSD and is
backed up alongside the app — so "the DB is on the SSD" stops being a surprise. (
deploy.html.) - 4C Monitoring storage list:
buildStorageBarsnow sorts deterministically (by path) and carries a purpose description explaining the user-data drives (rendered on the monitoring "Tárolók kapacitása" list). Note: this list is the controller's registered user-data drives only (the agent's local/local-lvm/pbs storage is not in this registry), so the role-tier sort/local-vs-local-lvmdescriptions belong to the agent-backed storage-management page, not here.
v0.55.0 — Phase 3: auto off-drive Tier 2 (rootfs-headroom guard, durable off-disk target) (2026-06-13)
Tier 2 = an off-drive copy of each HDD app's recovery unit + bulk userdata to a different physical disk — the only off-drive protection browsable HDD userdata can get (PBS can't reach bind mounts). Auto-enabled for every HDD app; the target is auto-picked and the dangerous case (the small guest rootfs) is refused rather than filled.
- Engine
internal/backup/tier2.go(RunTier2/RunAllTier2): rsync-a --deleteof the recovery unit (backups/primary/<app>/) and the app'sappdata/<app>/to<target>/backups/secondary/<app>/. restic is not revived — plain browsable mirror. - Auto target selection: prefer another registered user-data drive on a different physical disk
(can hold bulk userdata); else fall back to the internal SSD for small units only. Off-disk is
enforced by
system.SamePhysicalDevice(block-device identity; new exported helper, linux + stub) — defense-in-depth re-checked before the copy. - Rootfs-headroom guard (the key safety): the SSD target is the ~8 GB guest rootfs, so a size-aware
guard (
tier2FitsHeadroom, unit-tested) refuses unless the unit fits while leaving a reserve free (max(2 GB, 20% of total)). When nothing fits, it records an honest "needs a 2nd HDD" status rather than silently doing nothing or endangering the rootfs. - Status + UI: results persist via the surviving
settings.CrossDriveBackup(rsync method, dest, last-run/status/size). The "2. mentés" card is now populated (buildAppBackupRows): real target ("belső SSD (csak DB/konfiguráció)" vs an external drive) on success, or the honest no-off-drive-target reason. Notifications via the survivingNotifyCrossDrive{Completed,Failed}hooks. - Scheduling + trigger: daily
tier2-backupjob (03:30, after the DB dump); manualPOST /api/backup/tier2. - Fixed a stale pre-existing test (
TestBackupCopiesOnPath) that still used the oldfelhom-data/backups/secondarylayout — now the Model-A in-guest layout the Tier 2 copies actually use.
v0.54.0 — Phase 2b: restore-from-recovery-unit + fail-closed data-key gate (2026-06-13)
Restore now recreates an app from its on-drive recovery unit plus the guest's own secrets — never from secrets stored in the unit (there are none), and regenerating nothing.
- Fail-closed data-key gate (
reconcileRestoreSecrets,internal/backup/restore_unit.go— a pure, exhaustively unit-tested function): merges the unit's non-secret env with the secret values recovered from the guest's live app.yaml. A missing/empty data-encrypting key (data_key) aborts the restore with a clear message (a PBS whole-guest restore is required) — because regenerating it would render stored data unreadable. A missing resettable secret (DB/admin password) is non-fatal (warn + proceed; the app may need a credential reset). Secrets are recovered, never regenerated. RestoreFromRecoveryUnit: reads the unit manifest → recovers secrets from the guest (RecoverStackSecrets) → applies the gate → restores named-volume data from the unit's tars → recovers the app definition from the unit and redeploys with the reconstructed env (re-pulling the pinned image). Falls back to the legacy volume-onlyRestoreAppif no unit exists. Wired into the/backup/restoreweb handler.- New seams:
StackDataProvider.RecoverStackSecrets/RecreateStackFromUnit(main.gostackAdapter, with the controllerencKeyfor decrypting the live app.yaml);stacks.Manager. RedeployFromEnv(writes app.yaml from the full env incl. locked secrets, thencompose up -d). - Tests: the gate (all recovered / data-key missing → refuse / empty data-key → refuse / resettable
missing → proceed+warn, recovered values used verbatim) and
data_keyparsing from.felhom.yml(Metadata.DataKeyEnvVars()). - Live-validated on guest 9201 (AdventureLog, a real data_key app): its recovery-unit manifest
correctly carries
data_key_env_vars: [SECRET_KEY](catalog→metadata→manifest flow proven live); and withSECRET_KEYmade unrecoverable,POST /backup/restorerefused with the exact fail-closed message ("…[SECRET_KEY] could not be recovered … a PBS whole-guest restore is required first…"), before any compose-up (no side effects). The demo has no dashboard password, so the API is open (auth + CSRF are both skipped in that mode) — this was driven via the public URL. Gate + reconciliation- orchestration + data_key parsing are also unit-tested.
- One e2e not run (environment limit, not a code gap): the full "deploy with data → restore → confirm data decrypts" — AdventureLog's images don't fit the 8 GB guest rootfs (the deploy hit "no space left on device"). This is exactly the Phase 3 rootfs-headroom concern, now observed live. Key-preservation/regenerate-nothing is covered by the gate's verbatim-recovery unit test.
v0.53.1 — Phase 2: recovery units refresh on the periodic cache cycle (idempotent) (2026-06-13)
The recovery-unit capture now also runs from RefreshCache (controller startup + every 5m), not only
the daily DB dump — so a unit exists shortly after startup and stays current with config changes
(redeploy / optional-config) without a 24h wait. CaptureRecoveryUnit builds the captured content in
memory and skips all writes when the unit is already current (same config checksums + dump set +
controller version), so the periodic refresh does not thrash a spinning USB drive. Added an idempotency
test (unchanged → skip; config change → rewrite).
v0.53.0 — Phase 2: per-app self-contained recovery unit (capture side, SECRET-FREE) (2026-06-13)
Each app's on-drive backup becomes a complete, recreatable recovery unit — not just DB dumps + volume tars, but the app's definition too, so it can be recreated. The unit is secret-free by design (decided after reading the actual hub code: the hub is deliberately zero-knowledge and holds no app secrets; app.yaml + the encryption key live on the guest rootfs → already inside the PBS whole-guest snapshot). Secrets/data-keys are recovered at restore from the guest's own app.yaml (live, or via PBS) — never stored in the unit, never regenerated.
- Unit layout (rooted at the existing
backups/primary/<app>/— no risky dump-dir migration):compose/(docker-compose.yml + .felhom.yml + a secret-stripped app.yaml) + the existingdb-dumps/+volume-dumps/+manifest.json. New path helpersRecoveryUnitPath/RecoveryUnitComposePath/RecoveryUnitManifestPathininternal/appbackup/paths.go(AppDBDumpPath/AppVolumeDumpPathrefactored ontoRecoveryUnitPath— identical resolved paths). - Secret-free manifest (
internal/backup/recovery_unit.go): app id, display name, controller version, timestamp, drive, namespace root, pinned image tags (image NOT stored — re-pulled on restore), the NAMES of secret env vars (values never stored), thedata_keyenv-var names, the explicitsecret_sourcenote ("guest app.yaml (live) or PBS — never stored in this unit"), captured config-file list, enumerated dumps, and sha256 checksums of the captured config. - Capture has no secret access: non-secret env is plaintext in app.yaml; the capture simply excludes
the secret-named keys (plus a defensive
crypto.IsEncryptedguard), so it reads no secret value. NewStackDataProvider.GetStackRecoveryInfo+RecoveryInfo(inappbackup), implemented by the main.gostackAdapter;ParseComposeImagesextracts the image pins. data_keyannotation (DeployField.DataKey,Metadata.DataKeyEnvVars()): marks a data-encrypting key (e.g. AdventureLog's "Titkosítási kulcs",SECRET_KEY) — a fail-closed safety annotation for restore (refuse + warn rather than regenerate-and-corrupt), NOT a per-secret preserve/regenerate decision. Catalog:adventurelog/.felhom.ymlSECRET_KEYmarkeddata_key: true.- Wired into the dump flow:
RunDBDumpsrefreshes every deployed app's recovery unit after the DB dumps (best-effort per app; skips disconnected/decommissioned drives). Capture test (recovery_unit_test.go) proves the unit is secret-free (a secret in the source app.yaml never appears in the unit) and the manifest structure. - NOT in this increment (next): the restore-from-unit recreate (re-pull + compose-up + secret
recovery from guest/PBS) and its fail-closed
data_keygate, with live AdventureLog readable-data validation. The README backup-paths section (stale restic/secondary) is rewritten when Tier 2 lands.
v0.52.0 — Phase 1 GATE: deploy-side double-nest fix + path-agreement lock (2026-06-13)
Completes the Model-A double-nest reconciliation deferred in v0.48.0. v0.51.0 fixed the backup
helper side (NamespaceRoot provenance); the deploy/compose side still wrote one segment too
deep. On a Model-A in-guest drive the guest mount /mnt/<drive> already IS the host's
<drive>/felhom-data namespace, so the catalog templates' ${HDD_PATH}/felhom-data/appdata/<app>
double-nested to .../felhom-data/felhom-data/... on disk — diverging from where the backup helpers
look (AppDataDir(NamespaceRoot(HDD_PATH,true)), single-nested).
- Fix lives in the app catalog (
app-catalog-felhom.eu): all four HDD app templates (romm,nextcloud,immich,paperless-ngx) changed${HDD_PATH}/felhom-data/appdata/<app>→${HDD_PATH}/appdata/<app>. The controller passesHDD_PATHthrough verbatim and never appended the segment, so no controller runtime change was needed. Catalog change lands via git-sync / "Sablonok frissítése". - Agreement test (new):
internal/stacks/hddpath_agreement_test.goresolves a compose's${HDD_PATH}bind mounts via the real deploy-sideParseComposeHDDMountsand asserts they are byte-identical to the backup-sideAppDataDir(NamespaceRoot(HDD_PATH,true))— no doubledfelhom-data, deploy and backup locked together so they cannot drift again. - Live migration: existing drive-resident apps whose data sat at the doubled
…/felhom-data/felhom-data/appdata/<app>are migrated (stop → move → verify → redeploy) to the single-nested path (RomM confirmed on the demo guest).
v0.51.0 — offsite-backup UI (felhom-pbs DR) + Model-A double-nest fix (2026-06-12)
Pairs with felhom-agent v0.28.0 (whole-guest backup re-targeted to the offsite PBS tier).
Backups page — the whole-guest backup is now shown as real DR (separate hardware). The
"Rendszermentés" section's target label calls out the offsite tier: backupTargetLabel returns
"Biztonsági szerver – külön hardver (PBS)" for a PBS-stored backup (detected via backupIsPBS
on the target id / archive volid), so the customer sees the backup survives a host hardware failure.
The app-data section's "Távoli mentés" card stops reading "nincs beállítva": a new
guestBackupView.Offsite flag drives it to "külön hardveren (PBS)" with a ✓ when the whole-guest
backup landed on PBS. The restore-test "Visszaállítás ellenőrizve" trust signal is unchanged.
Model-A double-nest fix — drive-resident app backups land single-nested. Under slice-10 Model A the
host agent binds <drive>/felhom-data onto the guest mountpoint, so an enrolled drive's in-guest mount
IS the felhom-data namespace root (basename need not be felhom-data, e.g. /mnt/felhom-usb). The
backup path helpers were re-prepending felhom-data, producing .../felhom-data/felhom-data/... on the
host. appbackup path helpers now take a NAMESPACE ROOT (no internal felhom-data join) plus a new
NamespaceRoot(drivePath, inGuestDrive); backup.Manager.namespaceRoot/AppNamespaceRoot resolve
provenance (a drive-resident app's mount is the root as-is; only the SSD-only systemDataPath fallback
appends felhom-data). All parallel constructions updated coherently so writes, deletion
(GetStackBackupData, RemoveStack backups-base + ProtectedHDDPaths — legacy double-nest dirs kept
protected), the wipe-warning secondary scan, and export all agree. api.router passes the namespace
root across the package boundary. New appbackup test asserts no doubled felhom-data segment for an
in-guest drive and exactly one for the system fallback.
v0.50.0 — slice 10 P4: dual-role drives + backup-aware wipe warning (2026-06-12)
Pairs with felhom-agent P3 (self-heal). Establishes the dual-role MODEL + the backup-aware wipe warning; the cross-drive backup ENGINE (restic USB1↔USB2) is a follow-on slice (needs a 2nd physical drive to validate) and is deliberately NOT built here.
- 4A dual-role eligibility: a user-data drive is appdata AND backup-target-eligible (it may hold
cross-drive backup copies of other drives) — it is not locked to a single role. Surfaced in the
drive overview's per-card purpose note ("Más meghajtók biztonsági mentési céljaként is szolgálhat").
felhom-pbsstays the dedicated whole-guest backup datastore (operator-signature); system/backup roles unchanged. - 4B backup-aware wipe/eject warning:
handleStorageImpactnow also returnsbackup_copies— the apps whose cross-drive (secondary) backups are stored on the drive (backupCopiesOnPathscansfelhom-data/backups/secondary/<app>, skipping the shared restic repo /_infra). The type-to- confirm modal names them ("Ez a meghajtó más alkalmazások biztonsági másolatait is tárolja — a törlés ezeket is eltávolítja"). The wipe stays customer-confirmable (the copies are redundant — originals live on the source drive), not operator-signature. Forward-compatible: empty until the cross-drive engine writes there. Test:TestBackupCopiesOnPath.
v0.49.0 — slice 10 P2 activation: pending-drive detection + "Újraindítás most" (2026-06-12)
A drive enrolled into a running guest activates only at the next guest boot (the host-side live inject is blocked on unprivileged LXC — see felhom-agent v0.26.0). Per the decision: enroll persists (no forced reboot), and the customer activates pending drives with one batched restart.
- Pending detection (
pendingActivationDrives): a registered StoragePath whose backing drive the agent reports present+attached but which is NOT a live mount in this container → "pending activation". - Settings UI: a banner ("N meghajtó aktiválásra vár") with an "Újraindítás most (~30 mp)" button
(one restart batches all pending drives).
POST /api/storage/activate→agentapi.GuestReboot→ agentPOST /guest/reboot. The reboot takes the controller down too, so the JS reloads after the restart window rather than awaiting the (cut-short) response.
v0.48.0 — slice 10 P2C: enroll passes the drive into the guest (passthrough) (2026-06-12)
Pairs with felhom-agent v0.25.0 (POST /disks/guest-attach) + the golden's /mnt:rslave controller
bind. Closes the diagnosed Branch-A gap: enrolling an external drive now makes it actually usable in
the guest, not just mounted on the host.
- agentapi: new
GuestAttach(where)→POST /disks/guest-attach(idempotent on the agent side). - Enroll triggers attach:
runStorageInit,runStorageAttach, andhandleStorageRegistercallattachIntoGuestafter recording the StoragePath. Best-effort (logged, non-fatal) — the registration is the durable intent; a transient attach failure is healed by P3 self-heal (next slice). Test:TestRunStorageInit_Successnow asserts the drive is guest-attached. - Note: app data on these drives is written via
HDD_PATH(the registered/mnt/<name>), which Model A binds to the drive'sfelhom-datanamespace — so app bytes land on the external drive, and the controller's storage probe (os.Stat + IsMountPoint) sees a real mount → the "nem elérhető" banner clears. (The controller's own backup-path helpers'felhom-datalevel is reconciled when app-data backup-to-drive is wired; not P2.)
v0.47.0 — backups page: whole-guest backup visibility + manual trigger (agent-sourced) (2026-06-12)
The backups page previously showed only the app-data (DB-dump) tier and had zero view of the agent's whole-guest PBS/vzdump backup. Adds visibility + a manual trigger over the agent's existing per-guest backup API (no agent change). Cadence/retention CONFIG stays out (hub-served policy, slice 10).
- agentapi (2A):
StatusResponsegainsBackup *BackupRecord(the agent's latest recorded whole-guest backup — target/archive/mode/size/success/started-at);DueResponsegainsage_seconds; newRestoreTestStatus()→*RestoreTestRecord(the "verified restorable" signal, nil until one runs). Non-hollow client tests (backup_test.go): parse the documented JSON + assertStartBackupPOSTs to/backup. - Section "Rendszermentés (teljes mentés)" (2B): new read-only cards above the app-data section —
last whole-guest backup (time + size + target: PBS vs Helyi (local), surfaced from the archive
volid/target-id), next-due (from
/backup/dueage vs cadence), restore-test result, and the running phase. Agent-unconfigured/unreachable degrades to a note, page still renders. - Manual trigger "Mentés most" (2C): the controller owns quiescing (confirmed: the
quiesce.Loopstops stacks →POST /backup→ polls → resumes; the agent's vzdump is crash-consistent only). The button therefore goes through the loop, not a bare agent call.quiesce.Loopgains a mutex +TriggerNow()(single-flight viaTryLock+ the existing marker;ErrBackupInProgresson overlap) that runs the same stop→backup→resume cycle async, bypassing the due-check. NewPOST /api/guest-backup/trigger+GET /api/guest-backup/status(distinct prefix from apiRouter's app-data/api/backup/{run,status}to avoid shadowing). The button warns per mode (snapshot ≈ a few seconds' downtime on lvm-thin; stop = full downtime). - App-data section (2D): the existing per-app DB-dump rows/table are now under a clear "Alkalmazás-mentések (adatbázis + konfiguráció)" divider, distinct from the whole-guest tier above (whole-guest = appliance restore; app-backup = granular per-app). No structural change.
- Config (2E): OUT OF SCOPE — whole-guest cadence/retention is hub-served policy (slice 10), so it survives re-provision; no agent config surface added.
v0.46.0 — fix: /backups 500 (template referenced disk-tier fields stripped in 8C) (2026-06-12)
GET /backups returned HTTP 500. Root cause (from the live log, not guessed):
backups.html:64: executing "backups" at <.Backup.RepoStats>: can't evaluate field RepoStats in type interface {}. The 8C de-privileging slimmed FullBackupStatus to app-data only (DB dumps +
Docker-volume tars; the disk-tier restic/cross-drive backup moved to the host agent), but
backups.html still carried the full pre-8C restic UI. It referenced .Backup.X struct fields that no
longer exist: RepoStats, LastBackup, ResticSchedule, NextBackup, PruneSchedule, Retention, SnapshotHistory, LastCheckTime, LastCheckOK. While those fields existed-but-nil, {{if .Backup.X}}
short-circuited safely; once the fields were removed from the struct, the field access itself errors →
500. (Not a panic, not a funcmap/nil-subfield issue; root-level map keys like .PerDriveRepoStats are
map lookups → nil on miss → safe, and the Tier1*/Tier2* fields are on AppBackupRows, still supplied.)
Fix — removed the dead disk-tier UI from backups.html, keeping the app-data backup view:
- Section 0 storage-stats: dropped "Mentési tároló" + "Pillanatképek" (RepoStats); kept "DB mentések".
- Section 1 cards: the status card now keys on
.Backup.LastDBDump(was.Backup.LastBackup); removed the "Tároló méret" card. - Section 2 schedule: removed the "Restic pillanatkép" + "Karbantartás" rows and the restic-last-backup/retention summary; kept the DB-dump schedule + a DB-dump last-run summary.
- Section 5 "Pillanatképek" (restic snapshot history): removed entirely.
- Section 6 "1. szint" tier: removed the per-drive/repo-stats + integrity rows (relabeled "(restic)" → "(adatbázis + konfiguráció)"); kept the DB-dump-count row.
No Go change (FullBackupStatus was already correct); template-only. settings.html's .ResticSchedule/
.LastCheckTime are unaffected — they're root-map lookups (nil-safe), not struct-field access.
v0.45.0 — storage UX polish: deterministic order, init filter, register shortcut, system-storage clarity (2026-06-12)
Builds on v0.44.0's role-aware drive management. Pairs with felhom-agent v0.24.0 (the eject role-gate lives at the agent — see its CHANGELOG). This release is the controller-side clarity/ordering polish.
- Deterministic disk order (B1) —
GET /api/disksnow sorts the agent's drive list server-side: user-data → system → backup (then unrecognized), alphabetical by storage name within each tier. The agent's storage view iterates an unordered Go map, so the list previously reordered on every reload (CLAUDE.md lesson #3). The customer's manageable drives are now always on top, stably.sortDisksForViewinagent_disk_handlers.go+TestSortDisksForView. - Init wizard excludes mounted drives (B2) —
storage_init.html's formattable filter gained&& !d.mount_path, matching the attach wizard: an already-mounted drive (e.g.felhom-usb) no longer appears as an "initialize" candidate. Eject it first to make it an init target. - Register shortcut (B3) — a mounted, unregistered user-data drive now offers Regisztrálás
as its PRIMARY per-card action (Leválasztás/Törlés stay secondary). It records the existing mount
into the
StoragePathregistry (no format, no eject) via the newPOST /api/storage/register→registerStoragePath, then FileBrowser syncs. The natural "use this drive" intent, not "wipe it". - System-storage clarity (B4) —
localandlocal-lvmare both kept (not collapsed); each storage card now carries a plain-Hungarian purpose description keyed on the agent's role/type, the app-backing storages (local-lvm→ "Alkalmazás-rendszer"; user-data → "Alkalmazás-adatok") are tagged, and a one-line tiering note above the list answers "which storage do the apps use?". Pure controller-side presentation — no agent contract change; role/type stay authoritative from the agent. - Eject impact (B5) — the eject confirmation already lists, by name, the deployed apps that lose
their storage (via
/api/storage/impact), at parity with the wipe warning — verified, no change.
v0.44.0 — role-aware drive management: protected lockout + customer type-to-confirm wipe + drive-list restyle (2026-06-11)
The controller half of the storage-authorization redesign. The drive UI is now driven by the agent's
authoritative role (system | backup | user-data, from GET /api/disks): the appliance's own
system storage and the backup safety-net are visibly protected with NO destructive controls; the
customer manages their own data drives with informed consent instead of a support ticket.
agentapiclient —DiskInfogainsrole+ capacity (total_bytes/used_bytes/used_fraction);FormatResultgainsrole/needs_confirmation/durable_id.FormatDisknow takesconfirmed+durableIDand returns a newErrNeedsConfirmation(user-data, awaiting the customer's confirmation) distinct fromErrFormatRefused(system/backup, operator signature).- Role-aware overview (
settings.html, "Meghajtók (ügynök nézet)") — restyled from a raw<table>to cards in the house style: prominent storage name, mono device/mount detail, badges for class (gyors/lassú), data ("Adatot tartalmaz"), role (🔒 Rendszer / 🔒 Biztonsági mentés — védett / Felhasználói adat) and registered state, plus a capacity bar (the monitoring page's green→amber→redsystem-bar). Destructive controls (Leválasztás / Törlés) render only for user-data drives mounted under/mnt. System/backup get the lock badge and no controls. - Type-to-confirm + name-the-apps — a modal that (1) lists, by name, the deployed apps whose
data lives on the drive (
GET /api/storage/impact→appsUsingPath), and (2) requires the customer to type the mount name before the destructive button enables. No reflex-clickable destructive action. Applies to both eject and wipe. - Customer wipe (
POST /api/storage/wipe) — eject (unmount + deregister) then a server-side two-step customer-confirmed format (learn the agent's durable id, then re-submitconfirmed:truebound to it). The mount name is re-checked server-side. A system/backup device is refused by the agent regardless of what the controller sends. - Init wizard (
storage_init.html) — the data-bearing path now uses the customer-confirmation flow (type-to-confirm → re-submit confirmed) instead of thefelhom-opsigninstruction; the disk selector is restyled to cards and lists only user-data targets.storage_attach.htmllikewise restyled (cards, user-data only). No raw<table>remains in the storage UI. - Tests —
agentapi: blank → ok, system/backup → ErrFormatRefused (+pending op), user-data → ErrNeedsConfirmation (+durable id), confirmed → formatted.web: init surfaces NeedsConfirmation and does NO mount/register; confirmed init forwards the confirmation+durable id and proceeds; the dependency-impact (appsUsingPathIn) names the right deployed apps.
Pairs with felhom-agent v0.23.0 (the authoritative role classifier + the tiered wipe gate).
v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11)
After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (/settings/storage/init,
/attach, /migrate-drive, per-stack /migrate) — all 404. Everything underneath already worked (the
agent owns disk execution + the data-bearing signature gate; the controller has the agentapi client +
/api/disks/* proxies + the StoragePath registry). This is a controller-only UI/orchestration layer
over those.
- Storage overview (
settings.html, driven byGET /api/disks): the agent's live disk view — name, type, state, device, mount, class, and thedata_bearingbadge + registered cross-reference. - Guided init (
/settings/storage/init+POST /api/storage/init): pick a disk → format → resolve the new fs UUID from the re-listed disks → assign (mount) → register theStoragePath. A data-bearing device is REFUSED by the agent; the UI surfaces the exactfelhom-opsign -op storage_wipe -host … -durable-id …command and stops — there is no force-format path (the gate is the agent's; the controller has no destructive authority). - Guided attach (
/settings/storage/attach+POST /api/storage/attach): non-destructive — resolve the existing fs UUID → assign → register. - Eject (
POST /api/storage/eject): benign unmount (data preserved) + deregister, surfacing the agent's dependent-guest warning. agentapi:DiskInfogainsDurableID(+FSUUID()to strip theuuid:prefix — the assign key);FormatResultgainsPendingOp(+OpsignCommand()), now parsed from the agent's 403 body (the old path discarded it). Pairs withfelhom-agentv0.22.0, which exposesdurable_idin/disks.- Honest buttons: init/attach are wired; migrate (drive + per-stack) is disabled "Hamarosan" — no 404s.
- De-priv template debt (Phase 3): removed the dead
CrossDrive*blocks indeploy.html(the "2. mentés" form + 3 JS fns) andbackups.html(the run buttons + 2 JS fns) — they referenced fields the de-privileged handlers no longer provide (agt/eqover a missing field 500s the page). - Migration (controller-side rsync) is intentionally deferred to its own slice (the migrate buttons are disabled, not dead).
- Tests: the init refusal surfaces the
pending_op/opsign and performs no assign/register; success assigns with the resolved UUID + registers the expectedStoragePath; a template-parse test guards all pages.
v0.42.1 — real Let's Encrypt cert: wildcard proactive issuance via the controller route (2026-06-11)
The base-infra traefik obtained no real cert (acme.json empty) — both routers relied on the
websecure entrypoint-default certResolver, which does not trigger proactive DNS-01 issuance, so
everything ran on traefik's self-signed default (masked externally by the tunnel's noTLSVerify).
This blocks LAN-direct (a LAN client TLS-handshakes straight to traefik and needs the real cert).
infra.RenderControllerRoute(domain, wildcardTLS)— the always-present controller route is now the wildcard-issuance anchor: when DNS-01 ACME is configured it carries router-leveltls.certResolver: letsencrypt+tls.domains: [{main: "*.<domain>", sans: ["<domain>"]}], so traefik proactively obtains*.<domain>+ apex at startup via Cloudflare DNS-01. Every other router (filebrowser, future apps) then serves that one wildcard by SNI match — no per-app certresolver labels, real cert ready before the first client connects.stacks.wireControllerpasseswildcardTLS = (CFAPIToken != "" && Email != "").- Empirically established (staging on 9201): traefik v3 issues from a router-level
tls.domainsbut NOT from the entrypoint-levelhttp.tls.domains(acme.json stayed empty with the latter). The v0.42.0 attempt (entrypointdomains+TraefikData.Domain) was reverted accordingly. - Validated staging→prod on guest 9201 (Fake LE wildcard → real LE wildcard), then GATE:
felhom.<domain>files.<domain>return200 0(real wildcard cert, TLS verify OK) direct-to-guest from a real LAN host.
v0.41.2 — fix controller-route auto-connect + dead dashboard cross-drive block (2026-06-11)
Two fixes found while live-validating v0.41.1 routing on guest 9201:
containerOnNetworkfalse-positive (v0.41.1 regression): the membership check used{{index .NetworkSettings.Networks "traefik-public"}}, whose output for an absent key is<nil>(non-empty) — sowireControllerthought the controller was already attached and skipped thedocker network connect. traefik then matched the route but 502'd (backend unresolvable). Fixed by listing the network names and matching exactly. Live:felhom.<domain>now reaches the controller.- Dead cross-drive dashboard block (pre-existing, slice-8C leftover):
dashboard.htmlstill referenced.CrossDriveTotal/.CrossDriveConfigured/.CrossDriveFailed, which the de-privileged dashboard handler stopped providing — sogt <nil> 0500'd the entire dashboard. Only surfaced now because v0.41.1 finally made the dashboard reachable. Removed the dead block (cross-drive backup is the host agent's job since 8C).
v0.41.1 — wire the controller dashboard into traefik (felhom.<domain> routing) (2026-06-11)
Completes v0.41.0: the base-infra bring-up stood up traefik/cloudflared/filebrowser but nothing routed
the controller itself through traefik, so felhom.<domain> 404'd (live-confirmed: controller on
bridge only, no traefik labels, empty dynamic/). filebrowser self-registers via Docker labels +
network membership baked into its compose; the controller can't — it's started by the golden bootstrap
before traefik-public exists, and the v2 bootstrap.json carries no domain (it comes from the hub
pull). So the wiring must happen post-pull.
infra.RenderControllerRoute(domain)— a traefik file-provider dynamic route:Host(felhom.<domain>)→http://felhom-controller:8080on websecure (tls: {}inherits the entrypoint's defaultletsencryptresolver when ACME is configured, else self-signed).EnsureBaseStacknow callswireController: writesdynamic/controller.yml(write-if-changed, so the traefik file watcher doesn't reload every health tick) anddocker network connect traefik-public felhom-controller(idempotent — skipped when already attached) so traefik can resolve the controller by name. Runs on first boot and every self-heal tick. The Section-G shared/opt/docker/stacksmount means traefik picks up the dynamic file live.- Diagnostic confirmed the tunnel chain was already healthy (token tunnel-id matches the DNS tunnel;
CF ingress
*.<domain> → https://traefik); the only gap was this controller wiring.
v0.41.0 — first-boot base-infrastructure bring-up + self-heal (+ Section-G mount fix) (2026-06-11)
Lockstep with felhom-agent v0.20.0 + a golden rebake. A freshly-onboarded controller came up ONLINE
but Health = FAIL: protected containers not running — traefik, cloudflared, filebrowser: nothing
ever deployed the base stack on a Proxmox bootstrap (it was only ever created by the bare-metal
scripts/docker-setup.sh), and the health loop only detected the gap. This release makes the
controller stand up its own base infrastructure.
- New
internal/infrapackage — pure renderers (//go:embedtemplates lifted verbatim fromscripts/docker-setup.sh) for traefik (traefik.yml+ compose + a 0600.envcarrying the CF DNS token only when set), cloudflared (compose;TUNNEL_TOKEN), and filebrowser (compose +config.yaml). Image tags are PINNED here as the single source of truth —traefik:v3.6.7,cloudflare/cloudflared:2026.6.0,gtstef/filebrowser:1.3.3-stable(no:latest). The web FileBrowser sync path now delegates toinfraso the pins can never diverge. stacks.Manager.EnsureBaseStack(internal/stacks/infra.go) — creates thetraefik-publicnetwork, then deploys traefik → cloudflared → filebrowser under${stacks_dir}/<name>. Single-flight (TryLock — it's fired from both first-boot and every health tick), idempotent (skips a stack whose container is already running), non-fatal (logs, never crashes). cloudflared is deployed only when a tunnel token is configured; filebrowser is not overwritten if its compose already exists (preserves the storage mounts the web sync path manages).- Triggers (
cmd/controller/main.go): first-boot bring-up after stack init (goroutine, non-fatal); self-heal callsEnsureBaseStackunconditionally on everysystem-healthtick (decoupled from the issue strings — safe because of the single-flight + idempotency). - Dynamic protected set (
monitor.EffectiveProtected): cloudflared counts as a protected container only when a tunnel token is configured, so a LAN-only node doesn't report FAIL forever for a stack it intentionally skips. Detection and the bring-up condition agree. - Section-G fix (in
felhom-agentbuild-golden.sh): the controller writes compose stacks under/opt/docker/stacksinside its container, but the bootstrapdocker runnever bind-mounted that path, so the guest daemon resolved every relative bind source on the guest filesystem (empty dirs) — breaking all bind-mounted stacks (base infra + customer apps). Fixed with a same-path host bind (-v /opt/docker/stacks:/opt/docker/stacks). Empirically confirmed on guest 9201 (probe printedcat: read error: Is a directorybefore,hello-from-controllerafter). - Tests: non-hollow
infrarender tests (customer params present, no:latestsurvives, both ACME/CF branches render,.env0600, rendered YAML parses),EnsureBaseStacksingle-flight, andEffectiveProtected.
v0.40.0 — bootstrap pull+merge onboarding (controller pulls its config from the hub) (2026-06-11)
Lockstep with felhom-agent v0.19.0. Fixes the onboarding 401: a freshly provisioned guest used to
seed a "configured" controller.yaml from the agent's host hub key, which the hub's /api/v1/report
(customer-scoped auth) rejects → the controller could never report ONLINE. Now the controller pulls
its full controller.yaml from the hub on first boot (the hub mints the customer-scoped key) and
merges in the per-guest local_api block.
Changed — bootstrap contract v1 → v2 (internal/bootstrap)
SchemaV1 → SchemaV2 = "felhom.bootstrap/v2".BootstrapCustomerdropsname/domain/email(keepsid);BootstrapHubdropsapi_key/host_id, addsretrieval_password(SECRET).local_apiunchanged. A non-v2 schema → setup mode.MaybeIngest(configPath, cfg, logger, pull PullFunc)— new injectedpullarg (decision (b): keepsbootstrapfrom importing the heavyinternal/reportpackage; wired inmain.gotoreport.PullConfig). Flow: idempotent (configured → return, no pull) → parse + validate v2 → pull the hub config with bounded retry (1 + 3 backoff attempts on transientErrPullTransientonly; auth/not-found fail fast) → merge the per-guestlocal_apiat the YAML-map level (preserves every hub-emitted field — assets, CF, backup) → write 0600 atomic → reload. Fail-safe throughout: a hub outage at first boot leaves the guest in setup mode (the manual wizard remains the fallback), never crashes.- New sentinel
ErrPullTransient;main.go's pull adapter mapsreport.ErrHubUnreachableonto it (transient/retryable) and passes auth/not-found through as permanent. RemovedconfigFromBootstrap(the host-key-seeding path) and the struct-marshal writer.
Tests (internal/bootstrap)
- Pull+merge (asserts the merged controller.yaml carries the customer key + identity + a preserved
unmodeled
assets.source_urland the bootstrap'slocal_api, with no host key); idempotency (pull never invoked when configured); transient-retry (N attempts then setup); permanent-no-retry; non-v2 schema reject; missing-required reject; malformed/absent. Cross-repo render→ingest round-trip verified against the agent's v2 renderer.go build ./... && go test ./...green.
v0.39.1 — 8C orphan-template cleanup (source hygiene) (2026-06-11)
Dead-template removal — no behaviour change. Slice 8C de-privileged the controller and retired the
disk/storage/restore web handlers (storage_handlers.go, handler_restore.go and the /api/storage/*
/api/restore/*routes), but five HTML templates that those handlers rendered were left behind. They have zero.goreferences, zero cross-template{{template …}}references, no route, and no nav entry; the embed is a glob (//go:embed templates/*.html templates/*.css), so deleting them is safe and the remaining 14 templates still embed cleanly.
Removed (internal/web/templates)
storage_init.html,storage_attach.html,migrate.html,migrate_drive.html,restore.html— orphaned pages for removed endpoints. Re-confirmed unreferenced before deletion (grep -rnoverinternal/: only the templates' own{{define}}lines matched).
Noted, not changed (dead-but-harmless restic/cross-drive remnants)
- Two never-called notifier methods
NotifyCrossDriveCompleted/NotifyCrossDriveFailed(internal/notify/notifier.go:353,359) and a vestigialcrossdrive_failedentry in the notification-events list (internal/web/handlers.go:937) that still renders a settings toggle for an event that can no longer fire. Plus restic config fields/comments inconfig/config.go,settings/settings.go,report/types.go. None are live emitters — left in place, flagged for a future dedicated cleanup.
v0.39.0 — slice 9: host metrics in the controller (customer host-health view) (2026-06-10)
The customer-facing half of slice 9. Pairs with felhom-agent v0.14.0. The de-privileged controller (slice 8C) sees only its own cgroup, so it can't read the host. The monitoring page now shows the real Proxmox box — CPU% + load, memory used/total, CPU/chassis temperature (or "n/a" when the hardware exposes none), uptime, and per-storage capacity (used/total bar, thin-pool fill, disk temp/wear) — proxied from the agent's new GET /host/metrics.
Added (internal/agentapi)
Client.HostMetrics(ctx)— calls the agent'sGET /host/metricsover the leaf-pinned, per-guest-token channel (same client as the 8C disk proxy) and returnsHostMetricsResponse(host block + per-storage targets). New mirror structsHostMetrics(with nullableCPUTempC),StorageTarget,ThinPoolFill,SmartSummary(subset — only the fields the UI renders; unknown wire keys ignored).
Added (internal/web)
ServeHostMetricsAPI(agent_host_metrics_handler.go) — a thin read-only proxy:GET /api/host-metrics→ agentGET /host/metrics. Returns the{ok,data,error}envelope; 503 when the local API is not configured (unprovisioned guest), 502 on an agent error. Wired inmain.gobehindRequireAuth(GET-only → no CSRF wrapper).- Monitoring view (
templates/monitoring.html): a new "Szerver állapota (gazdagép)" card at the top renders the agent's host block + per-storage capacity bars (reusing the existingsystem-bar/storage-itemstyling).cpu_temp_c: nullrenders as "n/a" cleanly. Polls/api/host-metricsevery 8 s while the page is open (the host view is a live snapshot, distinct from the controller's own 60 s metric charts); shows a yellow "nem elérhető" banner when the agent is unreachable.
Tests
agentapi/host_metrics_test.go: decodes host + storage (thin-pool, SMART temp + NVMe wear), USB drive's null SMART, and a nullcpu_temp_c→ nil pointer.
v0.38.0 — slice 8B.2: quiesce downtime optimization (resume at snapshotted) (2026-06-10)
The controller half of slice 8B.2. Pairs with felhom-agent v0.13.0. The quiesce loop now resumes
the app at the snapshotted phase (storage snapshot taken) instead of done — app downtime
drops from whole-backup to until-snapshot (seconds), with no loss of app-consistency (the
snapshot froze the app-stopped state).
Changed (internal/quiesce)
- The status-poll loop resumes (
StartStack+ clears the marker) atsnapshotted, then keeps polling todone/failed— so a new backup isn't started until this one truly finishes, and a post-snapshot failure is observed (the backup isn't "successful" untildone; resuming early does not mark it done). - Fallback preserved: if
snapshottednever arrives (stop/downgraded mode), it resumes atdoneexactly as 8B. Crash-safety unchanged: marker written before stop; guaranteed unquiesce; startupRecover(). A backup that fails aftersnapshottedis harmless — the app is already up.
Tests
- resume at
snapshotted(RESUME event beforedone, marker cleared, then tracked todone); stop-mode fallback (resume atdone, nosnapshotted); fail-after-snapshotted(one resume, app stays up); the 8B crash-safety tests stay green.
v0.37.0 — slice 8C: controller de-privileging + disk management via the agent (2026-06-10)
The in-guest controller half of slice 8C (closes slice 8). The disk-execution subsystem moves to
the host agent (felhom-agent v0.12.0); the controller becomes Docker-only with no disk
privileges and drives disk management through the agent's local API. ~12.3k LOC retired.
Added
internal/web/agent_disk_handlers.go— agent-backed disk API (ServeDiskAPI):GET /api/disks(list + data-bearing flags),POST /api/disks/assign(mount),POST /api/disks/eject(unmount + dependent-guest warning),POST /api/disks/format. Thin proxies over the slice-8Aagentapiclient (leaf-pinned, own token). Execution is the agent's; the UX stays here. A data-bearing format refusal (agentapi.ErrFormatRefused) is surfaced as HTTP 409 "operator authorization required" (the 8C invariant — the agent inspects the device; the controller's claim is irrelevant).internal/agentapi:Disks/AssignDisk/EjectDisk/FormatDisk+ErrFormatRefused.
Retired (moved to the host agent / obsolete)
internal/storage/— the entire package (scan/format/attach/migrate/safety, DriveMigrator).internal/backup/— restic (ResticManager),crossdrive(CrossDriveRunner),restore_drives*,disk_layout,local_infra,restore_scan, the restic path helpers, and the drive-restorerestore_app*.backup.Managersurgically split to app-data only: kept DB dumps, Docker-volume tars, and per-app restore; dropped restic snapshots, cross-drive, per-drive repo stats, integrity check, snapshot history.RestoreAppnow restores from the on-disk volume-tar dumps (snapshot/restic restore is the agent's domain).internal/report/infra_backup*+infra_pull(kept the setup fresh-install config download asconfig_pull.go);internal/setup/scanner.go+ the wizard's drive-recovery flows (restore is the agent's job now);internal/monitor/watchdog.go+pinger.go(storage watchdog → agent; Healthchecks.io pinging → the Hub owns monitoring);web/storage_handlers.go+handler_restore.go(replaced by the thin agent-backed disk API).- Wiring dropped from
main.go/api/router.go/web/server.go: CrossDriveRunner, DriveMigrator, storage watchdog, infra-backup push, the restic backup scheduler jobs (kept the db-dump job).
De-privileged
scripts/docker-setup.shcontroller compose template: droppedprivileged: true, the/mntrshared bind,/sys,/dev,/etc/fstab,/run/udev. The golden's bootstrapdocker run(felhom-agentbuild-golden.sh) was already minimal (bootstrap config + data + docker socket).
Tests / build
go build ./...+go test ./...green (app-data backup / stacks / quiesce / bootstrap / agentapi / disk-client tests pass). The data-bearing-format refusal is proven inagentapitests.
v0.36.0 — slice 8B: app-consistent backup quiesce loop (stack-stop) (2026-06-10)
The in-guest controller half of slice 8B (doc 03 §6/§8). Pairs with felhom-agent v0.11.0. An
agent-initiated vzdump is crash-consistent only (an LXC has no fsfreeze); this makes app-consistency
the controller's job — it stops its app stacks around the backup so the captured state is
clean-shutdown-consistent.
Added
internal/quiesce— the background quiesce loop: poll the agent'sGET /backup/due→ when due, quiesce (stop deployed, non-protected, running stacks) →POST /backup→ pollGET /backup/statustodone/failed→ unquiesce (restart exactly the stacks it stopped).- Crash-safety (the centerpiece — a stranded-down app is worse than a crash-consistent backup):
a persisted marker (atomic,
0600) written before stopping anything; guaranteed unquiesce (a deferred closure restarts the stacks on a backup error, a status-poll error, the max-quiesce bound, or context cancellation); a max-quiesce-duration hard bound that restarts the app no matter what (the backup continues on the agent); crash recovery at startup (Recover()restarts stacks left stopped by a mid-quiesce crash, then clears the marker); and the marker as a single-flight guard.
- Crash-safety (the centerpiece — a stranded-down app is worse than a crash-consistent backup):
a persisted marker (atomic,
agentapi:BackupDue/StartBackup/BackupStatusmethods + aposthelper.stacks.Manager.RunningAppStacks()— deployed, non-protected, currently-up stacks (protected infra — traefik/cloudflared/felhom-controller — is never stopped), sorted for deterministic order.config.QuiesceConfig(quiesce: enabled, poll_interval, status_poll_interval, max_quiesce_duration). Wired inmain.go:Recover()at startup, then the loop goroutine, gated on the local API being configured (a provisioned guest) + quiesce enabled.
Tests
- happy path (stop → backup → poll done → restart exactly those, in order; marker cleared); backup-start failure → stacks STILL restarted; failed phase → restarted; max-quiesce guard → restarted at the bound; crash recovery → marker stacks restarted + cleared; single-flight (no second backup while a marker is active); only the stacks we stopped are restarted (an already-stopped stack is never started); and marker-written-before-stop ordering.
v0.35.0 — slice 8A: bootstrap.json ingestion + pinned agent local-API client (2026-06-10)
The in-guest controller half of slice 8A (doc 03 §6). Pairs with felhom-agent v0.10.0. No
behaviour change for an already-configured controller; adds the first-run provisioning path.
Added
internal/bootstrap— first-runbootstrap.jsoningestion (config-contract decision (c)). On startup, if the controller is NOT yet configured AND the host agent's back-half attached abootstrap.jsonconfig mount, the controller seedscontroller.yamlfrom it and comes up configured, skipping the setup wizard. Idempotent (an existingcontroller.yamlis never clobbered) and fail-safe (a malformed/absent/missing-identity/unsupported-schema bootstrap leaves the controller in setup mode — logs, never crashes). The agent emits the stable contract; the controller owns the translation (the two stay decoupled).internal/agentapi— a minimal pinned client for the agent's local API. It reaches the agent over the bridge, pinning the agent leaf-cert SHA-256 from the bootstrap (fails closed on mismatch —VerifyPeerCertificateexact leaf-DER match, the same pin convention the agent uses for the Proxmox/PBS host certs), and authenticates with the per-guest bearer token. In 8A it exercisesGET /storage(connectivity + the controller learning its mounts); the/backup/duequiesce loop is 8B.config.LocalAPIConfig(local_api: endpoint, fingerprint, token) — seeded from the bootstrap.- Startup probe — when seeded with a local-API endpoint, the controller proves the channel at boot and logs this guest's mounts (non-fatal).
Tests
- bootstrap: seeds when unconfigured (reloads configured, skips setup); never clobbers a configured controller; stays in setup on malformed / missing-identity / unsupported-schema / absent bootstrap.
- agentapi: correct pin + token reaches
/storage; a wrong pin fails closed; a bad fingerprint is rejected at construction; colon-separated fingerprints are accepted.
docs: reflow CLAUDE.md; unify REPORT/CHANGELOG convention; add no-secrets rule (2026-06-08)
Changed
- Reflowed
CLAUDE.md— removed hard mid-paragraph line wraps (prose, list items, blockquotes now single-line, soft-wrapped); code blocks and tables untouched; rendered output unchanged. - Added the uniform REPORT/CHANGELOG convention:
CHANGELOG.mdis the cumulative log (newest on top);REPORT.mdis overwritten with the most-recent implementation only. Added an explicit no-secrets rule (never write tokens/passwords/keys into committed files; reference them as stored out-of-band). Docs/meta only — no code change, no version bump.
Repo rename — deploy-felhom-compose → felhom-controller (2026-06-08)
Changed
- Gitea repo renamed
admin/deploy-felhom-compose→admin/felhom-controller(via API). Sibling rename:admin/proxmox-controller→admin/felhom-agent(docs repo, future agent code). - Reference rework (no functional change): updated every reference to the old repo name across docs and scripts — clone URLs, clone dirs (
~/git/felhom-controller), the customer bootstrap URL inscripts/felhom-wipe.sh/scripts/README.md,controller/build.sh,controller/BUILDING.md,controller/README.md,CLAUDE.md,CONTEXT.md,TASK.md. Local working-copy dirs and the build-server source clone (192.168.0.180:~/git/) renamed to match. - Intentionally unchanged: Go module path
gitea.dooplex.hu/admin/felhom-controller(already matches new name), Docker image pathgitea.dooplex.hu/admin/felhom-controller(registry is namespaced by owner, not repo), binary namefelhom-controller. Historical CHANGELOG entries left as-is (they record what was true at the time).
Refactor — extract app-data-backup primitives into internal/appbackup (no behaviour change) (2026-06-08)
Changed
- New package
internal/appbackup/: extracted the stateless, keep-side app-data backup primitives out ofinternal/backup/— DB dump discovery/execution (dbdump.go:DiscoverDatabases,DumpAll,DumpOne,ValidateDump,ListDumpFiles), Docker-volume/app-data discovery (appdata.go:StackDataProvider,DiscoverAppData,ParseComposeNamedVolumes,ResolveDockerVolumeNames,HumanizeBytes), and keep-side path helpers (paths.go:FelhomDataDir,PrimaryBackupPath,AppDBDumpPath,AppVolumeDumpPath,AppDataDir). Pure move — logic unchanged. - backup/appbackup_bridge.go (new): re-exposes the moved symbols to the
backuppackage via type/const aliases and one-line function forwarders, so the still-present disk/host-side code (restic, cross-drive, drive-mount) and the both-side consumers (web, api, report) compile unchanged. - appexport/export.go, storage/migrate.go, storage/migrate_drive.go: rewired to import
internal/appbackupdirectly and dropped theirinternal/backupimport — these keep-side consumers are now independent of the delete-side code. - Why: Part-2 prerequisite for the Proxmox port. Isolating the keep-side now (as a separate green, behaviour-identical commit) means the disk/host-side code can later be removed without breaking app-data backup or
appexport.appbackuphas zero references to restic/cross-drive/drive-mount and does not importbackup(no import cycle). - Not moved (documented coupling): the
*ManagermethodsRunDBDumps/DumpAppVolumes/DumpAppVolumesSafe(share one mutex/running-flag + status state with the delete-sideRunBackup) andRestoreAppFromTier2(intrinsically reads the cross-drive mirror viacopyFile/AppSecondaryRsyncPath) stay onManager; they delegate toappbackupand are left for the later re-platform step.
v0.34.0 — Backup safety: stop-before-dump, streaming restore, health check, per-app restic, infra configs (2026-02-28)
Changed
- backup/backup.go:
DumpAppVolumesSafe()stops stack before volume dump, restarts after — prevents inconsistent tars of live database volumes (PostgreSQL, MariaDB, SQLite) - backup/backup.go:
backupDrive()includes per-app stack config dirs instead of full StacksDir;controller.yamlonly on system drive — reduces snapshot duplication across drives - backup/crossdrive.go:
VolumeDumperinterface extended withDumpAppVolumesSafe(); cross-drive backup uses safe variant for pre-backup volume dumps - backup/restore.go: Tier 2 DB dump copy uses streaming
copyFile()(io.Copy + atomic rename) instead ofos.ReadFile/os.WriteFile— eliminates full-file memory allocation for large dumps - backup/restore.go: Post-restore health check via
waitForHealthy()polls container state (with docker ps refresh) for up to 90s after restore
Added
- backup/appdata.go:
RefreshAndIsRunning()onStackDataProviderinterface for reliable post-restore state checks (forces docker ps refresh before reading state) - report/infra_backup.go:
InfraStacknow includesDockerComposeB64,AppYamlB64,FelhomYamlB64— actual stack config files for disaster recovery (derived fromGetStackComposePath, no signature change)
v0.33.0 — Docker volume backup + Tier 2 restore + restore dropdown fixes (2026-02-27)
Added
- backup/backup.go:
DumpAppVolumes()exports Docker named volumes to tar files usingdocker run alpine tar;runVolumeDumpsInternal()runs volume dumps for all stacks in nightly schedule (Phase 1b between DB dumps and restic); volume dump dirs included in per-drive restic snapshots - backup/appdata.go:
ResolveDockerVolumeNames()resolves full Docker volume names with project prefix (e.g.,mealie_mealie_datainstead ofmealie_data);GetDockerVolumes()added toStackDataProviderinterface;HasVolumeDatafield onAppBackupInfo,HasVolumesonStackSummary - backup/paths.go:
AppVolumeDumpPath()returns<drive>/felhom-data/backups/primary/<stack>/volume-dumps/ - backup/restore.go:
RestoreAppFromTier2()restores from cross-drive rsync mirror (config, HDD data, DB dumps, Docker volumes via rsync);restoreDockerVolumes()populates Docker volumes from tar files after Tier 1 restore;restoreDockerVolumesFromDir()for Tier 2 volume restore - backup/crossdrive.go:
VolumeDumperinterface +SetVolumeDumper()for pre-backup volume dumps;copyStackVolumeDumps()copies volume tars to_volumes/in rsync mirror - backup/backup.go:
ListSnapshotsForApp()returns snapshots only from the app's home drive primary repo - backup/restic.go:
Sourcefield onSnapshotInfo("restic" or "rsync") - api/router.go:
backupSnapshots()now accepts?stack=param to filter by app's home drive; appends synthetic Tier 2 entry from cross-drive config when backup succeeded - web/handlers.go:
backupRestoreHandler()routestier2-rsyncsnapshot ID toRestoreAppFromTier2() - web/templates/backups.html: Import from
.fabbundle link in restore section;data-has-volumesattribute on restore app options; volume-aware restore type banners; "Konfig + Adatok" label for volume-backed apps
Fixed
- Volume name resolution bug:
ParseComposeNamedVolumes()returned short names but Docker Compose V2 uses<project>_<name>— fixed in both backup and export adapters viaResolveDockerVolumeNames() - Double Tier 1 in restore dropdown: snapshots from non-home drives appeared because stacks dir is in every drive's primary repo — now filtered by app's home drive via
ListSnapshotsForApp()
v0.32.8 — Move optional config to deploy/settings page (2026-02-27)
Changed
- web/templates/deploy.html: Optional config fields (metadata providers, API keys) now render on the deploy/settings page instead of the app info page — consistent with integrations and geo-restriction which already live there
- web/handlers.go:
deployHandlernow passesOptionalConfig,CurrentValues,HasOptionalConfigto the deploy template for deployed apps;appDetailHandlercleaned up to remove optional config data - web/templates/app_info.html: Removed optional config section (HTML + JS) — no longer rendered here
v0.32.7 — Fix FileBrowser config not being read on fresh deployments (2026-02-27)
Fixed
- web/handlers.go:
generateFileBrowserCompose()now setsFILEBROWSER_CONFIG=/home/filebrowser/config.yamlenvironment variable — thegtstef/filebrowserimage bakes inFILEBROWSER_CONFIG=/home/filebrowser/data/config.yamlwhich reads a stale initial config from the data volume instead of the controller-managed bind mount. This caused fresh deployments to show only a single "srv" source, ignore per-drive sidebar entries, and create the database outside the persistent volume (triggering the "new database was created" warning on every container recreation)
Changed
- scripts/docker-setup.sh: Initial FileBrowser compose template also includes the
FILEBROWSER_CONFIGoverride for consistency
v0.32.6 — Format empty partitions on system disk (2026-02-27)
Added
- storage/scan.go: New
FormatablePartitionstruct andFormatablePartitionsfield onScanResult— detects empty (no filesystem), unmounted, non-system partitions on system disks - storage/scan_linux.go: New
getSystemPartitionPaths()resolves actual system partition device paths from fstab (more granular thangetSystemDiskNames()which returns parent disk names).ScanDisks()now populatesFormatablePartitionsafter enrichment - storage/safety_linux.go: New
IsSystemPartition()— checks if a specific partition is a system partition (/, /boot, /boot/efi, swap) or is currently mounted; more granular thanIsSystemDisk()which blocks the entire disk - web/storage_handlers.go: Scan API response now includes
formatable_partitionsarray - web/templates/storage_init.html: Init wizard shows formatable system-disk partitions as a separate selectable section with info banner, conditional warning text, and hidden partitioning progress step
Changed
- storage/format_linux.go:
FormatAndMount()now usesIsSystemPartition()for partition-only operations (CreatePartition=false) instead ofIsSystemDisk()— allows formatting empty data partitions on the system disk while still blocking system partitions
v0.32.5 — USB badge fix + graceful Tier2 backup on disconnected/inactive/removed destinations (2026-02-27)
Fixed
- system/mounts_linux.go:
IsUSBDevice()anddiskModel()now strip findmnt bind-mount suffix ([/subdir]) before parsing device path — fixes USB badge and disk model not showing for drives mounted via the attach wizard - backup/crossdrive.go: Disconnected source/destination drives now silently skip with WARN log instead of returning error — prevents noisy error aggregation in
RunAllScheduled()and false "failed" counts - web/handlers.go + backup/crossdrive.go: Tier2 destination check now covers drives removed from storage (not just marked disconnected) —
IsStoragePathKnown()detects when destination path is no longer in any registered storage, UI shows yellow "Cél meghajtó leválasztva" and scheduler skips silently - web/handlers.go + backup/crossdrive.go: Tier2 destination check now also covers inactive (Schedulable=false) drives —
IsStoragePathSchedulable()detects when destination drive is deactivated, UI shows yellow "Cél meghajtó inaktív" and scheduler skips silently
Added
- settings/settings.go: New
IsStoragePathKnown(path)method — returns whether a path belongs to any registered storage (connected, disconnected, or decommissioned); paths removed entirely return false - settings/settings.go: New
IsStoragePathSchedulable(path)method — returns true only if path belongs to a registered, active (Schedulable), non-disconnected, non-decommissioned storage - web/handlers.go: New
Tier2DestDisconnectedandTier2DestInactivefields onAppBackupRow— detect when Tier2 destination is disconnected/removed/inactive, sets yellow status dot instead of green/red - web/templates/backups.html: New template branches for disconnected ("Cél meghajtó leválasztva") and inactive ("Cél meghajtó inaktív") Tier2 destinations — grayed-out info, warning badge, no "Futtatás most" button
v0.32.4 — Controller telemetry: include controller in hub app telemetry (2026-02-27)
Added
- report/telemetry.go: Include the
felhom-controllercontainer as a special entry in theapp_telemetryarray sent to the hub — reuses all existing hub telemetry infrastructure (memory trends, known issues, fleet aggregation) with zero hub-side changes - report/telemetry.go: New
buildControllerTelemetry()function collects controller container metrics (memory, CPU) and log scan results (warnings, errors, deduplicated issues)
v0.32.3 — Logging cleanup: consistent tags, dedup, standardized prefixes (2026-02-26)
Fixed
- All modules: Standardized
[LEVEL] [module]format across every log line — added missing module tags ([stacks],[backup],[cloudflare],[sync],[scheduler],[storage],[monitor],[metrics],[report],[settings],[setup],[api],[integrations],[selfupdate],[assets],[web]) - Removed duplicate logs: ScanStacks double completion, GetLogs INFO+DEBUG, LoadAppConfig WARN+DEBUG, copyStackDBDumps DEBUG+INFO, invalidateAllSessions INFO+DEBUG
- Standardized stale prefixes:
[CF]/[CF-DEBUG]→[INFO/DEBUG] [cloudflare],[SYNC]→[LEVEL] [sync],[SCHED]→[LEVEL] [scheduler],[API]→[LEVEL] [api],[STORAGE]→[storage],[HEALTH]→[monitor],[ROLLBACK]/[ROLLBACK-ERROR]→[LEVEL] [storage],[DEBUG-SIM]→(simulation) - Fixed wrong log levels: Restic restore start WARN→INFO, ungated DEBUG lines in crossdrive/dbdump/onlyoffice/alerts removed or gated
- Improved vague messages: Settings SetDisconnected/SetDecommissioned now include storage path and migration target
- Added missing logs:
execCommand()error,DiscoverAppData()completion,BuildInfraBackup()completion
v0.32.2 — Comprehensive INFO/WARN/ERROR logging across all modules (2026-02-26)
Added
- stacks/manager.go: INFO logs for status refresh container/stack counts, log fetching, encryption migration, ScanStacks completion
- stacks/deploy.go: INFO logs for config updates, InjectMissingFields summary; ERROR logs for SaveAppConfig failures; WARN for LoadAppConfig errors
- stacks/delete.go: INFO log for ParseComposeHDDMounts result count
- stacks/metadata.go: Fixed LoadMetadata error to use
log.Printfinstead offmt.Fprintf(os.Stderr) - backup/backup.go: WARN for perDriveRepoStats failures; INFO for drive stats, aggregate stats, dump file count, snapshot history save
- backup/crossdrive.go: INFO for cross-drive backup start/completion with success/fail counts; ERROR for rsync failures; INFO for DB dump copy counts
- backup/restic.go: INFO for Snapshot and Check success
- backup/dbdump.go: INFO for DiscoverDatabases count; INFO for DumpAll start/completion
- backup/restore_drives_linux.go: INFO for fstab entry additions
- backup/local_infra.go: INFO for backup version pruning with kept/removed counts
- cloudflare/geosync.go: Standardized all
[GEO]prefixed logs to[INFO]/[WARN]/[ERROR] [cloudflare]format - scheduler/scheduler.go: Standardized all
[SCHED]prefixed logs to[INFO]/[WARN]/[ERROR] [scheduler]format - sync/sync.go: INFO for catalog sync start/completion; ERROR for git/network failures; WARN for file copy errors (replaced
[SYNC]prefix) - report/pusher.go: WARN for Push and InfraBackup push failures
- report/builder.go: INFO for BuildReport start
- monitor/healthcheck.go: WARN for CPU/memory/disk/temperature threshold breaches; INFO for health check result status
- system/mounts_linux.go: WARN for unsafe backup destinations and storage path probe failures
- settings/settings.go: INFO for settings load/save, storage path add/remove, disconnect/decommission, pending events; ERROR for save failures
- storage/attach_linux.go: INFO for disk attach start/success; ERROR for attach failures
- storage/scan_linux.go: INFO for disk scan start/completion with count
- storage/format_linux.go: INFO for format start/success; ERROR for format failures
- storage/migrate.go: INFO for migration start/completion; ERROR for migration failures
- integrations/manager.go: ERROR for integration apply failures; WARN for context build and env load failures
- integrations/lifecycle.go: Added
[integrations]module tag to all logs; upgraded re-apply failure from WARN to ERROR - integrations/onlyoffice_filebrowser.go: ERROR for all Apply/Revoke error paths
- integrations/onlyoffice_nextcloud.go: ERROR for all Apply/Revoke error paths
- selfupdate/updater.go: INFO for up-to-date and update-available results; INFO/ERROR for compose file updates
- selfupdate/state.go: INFO for state cleared
- assets/syncer.go: ERROR for manifest save failures (previously silent); changed sync failure log from WARN to ERROR
- appexport/restore.go: INFO for import start
- web/auth.go: INFO for logout/session invalidation/session cleanup; WARN for unauthorized API requests
- web/server.go: WARN for 404 Not Found on unknown routes
- web/handlers.go: INFO for default storage path and schedulable state changes
- web/handler_restore.go: INFO for restore-all initiation
- web/handler_export.go: ERROR for export/import start failures
- web/storage_handlers.go: INFO for disk disconnect/reconnect/restart-apps completion
- api/router.go: ERROR for stack action failures, backup snapshot listing failures, metrics query failures
Changed
- stacks/healthprobe.go: Summary log now always prints — WARN when unhealthy, INFO when all ok (was debug-only for all-ok)
- backup/restore.go: Changed RestoreApp start log from
[WARN]to[INFO] [backup] - backup/restore_app_linux.go: Changed restoreUserData/restoreDBDumps failure logs from
[WARN]to[ERROR]where data loss could occur
v0.32.1 — Comprehensive debug logging across all modules (2026-02-26)
Added
- stacks/delete.go: Debug logging for DeleteStack/RemoveStack with stack state, HDD mounts, compose output, path removal; GetStackHDDData/GetStackBackupData path scanning
- stacks/manager.go: Debug logging for ScanStacks per-stack discovery, refreshStatusLocked container resolution, Start/Stop/Restart pre-operation state, MigrateEncryption progress, getCatalogTemplateSlugs count
- stacks/deploy.go: Debug logging for UpdateStackConfig/UpdateOptionalConfig changed keys, InjectMissingFields per-stack checks, SaveAppConfig encryption counts, LoadAppConfig results
- stacks/healthprobe.go: Debug logging for per-target interval calculations and target collection summary
- backup/restic.go:
debugfield +SetDebug()method; debug logs for Snapshot/Prune/Check/ListSnapshots/LatestSnapshot/Stats/RestoreAppData with timing, sizes, and command details - backup/restore_scan.go: Debug logging for ScanDrivesForBackups drive/app scanning with per-drive availability and backup component summary
- backup/restore_app_linux.go: Debug logging for RestoreAppFromBackup step timing, restoreUserData per-dir rsync, restoreDBDumps per-file copying
- backup/restore_drives_linux.go: Debug logging for MountDrivesFromLayout device discovery, mount strategy selection, fstab checks
- cloudflare/geosync.go:
debugfield +SetDebug()method; debug logs for Sync zone/ruleset resolution, existing/desired rule diffing, rule create/update/delete operations - cloudflare/waf.go: Debug logging for GetCustomRulesetID/GetRules/GetFelhomRules counts, CreateRule/UpdateRule expression snippets
- cloudflare/zone.go: Debug logging for GetZoneID progressive domain lookup attempts
- integrations/manager.go:
debugfield +SetDebug()method; debug logs for Toggle validation/timing, ListForProvider counts, buildApplyContext details, ReapplyConfigForTarget per-integration progress - integrations/lifecycle.go: Debug logging for OnStackStop/OnStackStart/OnStackRemove with integration counts, state checks, revoke/re-apply operations
- integrations/onlyoffice_filebrowser.go: Debug logging for Apply/Revoke config path, JWT secret presence, office URL
- system/: Package-level
DebugLoggervariable; debug logs for GetInfo timing/summary, readMemInfo/readDiskUsage/readLoadAvg/readTemperature raw values, CPU collector samples, GetDiskUsage/GetFSInfo/CheckBackupDestination/ProbeStoragePath/IsUSBDevice details - monitor/pinger.go:
debugfield +SetDebug()method; debug logs for Ping/Fail/Start with UUIDs, send URL/attempts/response status - settings/settings.go:
debugfield (json:"-") +SetDebug()method; debug logs for Load counts, save data size, AddStoragePath/RemoveStoragePath, SetDisconnected/SetDecommissioned, AddPendingEvent/DrainPendingEvents, SetGeoRestriction, SetIntegrationState, AutoDiscoverStoragePaths - scheduler:
debugfield +SetDebug()method; debug logs for job registration, execution timing, daily job wait calculations - storage/: Consistent
[DEBUG] [storage]prefix; scan timing; drive migration debug logging - metrics/logscanner: Debug logging for per-container scan timing, error/warning counts
- api/router:
debugfield +SetDebug()method; logs incoming API requests and handler entry points - selfupdate: Expanded debug coverage with
dbg()helper for TriggerUpdate preconditions, performUpdate step transitions, docker pull timing - assets/syncer: Expanded debug coverage with
dbg()helper for per-file hash comparison, download timing, manifest fetch details - web/auth.go: Debug logging for RequireAuth middleware decisions, login attempts (IP, success/fail), session creation/cleanup
- web/handlers.go: Debug logging for deploy/restore/settings/storage handler entry points with key parameters
- web/handler_restore.go: Debug logging for restore page, status polls, restore-all execution per-app timing
- web/storage_handlers.go: Debug logging for all storage API operations (scan, init, migrate, disconnect, reconnect, attach, cleanup)
- web/server.go: Debug logging for NewServer initialization, template loading, ServeHTTP request routing
- main.go: Wire
SetDebug()for settings, pinger, geoSync, integrationMgr, scheduler, apiRouter
v0.32.0 — App export/import (.fab bundles) (2026-02-26)
Added
- App export: Per-app export to
.fabbundles containing config, database dump, and all user data (HDD bind mounts or Docker named volumes) - App import: Restore apps from
.fabbundles — works for both existing and new apps (standalone import page) - Password protection: Optional AES-256-CTR + HMAC-SHA256 encryption with scrypt key derivation for exported bundles
- Pre-export estimation: Size estimation with free space check before starting export
- Export UI: New export page accessible from app info header with drive picker, password field, stop-app checkbox, and real-time progress tracking
- Import UI: Standalone import page (
/import) scans all registered storage drives for.fabfiles, shows manifest details, and handles encrypted bundles with password prompt - FileBrowser link: After export, link to open the exports directory in FileBrowser
- Bundle format:
{appname}_{timestamp}.fab— tar.gz internally withmanifest.json,config/,database/,data/directories - New package:
internal/appexport/— export/import engine with provider adapter pattern (same as backup.StackDataProvider) - API endpoints:
/api/export/estimate,/api/export/start,/api/export/status,/api/export/bundles,/api/export/manifest,/api/export/import,/api/export/import/status
Changed
- backup/appdata.go: Exported
ParseComposeNamedVolumes(was lowercase) for reuse by appexport package
v0.31.7 — Infra backup retention + version picker (2026-02-26)
Changed
- docker-setup.sh hub mode:
--hub-customernow generates a minimalcontroller.yaml(nocustomer.id) instead of installing the full hub config — this triggers the setup wizard on first run, giving the user a choice to restore from an infra backup or start fresh - docker-setup.sh: Hub credentials are passed to the controller via
FELHOM_SETUP_CUSTOMER_IDandFELHOM_SETUP_PASSWORDenvironment variables so the setup wizard auto-fills them - Local infra backup:
WriteLocalInfraBackup()now rotates previous backup intohistory/subdirectory before writing new files (keeps last 5 versions per drive) - Setup wizard scan results: Table now shows app names/count, disk count, and "korábbi" badge for historical versions
Added
- Setup wizard hub pre-seeding: When deployed with
--hub-customer, the wizard auto-detects pre-seeded credentials and auto-processes Hub API calls (no manual form entry needed) - Hub mode welcome page: Shows three options instead of two — "Visszaállítás a Hub-ról" (auto-connects to Hub), "Helyi mentés keresése" (local drive scan), "Friss telepítés" (fresh config download)
- Auto-process fallback: If Hub auto-connect fails, the wizard clears the pre-seeded password and falls back to the manual form with the error displayed
- Hub backup version picker: When multiple backup versions exist on the Hub, the setup wizard shows a version picker page (date, controller version, app names, disk count) — user selects which version to restore
- Local backup history restore: Setup wizard can restore from historical versions found in
history/subdirectory on local drives ReadLocalInfraHistory(): Scanshistory/directory for all retained backup versions with rich metadata (stack names, disk count, integrity status)ReadLocalInfraBackupFromHistory(): Reads a specific historical version by timestamp prefixPullRecoveryVersion(): Fetches a specific backup version from the Hub recovery endpoint via?version=IDparameter
Fixed
- Bind mount write:
atomicWriteFile()now falls back to direct write when rename fails (fixes "device or resource busy" on Docker bind-mountedcontroller.yaml) - Drive mounting after restore: Restore flow now calls
MountDrivesFromLayout()to mount drives by UUID and add fstab entries — previously drives referenced in the infra backup were not mounted, causing "Adattároló nem elérhető" warnings - Post-restore redirect: UI now polls until the controller is actually up instead of using a fixed 5-second timeout (which was too short for container restart)
- FileBrowser DB reset scoped to restore:
SyncFileBrowserMounts()no longer resets the FileBrowser database volume on source changes — only the post-restore startup path (SyncFileBrowserMountsReset) does, preserving user accounts, permissions, and share links during normal storage operations
v0.31.6 — UI: Brand-consistent button & card styling (2026-02-25)
Changed
- Buttons: Replaced traffic light colors (green/yellow/red) with brand-consistent palette — primary actions use blue gradient, secondary actions use ghost/outline, destructive actions show red tint on hover only (modal confirmations keep filled red)
- Card borders: Running apps now show a subtle blue glow instead of green top border; all other states have neutral borders
- Status badges: Running state badge uses brand blue instead of green
- Button alignment: Cards use flexbox column layout with
margin-top: autoon actions — buttons always align to the bottom regardless of card content height - Dashboard cards: Left border indicator changed from green to blue for running apps
v0.31.5 — Fix Nextcloud-OnlyOffice callback URL + trusted_domains (2026-02-25)
Fixed
- StorageUrl trailing slash:
http://nextcloud→http://nextcloud/— without trailing slash, Nextcloud's OO connector concatenates the hostname with/apps/...path, producinghttp://nextcloudapps/...(unresolvable hostname) - trusted_domains: OO Document Server callbacks arrive with
Host: nextcloudheader; addednextcloudto Nextcloud's trusted_domains so these internal callbacks are not rejected
v0.31.4 — Fix FB container not restarting + OO mixed content (2026-02-25)
Fixed
SyncFileBrowserMountsnow uses--force-recreateso the container always restarts and picks up config.yaml changes (bind mounts are invisible todocker compose up)- OnlyOffice compose template: added Traefik
X-Forwarded-Proto=httpsmiddleware to fix mixed content errors when OO generateshttp://URLs behind HTTPS proxy - Nextcloud integration: added
StorageUrl=http://nextcloudfor internal file download callbacks from OO Document Server
v0.31.3 — Fix FileBrowser integration config persistence (2026-02-25)
Fixed
- FileBrowser integration config (OnlyOffice URL, JWT secret) was lost after
SyncFileBrowserMountsregeneratedconfig.yaml— the asyncOnStackStartre-apply hook failed due to timing issues - New
ReapplyConfigForTarget()method applies integration config synchronously between config generation and container restart, ensuring it survives regen cycles
v0.31.2 — Show FileBrowser URL on app card (2026-02-25)
Fixed
- Protected stacks (e.g. FileBrowser) now show their subdomain URL link on the app card — condition relaxed from
DeployedtoDeployed OR Protected
v0.31.1 — Move integration & geo settings to deploy page (2026-02-25)
Changed
- Integration toggles and geo-restriction settings moved from app info page to deploy/settings page (user feedback: settings belong on the "Beállítások" page)
- Data wiring moved from
appDetailHandler()todeployHandler()in handlers.go
v0.31.0 — App-to-App Integration Framework (2026-02-25)
Added
- Generic integration framework (
internal/integrations/) — Extensible system for connecting deployed apps to each other via toggle switches on the provider's app info page - OnlyOffice → FileBrowser integration — Toggle enables document editing in FileBrowser by patching
config.yamlwith OnlyOffice URL and JWT secret - OnlyOffice → Nextcloud integration — Toggle installs and configures the OnlyOffice connector app via
occCLI commands - Integration lifecycle hooks — Integrations auto-suspend when provider or target stops, auto-re-enable when both are running again, permanently removed on app deletion
- Integration API endpoints —
GET /api/integrations/{provider}(list),POST /api/integrations/{provider}/{target}(toggle) - Integration UI — "Integrációk" section on app info page with toggle switches, status badges, and target availability indicators
IntegrationDefin.felhom.ymlmetadata — Apps can declare integrations with target app slug, label, and descriptionIntegrationStateinsettings.json— Persistent integration state with enabled/status/error tracking- SyncFileBrowserMounts re-apply — After config regeneration (which overwrites config.yaml), active integrations are automatically re-applied
v0.30.7 — Monitoring: Fix Memory Legend Overflow (2026-02-25)
Fixed
- Memory legend overflow — Legend items in the memory distribution chart now wrap properly instead of overflowing off-screen (
flex-wrap,white-space: nowrap)
Improved
- Sort by consumption — Memory distribution bar and legend are now sorted by memory usage (descending), largest consumers first
v0.30.6 — Telemetry: Better Log Deduplication (2026-02-25)
Fixed
- ANSI escape code stripping — Log scanner now strips ANSI color codes (e.g.
\x1b[35m) before classifying and fingerprinting lines, preventing color codes from polluting error messages and breaking deduplication - Timezone offset in timestamps — ISO timestamp regex now handles
+01:00/-0500timezone offsets and optional trailing colons (fixes Vikunja-style log entries) - Mid-line timestamps — Removed
^anchor from both ISO and syslog timestamp regexes, so timestamps embedded after log-level keywords (e.g.ERROR 2026-02-24T21:27:05) are now stripped correctly
Improved
cleanLine()helper — Consolidated ANSI + timestamp stripping into a single reusable function used by both message display and fingerprint deduplication
v0.30.5 — Health Probe: Fast Initial Checking (2026-02-25)
Improved
- Clear stale health probes on start/restart —
StartStackandRestartStacknow clear the previousHealthProberesult, preventing stale "unhealthy" state from being re-applied byRefreshStatus - Fast 10s probing until healthy — Stacks with no probe result (just started) or failing probes use 10-second intervals instead of waiting the full 5-minute default; reverts to normal interval once healthy
- Scheduler frequency 1m → 10s — Health probe scheduler runs every 10 seconds (interval logic inside
RunHealthProbesskips stacks that don't need probing, so no extra overhead for healthy stacks)
v0.30.4 — Deep Bug Hunt II: Concurrency, Security & Optimization (2026-02-25)
Fixed (Critical)
- Watchdog mutex panic — Wrapped
handleDisconnectcall in anonymous func with deferred re-lock to guarantee mutex re-acquisition even on panic (C1) - SetGeoAppOverride nil crash — Added nil guard; passing nil override now correctly deletes the entry instead of panicking (C2)
- SSD-only app DB restore —
restoreDBDumpsnow falls back toapp.DrivePathwhenHDDPathis empty (C3)
Fixed (High)
- Double deploy race — Added atomic check-and-set of
Deployingflag withclearDeploying()helper on all error paths (H1) - Delete/Remove during deploy — Both
DeleteStackandRemoveStacknow reject operations while stack is deploying (H2) - ScanStacks overwrite — Skips updating
Deployed/AppConfigfor stacks with active deploy in progress (H3) - FileBrowser mount race — Added
fileBrowserMumutex to prevent concurrentSyncFileBrowserMountscalls (H5) - PushEvent history gap — Added
recordHistorycalls on both success and failure paths in PushEvent goroutine (H6) - PushOnce silent failure — Now returns error for non-2xx HTTP responses instead of nil (H7)
- DB dump file corruption — Added
tmpFile.Sync()andtmpFile.Close()before rename inDumpOne(H8) - Restic retry timeout — Creates fresh 30-minute context for retry after unlock instead of reusing near-expired original (H9)
- Encrypt failure silent — Added warning log when encryption fails in
SaveAppConfig(H10) - Cross-backup path traversal — Validates destination path against registered storage paths in both web and API handlers (H11)
- deepCopyStack incomplete — Now deep-copies
Meta.OptionalConfig,Meta.HealthCheck, andDeployField.Options(H12)
Security
- Constant-time API key — Replaced
==withsubtle.ConstantTimeComparefor API key comparison, preventing timing attacks (M1) - Login rate limiting — Added per-IP rate limiter (5 attempts/minute) to login handler (M8)
- Git credential masking — Applied
maskRepoURL()inrunGitInDirlog output to prevent credential leakage (M23) - Path prefix traversal — Fixed
storageAttachBrowseHandlerprefix check to require trailing/, preventing sibling directory matches (M24)
Concurrency & Logic
- MigrateEncryption race — Moved
encKey == nilcheck inside the mutex lock (M5) - SubdomainInUse I/O under lock — Collect stack dirs under RLock, release, then perform disk I/O outside (M4)
- Scheduler late jobs — Jobs registered after
Start()now immediately get their goroutine launched (M10) - SQLite WAL verification — WAL pragma now verified via
QueryRow+Scaninstead of silentExec(M13) - Metrics shutdown —
sampleContainersnow uses parent context instead ofcontext.Background()for clean shutdown (M14) - Telemetry scan logging — Row scan errors now logged instead of silently swallowed (M15)
- Asset sync lock — Refactored to hold mutex only for status updates, not during entire HTTP download (M22)
Optimization
- DB dump copy — Replaced
os.ReadFile/os.WriteFilewith streamingio.CopyviacopyFilehelper for large dumps (M16) - Restic stats dedup — Per-drive stats now computed once and aggregated, eliminating duplicate restic subprocess calls (M17)
- Infra config atomic —
syncInfraConfigcontroller.yaml copy now uses atomic write viacopyFile(M20)
v0.30.3 — Comprehensive Bug Hunt Fixes (2026-02-25)
Fixed (Critical — P0)
- Encrypted env vars —
UpdateStackConfignow uses decrypted values when building compose env, preventingENC:...literals in containers (C01) - Silent decrypt failures —
DecryptMapnow logs warnings on decrypt failure instead of silently returning empty values (C02) - Deploy race condition —
Deployed = falseflag now set inside the mutex lock inrunComposeDeploy(C03) - Shared state mutation —
GetStack/GetStacksnow return deep copies preventing callers from mutating cached state (C04) - Watchdog races — Added per-state mutex to
pathProbeStatefor thread-safe probe state access (C05) - Metrics double-start —
MetricsCollector.Start()guarded withsync.Once(C06) - Raw mount race —
diskJobMunow held across entire cleanup+mount+set operation (C07) - Encryption key race — Added mutex to
SetEncryptionKey(C08)
Fixed (High — P1)
- Restic lock detection —
Snapshot()now extracts stderr from*exec.ExitErrorand checksunlockCmd.Run()error (H01) - Disconnected drives in backup —
activeDrives()now skips disconnected/decommissioned drives (H02) - Template rendering — Buffered via
bytes.Bufferto prevent partial HTML on error (H07) - Sync stop panic —
Stop()usessync.Oncefor safe channel close (H08) - Sync race —
syncing = trueset before releasing lock inTriggerSync(H09) - Cloudflare context — Threaded
context.Contextthrough all Cloudflare API calls for cancellation support (H10) - Cross-drive collision — Replaced flawed leaf-name dedup with proper
seenmap (H15) - CSRF bypass — Bearer token now validated against Hub API key before skipping CSRF (H16)
- Nil pointer — Added nil check for
crossDriveRunnerin handlers (H17) - Selftest panic — Replaced
out[:len(out)-1]withstrings.TrimSpace(H18) - Stderr goroutine — Added
sync.WaitGroupinMigrateDrive(H19) - UUID slice — Guarded
uuid[:8]with length check (H20) - Fstab matching — Parse fields exactly instead of loose
strings.Contains(H21) - Atomic save —
SaveAppConfigwrites to.tmpthen renames (H04) - Deploy failure —
SaveAppConfigon failure now includesencKey(H05) - Encryption migration — Uses write lock instead of read lock (H03)
- Deep copy —
GetFullStatusdeep-copieslastDBDump/lastBackup(H11) - IPv6 — TCP health probe uses
net.JoinHostPortfor IPv6 compatibility - Backup path validation —
RemoveStackvalidates paths under expected directory (M12) - Updater race —
SetBackupRunningCheckprotected by mutex (M18)
Fixed (Medium — P2)
- Config env overrides —
LoadFromBytesnow callsapplyEnvOverrides(M05) - Selfupdate state — Compose-up failure now sets
state.Status = "failed"(M16) - Memory check —
usableMBclamped to min 0 (M22) - Cross-backup trigger — Removed invalid "manual" schedule from
triggerAllCrossBackups(M23) - mmcblk support — Partition path and
stripPartitionnow handle mmcblk devices (M21, L25) - Scheduler —
Start()guarded against double-start,Stop()acquires mutex (M14, L24) - Pending events — Events restored on save failure in
DrainPendingEvents(M03) - Duplicate storage —
AddStoragePathrejects already-registered paths (M04) - Setup scan —
CleanupTempMountscalled after drive scan (H13) - Setup state —
SetStepnow logs save errors (M25)
Fixed (Low — P3)
- UTF-8 truncation —
TruncateStrnow operates on runes and handles negative maxLen (L05/L06) - AllDone — Returns false for empty restore plans (L14)
- PushOnce — Returns actual errors instead of swallowing them (L39)
- CSRF token — Panics on
crypto/rand.Readfailure instead of using static fallback (L40) - Logout — Requires POST method (L32)
- Server.Close — Uses
sync.Onceto prevent double-close panic (L49) - Log cap —
linesquery parameter capped at 10000 (L31) - Hash function — Replaced custom
simpleHashwithcrc32.ChecksumIEEE(L48) - hasPrefix — Replaced custom implementation with
strings.HasPrefix(L13) - DefaultEnabledEvents — Copied in
GetNotificationPrefsearly return (L09) - Variable shadowing — Renamed
copytocpinSetNotificationPrefs(L07)
Removed
- Dead
imageNamefunction in selfupdate (L02) - Dead
detectHostIPViaRoutefunction in setup (L03) - Custom
hasPrefixfunction in restore_scan (L13)
v0.30.2 — Report geo-restriction + logo/favicon update (2026-02-25)
Added
- Geo-restriction in reports (
internal/report/) — NewGeoRestrictionReportstruct andgeo_restrictionfield in the Report JSON. Hub can now display current geo-blocking status (enabled, allowed countries, per-app overrides, sync state) on customer detail pages. - Favicon route (
/static/favicon.svg) — Separate favicon SVG served from synced assets or embedded fallback. Uses the cloud icon fromlogo_favicon_2.svg. - Hub Bearer auth for geo API —
/api/geo/routes now acceptselfUpdateAuthMiddleware(session auth OR Hub API key), allowing the Hub to send geo-disable commands to controllers.
Changed
- Logo SVG updated (
internal/web/templates.go) — Replaced embedded logo with the latestlogo.svgfrom the website (white text variant). - Favicon link — Layout and catch-all templates now reference
/static/favicon.svginstead of the full logo.
v0.30.1 — Geo-Restriction fix (2026-02-25)
Fixed
- WAF rule creation — Removed custom block response body from WAF rules (requires paid Cloudflare plan). Block action now uses Cloudflare's default 403 page.
v0.30.0 — Geo-Restriction via Cloudflare WAF (2026-02-25)
Added
- Geo-restriction feature (
internal/cloudflare/) — New package for managing Cloudflare WAF Custom Rules. Allows restricting access to apps by country using thehttp_request_firewall_customphase. Rules are identified by[felhom-geo]description prefix — other WAF rules are untouched. - Cloudflare API client (
internal/cloudflare/client.go) — HTTP client with Bearer token auth for the Cloudflare v4 API. Supports zone lookup, ruleset management, and rule CRUD operations. - Country data (
internal/cloudflare/countries.go) — Embedded map of ~250 ISO 3166-1 alpha-2 country codes with Hungarian names. Includes search helpers for the UI. - Geo sync manager (
internal/cloudflare/geosync.go) — Orchestrator that diffs desired vs existing Cloudflare rules and applies changes. Runs on settings change, after app deploy/remove, and every 6 hours for verification. - Settings page UI (
templates/settings.html) — New "Földrajzi korlátozás" section with searchable country selector (autocomplete dropdown → tag chips), enable/disable toggle, per-app override summary, and sync status display. Hungary removal triggers a confirmation warning. - Per-app override (
templates/app_info.html) — Each app's detail page now has a "Földrajzi korlátozás" section (when the feature is globally enabled) to set app-specific allowed countries. - Geo API endpoints (
internal/api/geo.go) —GET /api/geo/status,POST /api/geo/settings,POST /api/geo/sync,GET /api/geo/countries,POST/DELETE /api/stacks/{name}/geo/override. - Settings model (
internal/settings/settings.go) — NewGeoRestrictionstruct withAllowedCountries,AppOverrides, and sync state (zone ID, ruleset ID, last sync). Thread-safe getter/setter methods following existing RWMutex pattern.
Changed
- Router (
internal/api/router.go) — AddedOnGeoRelevantChangecallback triggered after app deploy/remove to re-sync geo rules when hostnames change. - Main wiring (
cmd/controller/main.go) — Cloudflare client, geo sync manager, and scheduler job initialized whencf_api_tokenis configured. NewgeoStackAdapterprovides deployed app hostnames.
Hub Changes
- Config form (
hub/internal/web/templates/config_form.html) — Updated CF API token help text to indicate Zone WAF:Edit permission is needed for geo-restriction.
Notes
- The existing
cf_api_tokenneeds Zone WAF:Edit permission added (in addition to existing Zone DNS:Edit for ACME). No new token field is needed. - Local network access is inherently unaffected — local traffic bypasses Cloudflare entirely.
- Cloudflare Free plan supports up to 5 custom rules, which is sufficient for a global rule + a few per-app overrides.
v0.29.3 — Controller-side Health Probes (2026-02-25)
Added
- HTTP/TCP health probes (
internal/stacks/healthprobe.go) — The controller now probes deployed apps directly over the Docker network to verify services are actually responding, not just that containers are running. Runs every minute, configurable per-app interval (default 5 min). - Three probe types:
http(any response = alive),api(validates status code and response body),tcp(port reachability). Multiple checks per app supported. .felhom.ymlhealthcheck config (internal/stacks/metadata.go) — Newhealthcheck:section withinterval,checks[](type, port, path, method, expect). Parsed from app catalog metadata.- State override (
internal/stacks/manager.go) — If a running container's health probe fails, the stack state is overridden to "unhealthy". Clears automatically when probe passes again.
Fixed
- Vikunja healthcheck — Removed Docker-level healthcheck (distroless image has no wget/curl). Controller-side API probe to
:3456/api/v1/inforeplaces it.
v0.29.2 — Dynamic Logo & Favicon (2026-02-25)
Changed
- Logo served from synced assets (
internal/web/server.go) —serveLogoHandlernow checks the Hub-synced assets directory forfelhom-logo.svgfirst, falling back to the embedded SVG constant if not found. This allows logo updates via Hub without a controller rebuild.
Added
- SVG favicon (
templates/layout.html,templates/catchall.html) — Added<link rel="icon" type="image/svg+xml">pointing to/static/felhom-logo.svgso browsers display the Felhom logo as a tab icon.
v0.29.1 — Fix Git Lock File Stale After Interrupted Sync (2026-02-24)
Fixed
- Stale git lock file recovery — Catalog sync now removes stale
.git/index.lock,.git/shallow.lock, and.git/HEAD.lockfiles before runninggit fetch/git reset. Previously, if the container was killed mid-sync, the leftover lock file would block all subsequent syncs until manual intervention.
v0.29.0 — Encrypt Sensitive Values in app.yaml (2026-02-23)
Added
- AES-256-GCM encryption for app.yaml secrets — Sensitive deploy field values (
type: passwordandtype: secret) are now encrypted at rest in each stack'sapp.yamlusing a per-node 32-byte key. Encrypted values are stored asENC:base64(nonce+ciphertext). Newinternal/cryptopackage providesEncrypt,Decrypt,LoadOrCreateKey,DecryptMap, andIsEncryptedhelpers. - Encryption key in infra backup — The encryption key (
encryption.key) is included in the Hub infra backup bundle (encryption_key_b64field) and local drive infra backups for disaster recovery. - Encryption key restore — The setup wizard's infra restore flow restores
encryption.keyfrom the backup bundle so encrypted app.yaml values remain readable after disaster recovery. - Startup migration — On first start after upgrade, existing plaintext sensitive values in deployed stacks'
app.yamlfiles are automatically encrypted in-place.
Changed
SaveAppConfigsignature — Now acceptsencKey []byteandsensitiveVars []stringparameters for encryption. All callers (deploy, update, optional config, inject missing fields, HDD path update, storage handlers) updated.LoadAppConfigDecrypted— New helper that loads app.yaml and transparently decrypts allENC:values for docker-compose env injection and web UI display.SensitiveEnvVars— New exported helper that identifies sensitive env vars from.felhom.ymlmetadata (type: passwordortype: secretdeploy fields).- Manager struct — Added
encKeyfield andSetEncryptionKey()/MigrateEncryption()methods. - Web Server struct — Added
encKeyfield andSetEncryptionKey()method; deploy handler decrypts values before template rendering.
v0.28.8 — Password UX Polish (2026-02-23)
Fixed
- Password fields empty after deployment (
templates/deploy.html) — Password-type deploy fields now read their stored value fromDeployedFieldValues(app.yaml env) when viewing settings for an already-deployed app, instead of always using the field's.Default(which was empty). - Post-deploy credentials masked — Passwords on the post-deploy success card are now shown as
••••••••••••with "Megjelenítés" (reveal) and "Másolás" (copy to clipboard) buttons, instead of displaying plaintext.
Changed
- Settings page: initial password hint — Deployed password fields show a note: "Telepítéskor beállított kezdeti jelszó — ha az alkalmazásban megváltoztattad, az itt nem frissül." Generate button is hidden for already-deployed apps.
- Post-deploy credential detection — Added EMAIL to the username-detection heuristic (catches Kimai's
ADMIN_EMAIL).
v0.28.7 — Password Field UX (2026-02-23)
Changed
- Password deploy fields: masked input with reveal & confirmation (
templates/deploy.html) —type: passwordfields now render as masked inputs (hidden by default) with an eye toggle button to reveal/hide. Added a "Jelszó megerősítése" confirmation field below each password input. The "Generálás" button fills both fields simultaneously. Form validation checks that both fields match before allowing deploy. Confirmation fields are only shown for new deployments. - App catalog: admin passwords use
type: password(separate repo:app-catalog-felhom.eu) — Changed 4 apps (Nextcloud, Grafana, Kimai, Code-server) fromtype: secrettotype: passwordso users can see/edit/generate admin passwords during deployment (matching the existing Paperless-ngx pattern).
v0.28.6 — Filebrowser Link, Appdata Paths & Log Timestamps (2026-02-23)
Added
- Post-deploy credential display (
templates/deploy.html) — The success page now shows actual username/password values from the deploy form instead of a generic message. Reads from deploy field metadata, filtering out internal DB passwords and secret keys. Falls back todefaultCredsfor apps without typed deploy fields.
Fixed
- Filebrowser "open" link on stacks page (
web/handlers.go) — Protected stacks like filebrowser have no.felhom.ymlorapp.yaml, so the subdomain lookup found nothing. AddedprotectedStackSubdomainsfallback map for programmatically managed protected stacks (filebrowser → "files"). Now showsfiles.<domain> ↗link on both the stacks page and dashboard. - App catalog: appdata volume paths (separate repo:
app-catalog-felhom.eu) — 4 compose templates (nextcloud, immich, paperless-ngx, romm) used${HDD_PATH}/appdata/instead of${HDD_PATH}/felhom-data/appdata/as designed in the v0.26.0+ storage structure. Fixed all templates. Existing deployments need redeployment or manual volume path update. - Debug log viewer timestamps (
web/logbuffer.go,templates/debug.html) — Naplóviewer showed relative times like "-3586mp" (negative due to timezone bug:time.Parseassumed UTC butlog.LstdFlagsoutputs local time). Now usestime.ParseInLocationwithtime.Local, and displays absoluteHH:MM:SStimestamps.
v0.28.5 — Post-Deploy Info Card (2026-02-23)
Added
- Post-deploy success page (
web/templates/deploy.html) — After a successful deploy, instead of auto-redirecting to the apps list, shows a rich info card with: direct app link ("Alkalmazás megnyitása ↗"), first steps from catalog metadata (with DOMAIN placeholders replaced), default credentials info, documentation link, and a link to the settings page where passwords can be revealed. Also shown for unhealthy/timeout states since apps may still be usable during initialization.
v0.28.4 — Telemetry: Skip Stopped Apps (2026-02-23)
Fixed
- Stopped apps no longer send zero-value telemetry to hub (
report/telemetry.go) — Previously, deployed-but-stopped apps were included in the telemetry report with all-zero memory/CPU values, which dragged down hub-side averages. NowbuildAppTelemetrychecksisStackRunning()and only includes apps in running, starting, unhealthy, or restarting states.
v0.28.3 — Catch-All Page, Deploy Controls, Dashboard Open (2026-02-23)
Added
- Catch-all page for stopped/undeployed apps — When a user visits a stopped app's subdomain (e.g.,
travel.demo-felhom.eu), they now see a branded felhom page with the app name and status ("Az alkalmazás jelenleg le van állítva") instead of Traefik's raw 404. Implemented via a low-priority (1) Traefik catch-all router on the controller container +CatchAllMiddlewareinserver.gothat intercepts non-controller hosts and renders standalonecatchall.htmlwithout auth. - Start/Stop/Restart buttons on deploy settings page — Deployed apps now show Indítás/Leállítás/Újraindítás buttons in the page header, plus a "Megnyitás ↗" link to the app's subdomain (visible when running). Previously the deploy page had no state controls.
- "Megnyitás ↗" button on Vezérlőpult — Running apps on the dashboard now show an open button that launches the app in a new tab. Uses the
Subdomainsmap built fromapp.yamlSUBDOMAIN env with metadata fallback. findStackBySubdomain()helper inserver.go— looks up stacks by subdomain, checking deployedapp.yamlenv first, then.felhom.ymlmetadata.
Changed
- Subdomain links on Alkalmazások page — Links now only shown for deployed apps (previously shown for all apps including non-deployed ones where the subdomain isn't final yet).
docker-compose.yml— Added 6 catch-all Traefik router labels (traefik.http.routers.catchall.*) withpriority=1andcertresolver=letsencrypt.
v0.28.2 — Async Deploy & AdventureLog Fix (2026-02-23)
Changed
- Async deploy —
DeployStack()now runsdocker compose up -din a background goroutine instead of blocking the HTTP response. The deploy API returns immediately after validation + config save, so the UI switches to the progress panel instantly (previously waited 30-60s for image pulls). NewStateDeployingcontainer state shown while compose-up is in progress. On failure, the goroutine reverts both disk and in-memory state and stores the error inDeployErrorfor the polling UI to display. - Deploy progress UI — Polling now handles the
deployingstate ("Képek letöltése, konténerek indítása...") anddeploy_error(shows error message with links to logs). Previous behavior only showed progress after compose-up completed.
Fixed
- RestartStack uses
up -dwith env vars —RestartStack()previously used baredocker compose restartwhich only sends SIGTERM+start without re-reading the compose file or injecting env vars fromapp.yaml. Now usesdocker compose up -dwith full env, matchingStartStack()behavior. This ensures template changes (images, healthchecks) and env var updates are picked up on restart. - AdventureLog backend healthcheck — Replaced
wget(not available in v0.11.0 image) withpython urllib.request. Also uses127.0.0.1instead oflocalhostto avoid IPv6 resolution issues. - AdventureLog frontend healthcheck — Changed
localhost→127.0.0.1to fix IPv6 resolution causing connection refused (Node.js only listens on IPv4). - AdventureLog SECRET_KEY — Added
SECRET_KEY=${SECRET_KEY}env var alongsideDJANGO_SECRET_KEYfor v0.11.0 compatibility (Django settings now readsSECRET_KEYdirectly).
v0.28.1 — Telemetry Debug Section (2026-02-23)
Added
- Telemetria teszt section on Debug page — New collapsible section between "Hub & Kapcsolatok" and "Önfrissítés teszt". Click "Telemetria futtatása" to run the full telemetry collection pipeline on-demand without waiting for the 15-minute report cycle.
GET /api/debug/telemetry— New debug endpoint inhandler_debug.go. InvokesGetTelemetryPreviewcallback, returns per-app data: container list, memory (current/avg/peak), CPU avg, catalog limit, log error/warning counts, top issues, and overall latency. Response:{latency_ms, app_count, total_errors, total_warnings, app_telemetry[]}.GetTelemetryPreviewcallback added toDebugCallbacksstruct. Wired inmain.godebug-mode block: callsreport.BuildAppTelemetryForDebug(stackMgr, metricsStore, logger). Available regardless of hub configuration.report.BuildAppTelemetryForDebug()— Exported wrapper ininternal/report/telemetry.goaround the privatebuildAppTelemetrySection(). Allows debug endpoint access without exposing internal package details.- JS rendering —
runTelemetryTest()fetches the endpoint and shows a summary message.renderTelemetryDetail()builds a table with per-app rows (color-coded errors in red, warnings in yellow) and sub-rows for top issues. Includes a collapsible "Nyers JSON" section showing the exact payload that would go to the hub.
v0.28.0 — App Telemetry & Analytics (2026-02-23)
Added
- App telemetry in Hub reports —
Report.AppTelemetry(new field inreport/types.go) carries per-stack memory/CPU metrics and log scan results to the Hub on every report push. Backward-compatible: old Hub versions silently ignore the new field. internal/metrics/telemetry.go— NewMetricsStore.GetContainerTelemetry(since)method aggregates container memory (current/avg/peak) and CPU averages from the existingcontainer_metricsSQLite table over the last 15 minutes.internal/metrics/logscanner.go— NewScanContainerLogs(containerNames, since, logger)function runsdocker logs --since=15m --tail=1000on each non-protected deployed container. Detects errors/warnings by keyword matching, deduplicates via fingerprinting (strips timestamps, replaces 6+ digit numbers with<N>, hex with<HEX>, UUIDs with<UUID>). Returns[]ContainerLogSummarywith counts andRecentIssues(top 10 per container).internal/report/telemetry.go— NewbuildAppTelemetrySection()andbuildAppTelemetry()functions assemble per-stackAppTelemetryrecords by aggregating container-level metrics and log summaries. Only non-protected, deployed stacks are included.
Changed
internal/report/builder.go—BuildReport()now callsbuildAppTelemetrySection()after the stacks section, populatingr.AppTelemetry.internal/report/types.go— AddedAppTelemetry []AppTelemetryfield toReportstruct. Added newAppTelemetrytype with fields: app_name, display_name, containers, memory metrics, catalog estimate/limit, log error/warning counts, and top issues.
v0.27.3 — Real System Memory Everywhere (2026-02-23)
Changed
- Deploy page uses real system memory — Memory bar now shows actual
/proc/meminfousage instead of declaredmem_requestsums. Labels changed from "Jelenlegi foglalás" to "Jelenlegi használat".system.GetMemoryMB()provides real-time total and used memory. - Pre-start memory check uses real memory —
actionStack("start")inrouter.goandDeployStack()indeploy.gonow check real used memory (usedMB + newReqMB > usableMB) instead of declared committed sums.CommittedMemory()kept only for soft overcommit warnings.
Added
system.GetMemoryMB()helper — Lightweight function ininternal/system/info_linux.gothat returns real total and used memory from/proc/meminfowithout the overhead of fullGetInfo()(no disk/CPU/temp). Stub ininfo_other.gofor non-Linux.- Monitoring page memory distribution bar — New stacked bar on
/monitoringshowing per-container memory usage (colored segments), OS/system overhead (gray), and free memory. Built dynamically from container summary data + real-time/api/system/info. Color-coded legend with per-app labels.
v0.27.2 — Comprehensive Fixes and New Labels (2026-02-23)
Fixed
- Deploy error popups now copyable — Replaced all native
alert()calls with a custom modal (showAlert()in layout.html) using a<pre>block withuser-select:text. Error messages can now be selected and copied. Applied across deploy.html and layout.html. - Manual Tier2 backup now reports to Hub — Added
OnCrossDriveCompletecallback toRouter(internal/api/router.go). BothtriggerCrossBackup(single-app) andtriggerAllCrossBackups(run-all) now callpushInfraBackup()+writeLocalInfraBackup()after completion, matching the automatic scheduled path. - Memory bar excludes stopped apps —
CommittedMemory()ininternal/stacks/manager.gonow skips apps withStateStoppedorStateExited. Only running/starting/unhealthy apps count toward committed memory. - Pre-start memory check —
actionStack("start")ininternal/api/router.gonow validates available memory before starting a stopped app. Returns 409 Conflict with a descriptive Hungarian error if insufficient.
Added
hungarian_uimetadata field — NewHungarianUI boolfield inResourceHints(internal/stacks/metadata.go). Shows "Magyar felület" green badge on deploy, stacks, and app info pages whenhungarian_ui: truein.felhom.yml.- USB badge on storage cards — Settings page storage cards now show an orange "USB" badge next to Aktív/Alapértelmezett when the drive is USB-attached (using existing
IsUSBsysfs detection). StackMemoryMB()helper — New method onManagerto get a specific stack's memory request.
App Catalog (app-catalog-felhom.eu)
- AdventureLog — Fixed image tags from
v0.12.0(non-existent) tov0.11.0for both backend and frontend.
v0.27.1 — Fix FileBrowser Mount Sync (2026-02-22)
Fixed
internal/web/handlers.go—SyncFileBrowserMounts()was reading the domain from a.envfile that doesn't exist in the filebrowser stack directory (domain is baked into the compose labels bydocker-setup.sh). It always logged[WARN] Cannot read DOMAIN from FileBrowser .env — skipping mount syncand returned early, so storage paths were never synced to FileBrowser's config.yaml or docker-compose.yml. Fixed by usings.cfg.Customer.Domaindirectly from the controller config.
v0.27.0 — User-Configurable App Subdomains (2026-02-22)
Added
- User-configurable subdomains: Users can now customize the subdomain (e.g.,
wiki,cloud,my-notes) for each app during deployment, instead of using a fixed value. The deploy page shows an editable text input with the default subdomain pre-filled and the base domain as a suffix (e.g.,[wiki] .demo-felhom.eu). - New deploy field type
"subdomain"—internal/stacks/metadata.go,deploy.go: A new field type that is user-editable with a default value, validated, and locked after deployment. Changing the subdomain requires removing the app (clean install) and redeploying. - Subdomain validation —
internal/stacks/deploy.go: Three-layer validation: DNS-safe format (lowercase alphanumeric + hyphens, max 63 chars), reserved name blocklist (felhom,files,traefik,api,www,mail,admin, etc.), and uniqueness check across all deployed stacks. - Backward compatibility —
internal/stacks/deploy.go:InjectMissingFields()auto-fillsSUBDOMAINfrom the.felhom.ymldefault for existing deployed apps when templates are synced, so no manual intervention is needed. internal/web/handlers.go—stacksHandler()builds an effective subdomain lookup map (stored env → metadata fallback).appDetailHandler()passesEffectiveSubdomainto templates.internal/web/templates/deploy.html— New.subdomain-input-groupwidget with inline.domainsuffix. Client-side validation enforces DNS-safe format with real-time lowercasing.internal/web/templates/stacks.html,app_info.html— Subdomain links now read from storedapp.yamlenv (via lookup map) instead of hardcoded metadata, showing the user's actual chosen subdomain.
Changed
internal/stacks/deploy.go—PreviewDeployValues()domain case simplified: shows just the base domain now (subdomain is a separate field).internal/web/handlers.go— Deploy page domain auto-field no longer prependsmeta.Subdomain + ".". PassesDeployedFieldValuesfor rendering stored subdomain on settings page.
App Catalog (app-catalog-felhom.eu)
- All 51 template
docker-compose.ymlfiles updated: hardcoded{subdomain}.${DOMAIN}replaced with${SUBDOMAIN}.${DOMAIN}in Traefik labels, app env vars (APP_URL, trusted domains, webhook URLs, etc.), and comments. - All 51
.felhom.ymlfiles updated: addedSUBDOMAINdeploy field withtype: subdomainanddefault:matching the existingsubdomain:metadata value.
v0.26.2 — Show Full App URL on Deploy Page (2026-02-22)
Fixed
internal/stacks/deploy.go—PreviewDeployValues()now shows the full reachable URL (subdomain.base_domain) for domain-type fields instead of just the base domain. Informational only — stored env var remains the base domain.internal/web/handlers.go— Same fix applied to the already-deployed settings page: domain field displayssubdomain.base_domainmatching what the app card shows.
v0.26.1 — Show Auto-Generated Values on Deploy Page (2026-02-22)
Changed
internal/stacks/deploy.go— AddedPreviewDeployValues()method: pre-generates domain and secret field values when the deploy page is loaded, so the user can see (and note down) exact values before deploying. UpdatedDeployStack()to accept pre-generated secret values from the form instead of always regenerating.internal/web/handlers.go—deployHandlernow callsPreviewDeployValues()for non-deployed apps and populatesAutoFieldValues(previously empty for pre-deploy).internal/web/templates/deploy.html— "Automatikusan generált értékek" section now shows actual values on the pre-deploy page too: domain as a readonly text input, secrets as readonly password inputs with a "Megjelenítés" reveal button. Updated section description to inform the user to note down passwords. Pre-generated secret values are submitted as hidden inputs so the same values shown to the user are saved toapp.yaml.
scripts — Hub Mode + FileBrowser Controller-Managed Volumes (2026-02-22)
scripts/docker-setup.sh — v6.0.0
- Hub mode (
--hub-customer/--hub-password): downloadscontroller.yamlfrom Hub API early in setup, extractsdomain,email,cf_api_token,cf_tunnel_tokenand auto-populates all infrastructure settings. Single one-liner deploys fully configured Traefik + TLS + Cloudflare Tunnel with no additional flags needed. CLI flags always override hub values. yaml_get()helper: strips leading whitespace before key comparison — required because Go'syaml.v3uses 4-space indentation.apply_hub_config(): called beforeprint_bannerinmain()so hub-sourced values are reflected in the plan display.- FileBrowser initial install: removed drive auto-discovery from
install_filebrowser(). FileBrowser is now installed with no drive volumes and a minimalconfig.yamlwith/srvfallback. Drive volumes are managed entirely by the controller (SyncFileBrowserMounts()) after storage is registered via the dashboard. - Bug fix:
((found_mounts++))→found_mounts=$(( found_mounts + 1 ))—set -euo pipefailtraps post-increment when var=0 (exit code 1). Same fix applied tostep_numininstall_filebrowser().
scripts/felhom-wipe.sh
cleanup_scan_dir(): removes/mnt/.felhom-scan/(ephemeral DR scan directory) — called fromfulllevel onwards.cleanup_raw_mounts(): removes raw helper mount infrastructure (/mnt/.felhom-raw/) atnuclearlevel: unmounts bind mounts first, then raw mounts, strips fstab entries, removes empty directories. Physical drive data untouched.- Bug fix:
do_soft_wipe()used[ -f "$f" ] && rm -f "$f" && info "..."— withset -euo pipefail, when a state file doesn't exist[ -f ]returns 1, the whole&&chain returns 1, andset -eexits the script. Nuclear wipe was silently stopping after removing only the first two state files that existed. Fixed withif [ -f "$f" ]; then ...; fi.
scripts/README.md
- Hub mode quick start simplified to one-liner
- Updated installation steps table: step 7 reflects controller-managed FileBrowser volumes
- Added "Raw helper mounts" section explaining two-level mount architecture
- Updated wipe levels table for
full(scan dir) andnuclear(raw mounts + scan dir)
v0.26.0 — Storage Namespace felhom-data/ + Test Node Wipe Script (2026-02-22)
All felhom-managed data on external drives now lives under a felhom-data/ subdirectory, cleanly separating controller-managed data from user files. Plus a multi-level wipe script for repeatable test node cleanup.
Key design principle: HDD_PATH env var stays as the mount point (e.g., /mnt/hdd_1). The felhom-data segment is embedded in path helpers and compose templates — not in HDD_PATH.
Changed
internal/backup/paths.go— AddedFelhomDataDir = "felhom-data"constant. Updated 8 path functions to insertfelhom-databetween the drive root and data subdirectory:PrimaryBackupPath→<drive>/felhom-data/backups/primaryPrimaryResticRepoPath→<drive>/felhom-data/backups/primary/resticAppDBDumpPath→<drive>/felhom-data/backups/primary/<stack>/db-dumpsSecondaryBackupPath→<drive>/felhom-data/backups/secondaryAppSecondaryRsyncPath→<drive>/felhom-data/backups/secondary/<stack>/rsyncSecondaryResticRepoPath→<drive>/felhom-data/backups/secondary/resticSecondaryInfraPath→<drive>/felhom-data/backups/secondary/_infraAppDataDir→<drive>/felhom-data/appdata/<stack>InfraBackupDirunchanged — stays at drive root for DR scanner
internal/stacks/delete.go— Added localfelhomDataDir = "felhom-data"constant (cannot importbackupdue to architectural boundary). UpdatedProtectedHDDPaths()to protect<drive>/felhom-data,<drive>/felhom-data/appdata,<drive>/felhom-data/backups. Fixed hardcoded paths inGetStackBackupData().internal/storage/migrate_drive.go— Addedbackuppackage import. Fixed 4 issues:- Conflict check: uses
backup.AppDataDir()instead of hardcodedappdata/ - Verify step: uses
backup.AppDataDir()instead of hardcodedappdata/ - rsync excludes: updated from
backups/primary/restic/tofelhom-data/backups/primary/restic/ - Size estimation: now scans inside
felhom-data/namespace, skipping restic repos correctly
- Conflict check: uses
internal/storage/migrate.go— Addedbackuppackage import. Post-migration DB dump copy now usesbackup.AppDBDumpPath()instead of hardcoded paths.internal/web/handlers.go— Fixed legacy"storage"path in storage app detail size calculation (was dead code — path never existed); now usesbackup.AppDataDir().internal/storage/format_linux.go— Format wizard createsfelhom-data/subdirectory instead of legacystorage/.internal/storage/attach_linux.go— Attach wizard createsfelhom-data/subdirectory instead of legacystorage/.
Added
scripts/felhom-wipe.sh— Test node cleanup script with 4 wipe levels:soft— Removes controller state files (settings.json, metrics.db, session/setup/update/snapshot state)controller— Soft + removes all app containers, volumes, and stack directories (skips protected stacks by default)full—controller-level cleanup + removesfelhom-data/on all storage drives (also removes old-styleappdata/andbackups/for migration compatibility); infra containers preserved, controller restarted after cleanupnuclear— Full + removes controller.yaml, all infrastructure containers (controller, traefik, cloudflared, portainer), DR markers, and runsdocker system prune -af --volumes- Auto-detects paths from
controller.yamlandsettings.json - Dry-run by default; requires
--yesto execute - Interactive confirmation prompt with
--yesexecution
Notes
- Migration: Pre-v0.26.0 restic snapshots reference old paths (without
felhom-data/). Existing installations need data migration before upgrading. - App catalog: Compose templates need separate update:
${HDD_PATH}/appdata/→${HDD_PATH}/felhom-data/appdata/(tracked as separate task). - All backup, crossdrive, and restore logic automatically picks up new paths via
paths.gohelpers — no changes needed inbackup.go,crossdrive.go, orrestore.go.
v0.25.0 — Debug Page: Operator Testing & Diagnostics Dashboard (2026-02-21)
Full debug dashboard with 8 sections for testing all controller subsystems in debug mode.
Only available when logging.level: "debug" — sidebar link, page, and all /api/debug/* endpoints return 404 otherwise.
New files
internal/web/logbuffer.go— Ring buffer (1000 entries) implementingio.Writerfor capturing log output. Parses Go standard log format (with/withoutLshortfile), extracts level/source/timestamp. Supports filtered retrieval by level and timestamp.internal/web/handler_debug.go— Debug page handler + 20 API endpoint handlers organized in 8 sections.DebugCallbacksstruct (6 fields) for wiring main.go closures.internal/web/templates/debug.html— Full debug dashboard template with 8 collapsible sections, complete JS framework (lazy-load, polling, action buttons, log viewer with filter/auto-refresh).
Debug page sections
- Rendszer diagnosztika — Diagnostic dump (migrated from
api/router.go) with structured UI rendering: controller info, storage paths, deployed stacks, scheduler jobs, alerts. JSON download button. - Értesítés teszt — Send test events with configurable type/severity, view event history ring buffer (last 50 events, newest first).
- Mentés teszt — Trigger individual backup phases: full backup, DB dump only, cross-drive only, restic integrity check, infrastructure backup.
- Tárhely teszt — Storage watchdog status table with per-path probe state. Simulate disconnect (stops apps, marks disconnected, skips unmount) and reconnect (cleans locks, clears state). 5s auto-refresh.
- Hub & Kapcsolatok — Hub report push, infra backup push, Hub/Gitea connectivity tests with latency, preference sync.
- Önfrissítés teszt — Version check + dry-run (shows current/new image lines, compose writability, backup status).
- DR / Telepítő varázsló — Infra backup status per drive (files, timestamps). "RESET" confirmation + infra backup pre-check before triggering setup mode via marker file.
- Naplóviewer — In-memory log viewer with level filter (DEBUG/INFO/WARN/ERROR), 2s auto-refresh, color-coded entries, clear display.
Module additions
notify/notifier.go:PushTestEventSync()(synchronous, returns Hub status),GetEventHistory()(ring buffer),recordHistory()for debug page.backup/crossdrive.go:RunAllConfigured()— runs all enabled apps ignoring schedule filter.selfupdate/updater.go:DryRun()— checks update availability, compose writability, backup status without performing changes.monitor/watchdog.go:SimulateDisconnect()/SimulateReconnect()withsimulatedPathsmap,GetDebugStatus()for per-path probe state. WatchdogCheck()skips simulated paths.setup/setup.go:NeedsSetup()now checks.needs-setupmarker file.ClearSetupMarker()for cleanup.
Routing changes
- Mux carve-out:
/api/debug/routes to web server (same pattern as/api/storage/), with auth + CSRF. - Removed
SetDebugDumpDeps()fromapi/router.goand the/api/debug/dumproute — dump handler migrated tohandler_debug.gousing Server's existing fields.
Infrastructure
setupLogger()now returns(*log.Logger, *web.LogBuffer). In debug mode, createsio.MultiWriter(os.Stdout, logBuffer)so all log output is captured from the start.- Debug CSS: ~170 lines of styles for sections, result badges, log viewer, confirm input, danger button, spinner.
v0.24.0 — Pre-Testing Observability (2026-02-21)
Three features for pre-testing diagnostics: verbose debug logging, diagnostic dump endpoint, and startup self-test.
Feature 1: Debug logging across all modules
All [DEBUG] log lines are gated behind logging.level: "debug" — zero overhead at info level.
- New
internal/util/strings.go: sharedTruncateStr()for safely truncating command output in logs. - Backup (
backup.go,dbdump.go,crossdrive.go,restore.go,local_infra.go): addedisDebug()method and per-operation debug logging. DB dump logs container discovery, per-dump command details (passwords masked as***), validation results. Cross-drive logs source/dest paths, rsync results, auto-enable decisions. Restore logs step-by-step progress. - Storage (
scan_linux.go,format_linux.go,attach_linux.go,migrate.go): addedLogger/Debugfields to request structs. Logs raw lsblk output (truncated), per-disk classification, pipeline steps for format/attach, rsync progress for migrate. Updated*_other.gostubs. - Sync (
sync.go): logs masked clone URLs, per-file hash comparison, post-sync hook triggers. - Self-update (
updater.go): logs registry API calls, tag parsing, version comparison, compose file edits. - Monitor (
watchdog.go): smart logging — periodic 60-probe summaries (~5 min), immediate log on unexpected failures, reconnect attempt details. (healthcheck.go): logs raw check values and per-check results. - Notify (
notifier.go): logs event push URL/type/response, preference sync details. - Report (
pusher.go,builder.go): logs payload sizes, section summaries, push responses. - Assets (
syncer.go): logs manifest fetch, per-file hash comparison, download/removal actions. - Setup (
scanner.go,handlers.go): logs drive scan details, hub recovery/config write operations.
Feature 2: Diagnostic dump endpoint (GET /api/debug/dump)
Returns a comprehensive JSON snapshot of all controller state. Only available when logging.level: "debug" — returns 404 otherwise.
- Sections:
controller(version, uptime, config hash, PID),storage(per-path usage),stacks(deployed/running/stopped counts + list),backup(status, repo stats),hub(push status, consecutive failures),scheduler(all jobs with last_run/running/errors),health(fresh check),notifications,self_update,alerts. - API router expanded with
SetDebugDumpDeps()setter for scheduler, hub pusher, alert manager, version, and start time.
Feature 3: Startup self-test
- New
internal/selftest/selftest.go: runs 9 diagnostic checks on boot with 5s timeout each. - Checks: Docker socket, stacks directory, data directory (write test), system data path (mount point), storage paths (connected vs disconnected), git catalog (.felhom.yml files), Hub connectivity (/healthz), restic repos, metrics DB.
- Results logged in a clear block:
[PASS]/[WARN]/[FAIL]per check, summary at end. - Self-test summary (pass/warn/fail counts) sent to Hub via
NotifyControllerStarteddetails map. - Never blocks startup — purely diagnostic.
Constructor/signature changes
notify.New(): addeddebug boolparam.NotifyControllerStarted(): addeddetails map[string]interface{}param.report.NewPusher(): addeddebug boolparam.BuildReport(): addedlogger *log.Loggerparam.monitor.RunHealthCheck(): addedlogger *log.Loggerparam (5 call sites in main.go).selfupdate.NewUpdater(): addeddebug boolparam.assets.New(): addeddebug boolparam.backup.NewCrossDriveRunner(): addeddebug boolparam.WriteLocalInfraBackup(): addeddebug boolparam.backup.DiscoverDatabases(),DumpOne(): addeddebug boolparam.storage.ScanDisks(): addedlogger, debugparams.FormatRequest,AttachRequest,MigrateRequest: addedLogger/Debugfields.setup.ScanDrivesForInfraBackups(): addeddebug boolparam.
v0.23.0 — CSRF Protection (2026-02-21)
CSRF (Cross-Site Request Forgery) protection on all browser-facing POST endpoints — controller and hub.
Controller changes:
- New
internal/web/csrf.go:CsrfProtectHTTP middleware validates CSRF tokens on all state-mutating requests (POST/DELETE/PATCH).- Reads token from
_csrfform field orX-CSRF-Tokenrequest header. - Exempt paths:
Authorization: Bearerrequests (selfupdate, config/apply hub→controller calls) — browsers cannot auto-send Bearer headers, so no CSRF risk. - Auth-disabled mode (no password set): CSRF check is skipped entirely.
- On rejection: JSON error for
/api/paths, HTTP 403 text for page routes.
- Reads token from
internal/web/auth.go:sessionstruct gains acsrfToken stringfield.createSession()generates a second 32-byte random CSRF token alongside the session token. NewcsrfTokenForSession(sessionToken)method returns the CSRF token for a given session.internal/web/server.go: NewexecuteTemplate(w, r, name, data)wrapper auto-injectsCSRFField(template.HTMLhidden input) andCSRFToken(raw string) into every page render data map.cmd/controller/main.go: All route registrations wrapped withwebServer.CsrfProtect(...)middleware. Version bumped tov0.23.0.- All handlers (
handlers.go,storage_handlers.go,handler_restore.go): Switched froms.render(w, ...)tos.executeTemplate(w, r, ...). - All templates updated:
layout.html: Added<meta name="csrf-token">and inlinecsrfHeaders()JS helper (returns{'X-CSRF-Token': ...}) in<head>(before page-specific scripts). Updated 4 fetch POST/DELETE calls.settings.html: Added{{$.CSRFField}}to 5 forms inside{{range .StoragePaths}}(must use$for outer scope inside range). Added{{.CSRFField}}to 3 page-level forms. Inline-label form usesdocument.querySelector('meta[name="csrf-token"]').content. Updated 5 fetch calls.deploy.html: Added{{.CSRFField}}to cross-backup form. Updated 3 fetch calls.backups.html: Updated 3 fetch calls. Dynamically-created restore form injects_csrffrom meta tag.storage_init.html,storage_attach.html,migrate.html,migrate_drive.html,app_info.html,restore.html: All fetch calls updated.storage_attach.html: Replacednavigator.sendBeacon()withfetch(..., {keepalive: true})—sendBeaconcannot send custom headers, making CSRF impossible.
Hub changes (v0.3.8):
internal/web/server.go: Replaced insecure literalhub_session=authenticatedcookie with proper server-side session map.- New
hubSessionstruct withcsrfToken stringandexpiresAt time.Time. sessions map[string]*hubSession+sessionsMu sync.RWMutexonServerstruct.handleLogin: Generates cryptographically random 64-char hex session token + 64-char hex CSRF token. Cookie gainsSameSite=LaxandSecure(when TLS) attributes. Session expires after 7 days.RequireAuth: Validates session token against map (constant-time compare), redirects to/loginon failure.CleanupSessions(ctx): Goroutine that purges expired sessions every hour.- CSRF validation block at top of
ServeHTTP: checksX-CSRF-Tokenheader or_csrfform field on POST/DELETE/PATCH. Skips when no session cookie (Basic Auth / API path). csrfToken(r),csrfField(r)helpers for template data injection.
- New
internal/web/configs.go: Addedhtml/templateimport. All template render calls passCSRFField template.HTMLand/orCSRFToken string.renderConfigFormgainsr *http.Requestparameter.- Templates updated:
config_form.html: Added{{.CSRFField}}inside the<form>.customer_unified.html: Added<meta name="csrf-token">+ inlinecsrfHeaders()in<head>. Added{{.CSRFField}}to all 5 POST forms (unblock, block, delete config, create-config, regen-password). Updated 3 JS fetch POST calls (trigger-update, push-config, pull-config).
cmd/hub/main.go: Startedgo webServer.CleanupSessions(ctx)goroutine.
v0.22.3 — Hub Asset Sync (2026-02-21)
Hub-managed asset downloads
- New
internal/assetspackage: downloads and caches app assets (logos, screenshots) from the Hub API with SHA-256 change detection. - Asset syncer resolves files from downloaded cache first, falls back to baked-in
/usr/share/felhom/assets/directory. - Config:
assets.sync_enabled: true+assets.sync_schedule: "05:00"to enable daily sync. - API:
POST /api/assets/synctriggers on-demand sync,GET /api/assets/statusreturns sync status. - Web server's
serveAsset()now routes through syncer'sResolve()when available.
v0.22.2 — Setup Logo Fix (2026-02-21)
- Fix setup wizard logo: Logo failed to load because
handleLogo()tried to read it as a file from the filesystem, but it only exists as an embedded string constant. Now imports and servesweb.FelhomLogoSVGdirectly.
v0.22.1 — Setup Wizard Bugfixes (2026-02-21)
- Fix setup mode detection: Remove
demo-felhomfromNeedsSetup()check — only emptycustomer.idtriggers setup mode. Previously the demo customer was stuck in setup mode. - Fix CSRF nil pointer panic:
renderError()was passingnilinstead of*http.RequesttoensureCSRFToken(), causing panic when rendering error pages. - Fix double-v version display: Welcome page showed "vv0.22.0" — removed redundant
vprefix from template. - Fix IP detection in Docker: Setup wizard showed container bridge IP (172.x) instead of host LAN IP. Now reads
HOST_IPenv var (set by docker-setup.sh). - Add Hub download logging: Log Hub config download attempts and errors for easier debugging.
- docker-setup.sh: Inject
HOST_IPenv var into generated docker-compose.yml.
v0.22.0 — First-Run Setup Wizard & Local Infra Backup (2026-02-21)
Major feature release: moves ALL initial configuration and disaster recovery setup from docker-setup.sh into the controller itself as a web-based wizard.
Setup Wizard (internal/setup/):
- New web-based setup wizard replaces interactive CLI wizard from
docker-setup.sh - Dual listener:
:8080(behind Traefik) +:8081(direct HTTP for LAN access before DNS is configured) - Setup mode detection: controller enters wizard when
customer.idis empty - Two paths: "Restore from backup" (local drive scan + Hub recovery) and "Fresh install" (Hub download or manual config)
- Drive scanner: detects
.felhom-infra-backup/on all connected drives, validates checksums - Hub recovery:
GET /api/v1/recovery/{id}with retrieval password auth — returns combined config + infra backup - CSRF protection (cookie + hidden field) for all wizard POST endpoints
- State persistence (
setup-state.json) survives browser crashes - All UI text in Hungarian, uses existing dark theme CSS
- After setup: writes
controller.yaml, createssettings.json,os.Exit(0)→ Docker restart into normal mode
Local Infra Backup (internal/backup/local_infra.go):
- Writes infrastructure backup to all connected drives as
.felhom-infra-backup/backup.json+metadata.json - Schema-versioned with SHA256 checksum validation
- Runs on startup and after each nightly backup cycle
- Enables disaster recovery without Hub connectivity — any drive can bootstrap a new controller
Hub Verification:
- Pusher parses Hub report response for
customer_blockedfield - Updates
hub_verified/hub_verified_atin settings on each successful push IsLimitedMode()checks verification state + 7-day grace period
Recovery Info:
- New
internal/recovery/package generatesrecovery-info.txtin data directory - Settings page shows recovery info section (customer ID, Hub URL, masked retrieval password)
- Recovery file auto-regenerated on each startup when retrieval password is set
Pending Events:
- New
PendingEventtype in settings withAddPendingEvent()/DrainPendingEvents() - Events queued during setup (e.g., DR completed) are drained and pushed to Hub on first successful report push
Config & Settings Schema:
config.go: AddedSetupListenfield (default:8081),LoadPermissive(),Default()settings.go: Addedhub_verified,hub_verified_at,retrieval_password,pending_eventsfields with RWMutex accessors
Infrastructure:
docker-compose.yml: Added port8081:8081mapping for setup wizard- Removed old fresh-deployment auto-restore code from
main.go(lines 70-141) - Removed
restoreSettingsFromHub()andrestorePasswordsFromHub()helpers
v0.21.3 — Config Apply Infra Push + Fixes (2026-02-20)
- Push infra backup after config apply: After a successful
POST /api/config/apply, the controller immediately pushes an infra backup to the Hub so the config sync status updates right away. - Fix double "v" prefix in startup event: "Controller elindult (vv0.21.2)" → "Controller elindult (v0.21.3)".
v0.21.2 — Config Apply Bind Mount Fix (2026-02-20)
- Fix config apply on Docker bind mounts:
POST /api/config/applyfailed with "device or resource busy" becauseos.Rename()doesn't work on bind-mounted files. Now falls back to direct write when rename fails.
v0.21.1 — Config Content Endpoint (2026-02-20)
GET /api/config: New endpoint returning raw controller.yaml content (text/yaml). Used by Hub for live config diff and pull operations. Same auth as other config endpoints (Bearer token or session cookie).
What was just completed (2026-02-20 session 64)
-
v0.21.0 — Hub Monitoring Takeover (Controller-side, Phases 5+6):
Replaces external Healthchecks.io dependency with Hub-native event system. The controller now pushes structured events directly to the Hub's
/api/v1/eventendpoint, and the Hub handles dead man's switch detection, notification dispatch, and cooldown management.Phase 5 — Event Push System (
internal/notify/notifier.go):- New core method
PushEvent(eventType, severity, message, details)— non-blocking goroutine, 2 retries with 3s backoff, POSTs to Hub/api/v1/event - 8 typed detail structs:
BackupDetails,DBDumpDetails,DiskDetails,HealthDetails,StorageDetails,UpdateDetails,AppDetails,CrossDriveDetails - Replaced all old
Notify*methods with event-based equivalents:NotifyBackupCompleted/Failed→backup_completed/backup_failedeventsNotifyDBDumpCompleted/Failed→db_dump_completed/db_dump_failedeventsNotifyIntegrityOK/Failed→backup_integrity_ok/backup_integrity_failedeventsNotifyHealthChange→ detects transitions, pusheshealth_degraded/health_critical/health_recoveredNotifyStorageDisconnected/Reconnected→storage_disconnected/storage_reconnectedeventsNotifyControllerStarted→controller_startedevent on startupNotifyControllerUpdated→controller_updatedevent (replacesNotifyUpdateSuccess/Failed)NotifyAppDeployed/Removed→app_deployed/app_removedeventsNotifyCrossDriveCompleted/Failed→crossdrive_completed/crossdrive_failedeventsNotifyDRStarted/Completed→disaster_recovery_started/disaster_recovery_completedevents
- Removed old
/api/v1/notifyrelay,classifyWarning(), and client-side cooldown logic (Hub handles cooldowns now) SendTest()now pushestestevent type viaPushEventSyncPreferencesupdated to includecooldownHoursparameter
Phase 5 — Event Wiring:
main.go: Wired success events for backup, db-dump, integrity check; startup event with 5s delay; update event afterVerifyStartup()router.go: AddedNotifyAppDeployed/NotifyAppRemovedafter successful deploy/remove via APIhandler_restore.go: AddedNotifyDRStarted/NotifyDRCompletedin DR restore flowserver.go: NewHubPushStatusDatastruct andSetHubPushStatuscallback for monitoring page
Phase 5 — Hub Connection Monitoring:
pusher.go: AddedPushStatustracking (LastAttempt, LastSuccess, LastError, Consecutive failures) to report Pusherhandlers.go: Monitoring page now shows Hub connection status (connected/unreachable, URL, customer ID, last success, last error) instead of Healthchecks ping UUIDsmonitoring.html: Replaced "Távoli monitoring" section with "Hub kapcsolat" sectionalerts.go: Replaced "Missing ping UUIDs" alert with Hub connection alerts (hub-disabledwarning,hub-unreachableerror)
Phase 5 — Expanded Notification Settings:
settings.html: Expanded from 4 checkboxes to 11 grouped toggles in two categories:- "Hibák és figyelmeztetések": backup_failed, db_dump_failed, backup_integrity_failed, crossdrive_failed, disk alerts, storage_disconnected, node_down, health_critical, expected missed
- "Tájékoztató": storage_reconnected, health_recovered
- Compound toggles: "Lemez figyelmeztetés" maps to
disk_warning+disk_critical; "Elvárt mentés elmaradt" maps toexpected_backup_missed+expected_dbdump_missed settings.go: UpdatedDefaultEnabledEventsto new Hub event typeshandlers.go: Updated settings POST handler for expanded event names and compound toggles
Phase 6 — Config Cleanup:
main.go: Deprecation log on startup when ping UUIDs are configured:[INFO] Healthchecks ping UUIDs configured but no longer used — monitoring is now handled by the Hub- Pinger still runs for transitional backward compatibility
- New core method
What was just completed (2026-02-20 session 63)
-
v0.20.0 — Hub Config Management (Phase B):
Two new features enabling the Hub to manage and compare controller configuration remotely.
Feature A — Config Apply Endpoint:
router.go: AddedPOST /api/config/apply— accepts YAML body from Hub, validates it's parseable viaconfig.LoadFromBytes(), writes atomically to controller.yaml (.tmp+os.Rename), returns success JSON. Restart required to apply.router.go: AddedGET /api/config/hash— returns SHA256 hex digest of current controller.yamlrouter.go: Router struct gainedconfigPath stringfield;NewRouter()signature updatedconfig.go: AddedLoadFromBytes([]byte)— parses YAML without file I/O (for validation)config.go: AddedFileHash(path)— SHA256 hex digest helpermain.go: Config endpoints use same dual auth middleware as self-update (session OR Hub API key Bearer token)main.go: Added/api/config/mux entry withselfUpdateAuthMiddleware
Feature B — Config Hash in Reports:
types.go: AddedConfigHash stringfield toReportstruct (JSON:config_hash)builder.go:BuildReport()now acceptsconfigPath stringparameter, computes SHA256 of controller.yaml and includes it in every reportmain.go: All 4BuildReport()call sites updated to pass*configPath- Hub uses this hash to compare against its generated YAML — shows "In sync" / "Config mismatch" / "Unknown" on the unified customer detail page
What was just completed (2026-02-20 session 62)
- docker-setup.sh — Hub Config Download:
- Added
--hub-customerand--hub-passwordCLI flags for downloading pre-configured controller.yaml from Felhom Hub - Added
HUB_URLglobal variable (default:https://hub.felhom.eu) - Hub download logic at start of
run_config_wizard(): downloads YAML viacurlwithX-Retrieval-Passwordheader, validates response, extracts key variables (domain, CF tokens, email), sets global variables for subsequent setup steps - Falls back to interactive wizard if download fails or credentials not provided
- Added
What was just completed (2026-02-20 session 61)
-
v0.19.0 — Deployed App Removal + Missing Field Injection:
Two new features: "Eltávolítás" (Remove) action for deployed stacks and automatic missing deploy field injection on template updates.
Feature A — Deployed App Removal ("Eltávolítás"):
delete.go: AddedRemoveStack()— removes deployed (non-orphaned) stack:docker compose down --volumes, optional HDD data cleanup, optional backup data cleanup (DB dumps + cross-drive rsync), removesapp.yamlonly (template files preserved for redeploy); stack reverts to "Nincs telepítve" statedelete.go: AddedGetStackBackupData()— returns backup path info (DB dump dir + cross-drive rsync dir) with sizes and existence statusdelete.go: AddedRemoveResponse,BackupDataResponsestructs,buildPathInfo()helperrouter.go: AddedPOST /api/stacks/{name}/removeendpoint — accepts{remove_hdd_data, remove_backups}, computes backup paths viaAppDBDumpPath()/AppSecondaryRsyncPath(), cleans cross-drive config on successrouter.go: AddedGET /api/stacks/{name}/backup-dataendpoint — returns backup data paths with sizescrossdrive.go: MadegetAppDrivePath→GetAppDrivePath(public) for use by routerstacks.html: Added "Eltávolítás" button for stopped, deployed, non-orphaned, non-protected stacksdashboard.html: Same button in compact card layoutlayout.html: AddedremoveStack()modal — fetches HDD + backup data in parallel, 3-section layout (always removed / HDD data with checkbox / backup data with checkbox), reimport warning for preserved HDD data, restic retention notelayout.html: AddedconfirmRemoveStack()— POST to/remove, shows result summary with removed/preserved paths
Feature B — Missing Deploy Field Injection:
deploy.go: AddedInjectMissingFields(stackNames)— iterates deployed stacks, compares.felhom.ymldeploy_fields againstapp.yamlenv vars, auto-generates values for missingsecret(using generator spec) anddomainfields, saves updatedapp.yamldeploy.go: Addedbase64keygenerator type — producesbase64:<N random bytes base64-encoded>(for Laravel APP_KEY and similar)deploy.go: AddedcontainsStr()helpermanager.go: AddedDeployedStackNames()— returns names of all deployed stackssync.go: AddedpostSyncHook func(updated []string)field toSyncer;New()accepts optional hook; hook called indoSync()after rescan with names of updated stacksmain.go: Wired injection on startup (all deployed stacks) and after sync (updated stacks only)
v0.18.0 (2026-02-19 session 60)
-
v0.18.0 — Drive Migration & Tier 2 Restic Deprecation:
Full drive replacement workflow with decommissioned state, enhanced per-app migration with backup awareness, and deprecation of restic as a Tier 2 cross-drive backup method (rsync only).
Phase 1 — Restic Tier 2 Deprecation:
settings.go: Auto-migrate restic→rsync on startup viamigrateResticToRsync()inLoad()crossdrive.go: RemovedrunResticBackup(),pruneResticRepo(),ensureResticRepo();RunAppBackup()calls rsync directlybackup.go: Removed Tier 2 secondary restic scanning fromListAllSnapshots()settings.go: Removed cross-drive restic password methods (GetOrCreateCrossDrivePassword, etc.)deploy.html: Removed method dropdown (rsync/restic selector)handlers.go: SimplifiedTier2DriveGroup(flatItemslist), removed method handling fromsettingsCrossBackupHandler()backups.html: Removed method split in Tier 2 details sectionrouter.go: Always set method to "rsync" in cross-backup APIinfra_backup.go: Removed cross-drive password block fromCollectInfraBackup()main.go: RemovedSetCrossDriveResticPasswordrestore block
Phase 2 — Enhanced Per-App Migration:
backup.go: ExtractedbackupDrive()fromrunBackupInternal()loop; addedTryRunDriveBackup()with non-blocking lockcrossdrive.go: AddedAnyRunning()methodmigrate.go: AddedBackupTriggerinterface,MigrateOrchestrator,RunEnhancedMigration()with post-migration steps (DB dump copy, Tier 2 conflict clearing, auto-delete stale data, immediate Tier 1 backup)storage_handlers.go: Wired orchestrator into migration handler withauto_delete_stalesupportmigrate.html: Added auto-delete checkbox, "cleaning" + "backing_up" progress steps
Phase 3 — Full Drive Migration:
settings.go: AddedDecommissioned/DecommissionedAt/MigratedTofields toStoragePath; addedSetDecommissioned(),ClearDecommissioned(),IsDecommissioned(),GetDecommissionedPaths(),GetStorageLabel();GetConnectedPaths()/GetSchedulableStoragePaths()exclude decommissionedmigrate_drive.go(NEW):DriveMigratorwithMigrateDrive()10-step flow (validate→stop→rsync→verify→configure→decommission→Tier2→start→backup→notify),migrationTxrollback pattern, excludes restic repos from rsyncsettings.html: Decommissioned card variant with "Kiváltva" badge, "Összes adat átköltöztetése" button on connected cardsmigrate_drive.html(NEW): Drive migration wizard (form + progress + done cards)storage_handlers.go: Added/api/storage/migrate-drive,/api/storage/migrate-drive/status,/api/storage/decommission/removeendpointsserver.go: Added/settings/storage/migrate-driveroute,SetDriveMigrator()setterwatchdog.go: Skip decommissioned drives inCheck(); blockSafeDisconnect()for decommissionedhealthcheck.go: Skip decommissioned paths incheckStoragePaths()backup.go: Skip decommissioned drives inbackupDrive()/runDBDumpsInternal(); addedMigrationActiveCheckcallback to skip nightly backup during migrationcrossdrive.go: Reject decommissioned destinations inValidateDestination(); skip decommissioned paths inAutoEnableSmallApps()handlers.go: Skip decommissioned drives inbuildStorageBars(); madeSyncFileBrowserMounts()publicmain.go: AddeddriveMigrateStackAdapter, wiredDriveMigratorwith all dependencies
Phase 4 — Hub Changes:
report/types.go: AddedDecommissioned/MigratedTofields toStorageReportreport/builder.go: Include decommissioned drives in report with flag
Files modified: 21 files modified + 2 new files (
migrate_drive.go,migrate_drive.html).
What was just completed (2026-02-19 session 59)
-
v0.16.1 + hub v0.1.8 — Hub Update Trigger + Controller URL Reporting:
Controller now includes its external URL (
controller_url) in periodic hub reports so the hub can trigger self-updates remotely. Hub tracks the URL in a newcontroller_urlDB column, checks the Gitea registry for the latest controller image version (VersionChecker goroutine,web/version.go), and shows a "Controller Update" card on the customer detail page.Controller (v0.16.1):
internal/report/types.go: AddedControllerURL stringfield to Report struct.internal/report/builder.go: SetsControllerURLfromcfg.Customer.Domain→https://felhom.<domain>.internal/api/router.go: Bug fix — moved selfupdate routes to beforehasSuffix(path, "/update")stack case (which was catching/selfupdate/updatefirst).
Hub (v0.1.8):
cmd/hub/main.go: AddedRegistryconfig section + defaults; createsVersionCheckergoroutine if credentials configured; passesapiKeytoweb.New().internal/store/store.go: AddedControllerURLtoCustomerSummary; idempotentALTER TABLE reports ADD COLUMN controller_url TEXTmigration; updatedSaveReport,GetCustomers,GetCustomer,GetCustomerHistoryqueries.internal/web/version.go(NEW):VersionCheckertype — polls Gitea Docker Registry V2 API (/v2/<owner>/<repo>/tags/list) every 6h; parses semver tags; stores latest version thread-safely.internal/web/server.go: AddedapiKey,versionCheckerfields; updatedNew()signature; addedSetVersionChecker(); addedhandleTriggerUpdatehandler that proxies POST to controller's/api/selfupdate/update; added trigger-update route (before/customers/catch-all); updatedhandleCustomerDetailwithControllerURL,LatestVersion,UpdateAvailabletemplate data; addedcompareVersionshelper.internal/web/templates/customer.html: New "Controller Update" section between Health and Notifications — shows current/latest version with update indicator, controller URL link, and conditional "Trigger Update" button with JS.internal/api/handler.go: AddedControllerURLto/api/v1/customersJSON response.- Hub config (
hub.yaml): Addedregistry:section with Gitea admin credentials.
Files modified/created: controller: 3 files; hub: 5 modified + 1 created (version.go).
What was just completed (2026-02-19 session 58)
-
v0.16.0 — Controller Self-Update:
Watchtower-style self-update mechanism. New package
internal/selfupdate/with 3 files:version.go(semver parsing/comparison),state.go(audit log state file I/O),updater.go(registry check via Gitea V2 API, update trigger, startup verification).Flow: Gitea registry tag list →
docker pull→ atomic compose file rewrite →docker compose up -d→ process replaced. State file (update-state.json) persists across restart as audit log; verified on next startup to detect success/failure.Config:
SelfUpdateConfigextended withAutoUpdateTimefield + defaults forImageandAutoUpdateTime. Scheduler jobs: periodic check everycheck_interval(default 6h); optional daily auto-update atauto_update_time(default 04:30).API: 3 new endpoints under
/api/selfupdate/(status,check,update). Auth via session cookie ORAuthorization: Bearer <hub_api_key>header (for external triggering from build scripts).UI: Settings page "Verzió és frissítés" card shows current/latest version, check time, auto-update status, last update result. "Frissítés keresése" button queries registry; "Frissítés telepítése" button appears when update is available.
pollUntilBack()JS polls/api/healthafter triggering update and reloads when container is back up.Notifications:
NotifyUpdateSuccess()andNotifyUpdateFailed()added to notifier for post-update startup verification results.Alert: Dashboard shows "Új controller verzió elérhető" info alert when update is available.
docker-compose.yml: Added
/opt/docker/felhom-controller:/opt/docker/felhom-controllerdirectory bind mount (required for compose file access during self-update); named volume and read-only config override on top.Files modified/created (12):
internal/selfupdate/version.go(NEW),internal/selfupdate/state.go(NEW),internal/selfupdate/updater.go(NEW),internal/config/config.go,internal/notify/notifier.go,internal/api/router.go,internal/web/server.go,internal/web/handlers.go,internal/web/alerts.go,internal/web/templates/settings.html,cmd/controller/main.go,docker-compose.yml
What was just completed (2026-02-19 session 57)
-
v0.15.7 — Fix backup page storage display & rename system drive label:
Backup page ("Biztonsági mentés") now shows all registered storage paths instead of only a single "Külső HDD". Added
data["StorageBars"] = s.buildStorageBars()tobackupsHandler(was missing unlike dashboard/monitoring handlers). Updatedbackups.htmlstorage bars section to useStorageBarsloop (same pattern as monitoring page), replacing the old{{if .HDDConfigured}}single-HDD block.Renamed system root partition label from "SSD (/)" to "Rendszer (/)" on all three pages (backup, monitoring, dashboard), as the root filesystem is not necessarily on an SSD.
Files modified (4):
internal/web/handlers.go,internal/web/templates/backups.html,internal/web/templates/monitoring.html,internal/web/templates/dashboard.html
What was just completed (2026-02-19 session 56)
-
v0.15.6 (controller) + hub v0.1.7 — Bug hunt fixes (BUGHUNT.md):
Controller — Restore race conditions (P0-P1): All 4 restore handlers (
restorePageHandler,apiRestoreStatus,apiRestoreAll,apiRestoreSkip) now holdrestoreMu.RLock()across nil-check and field reads.apiRestoreAlluses newTryStartRestore()method for atomic check-and-set (eliminates double-restore race).executeAllRestores()snapshots plan under lock, usesSetStatus("done")instead of direct write. Removed dead no-op goroutine.Controller — restore_scan.go:
dirIsEmpty()now returnsfalseon read errors (was silently treating unreadable dirs as empty, losing backup data).Snapshot()deep-copies Apps and Drives slices. AddedTryStartRestore(),SetStatus(),GetStatus()helper methods.Controller — infra_backup.go (P0):
controller.yamlread failure now returns a real error (was silently creating empty backup).settings.jsonand restic password read failures now logged. Addedlogger *log.Loggerparameter toBuildInfraBackup.Controller — main.go DR wiring: Fixed ordering —
restoreSettingsFromHub+ settings reload now happens beforerestorePasswordsFromHub(prevents cross-drive password loss). Nil check afterScanDrivesForBackups.os.MkdirAllerror now logged.os.MkdirAlladded torestoreSettingsFromHubbefore write.Hub — store.go (P2): 5
json.Unmarshalcalls now log[WARN]on failure.GetInfraBackupMetalogs unmarshal error instead of silently returning wrong counts.docker-setup.sh (P0-P2): DRY_RUN check moved to top of
run_config_wizard()with dummy values (was prompting interactively even in dry-run). CF tunnel token quoted in docker-compose env.htpasswdusescut -d: -f2+ bcrypt format validation.grep -qFfor literal path matching. Volume paths quoted in YAML output. Post-wizard validation rejects defaultdemo-felhom/homeserver.localvalues.restore.html (P2-P3): Error text uses
textContentinstead ofinnerHTML. Poll errors counted; after 10 failures shows "Kapcsolat megszakadt" message instead of polling silently forever.Files modified (controller, 6):
internal/backup/restore_scan.go,internal/web/handler_restore.go,internal/report/infra_backup.go,cmd/controller/main.go,internal/web/templates/restore.html,scripts/docker-setup.shFiles modified (hub, 1):hub/internal/store/store.go
What was just completed (2026-02-19 session 55)
-
v0.15.5 — Fix startup hub report silently failing:
Push()now returns actual errors instead of alwaysnil. Previously, push failures were logged internally but the caller could never detect them, leading to a misleading[INFO] Startup hub report sentlog even when the push actually failed (e.g., hub returning HTTP 503 during simultaneous deployment). Removed the "Never returns error to caller" behavior: marshal error returns a wrapped error, and after 3 failed retries the error is returned to the caller (the internal[WARN]log beforereturn nilis gone).Startup hub push now retries 3 times with 15-second delays between outer attempts, giving the hub time to come up when both are deployed together. Each outer attempt uses
Push()'s own internal 3-retry logic (5s backoff), so the hub gets up to ~40s total to become ready. If all 3 outer attempts fail, logs a clear warning with the next scheduled push interval.Files modified (2):
internal/report/pusher.go,cmd/controller/main.go
What was just completed (2026-02-19 session 54)
-
v0.15.4 (controller) + hub v0.1.6 — Hub reporting improvements:
Controller: When
hub.enabled: falsebut URL+API key are configured, the controller now creates thePusherand sends a one-time "disabled" notification on startup (health.status = "disabled",reporting_disabled: true). This replaces the old behavior where a disabled controller was indistinguishable from a crashed node. AddedPushOnce()method toPusher(bypasses theenabledflag). AddedReportingDisabledfield to theReportstruct.Hub: Added "disabled" status handling — when the latest report has
health_status = "disabled", the overall status is "disabled" (checked BEFORE the stale-time logic, so it stays "PAUSED" even after 30min+). Dashboard shows gray "PAUSED" badge. Customer detail shows "Reporting has been disabled on this node" with a hint to re-enable. Storage labels now shown (labelfield with fallback tomount). Report history timestamps now show date + time ("Feb 19 09:46" instead of "09:46:54"). New.status-badge-disabledCSS (neutral gray#475569).Files modified (controller):
internal/report/types.go,internal/report/pusher.go,cmd/controller/main.goFiles modified (hub):hub/internal/web/server.go,hub/internal/web/templates/dashboard.html,hub/internal/web/templates/customer.html,hub/internal/web/templates/style.css
What was just completed (2026-02-19 session 53)
-
v0.15.3 — Show all storage paths on dashboard + fix hub report:
Dashboard ("Vezérlőpult") and monitoring ("Rendszermonitor") pages now show usage bars for ALL registered storage paths instead of just one hardcoded "Külső HDD" bar. New
StorageBarInfotype andbuildStorageBars()helper build bars fromsettings.GetStoragePaths(). Each bar shows the storage label and live disk usage.Hub storage report now correctly includes all registered storage paths with proper mount paths and labels. Previously it sent only root
/plus one HDD entry using the deprecated (empty)cfg.Paths.HDDPath. Now usessystem.GetDiskUsage()per storage path, same as the dashboard bars. AddedLabelfield toStorageReportintypes.go.Files modified (5):
internal/web/handlers.go,internal/web/templates/dashboard.html,internal/web/templates/monitoring.html,internal/report/builder.go,internal/report/types.go
What was just completed (2026-02-19 session 52)
-
v0.15.2 — Fix data loss on container restart (2 bugs):
Bug 1: Snapshot history delta stats (HOZZÁADOTT, ÚJ FÁJL, VÁLTOZOTT) showed 0 after container restart because restic doesn't store these stats — they were only in memory. Fixed by persisting the snapshot history ring buffer to
data/snapshot-history.json. On startup, persisted stats are merged with restic repo snapshots. AddedsaveSnapshotHistory()(atomic write via tmp+rename),loadSnapshotHistoryFromFile(), updatedappendSnapshotRecord()to save after each backup, and updatedLoadSnapshotHistory()to merge persisted + restic data.Bug 2: DB validation (ÉRVÉNYESÍTÉS column) showed "–" after restart because the synthesized
LastDBDump.Resultsdidn't copyValidationfromDumpFileInfo. One-line fix: addedValidation: f.Validationto the synthesizedDumpResultinGetFullStatus().Files modified:
internal/backup/backup.go
What was just completed (2026-02-19 session 51)
-
v0.15.1 — Backup Page "Részletek" Overhaul:
Replaced the "Tároló" section on the backup page with a new "Részletek" section containing 3 collapsible tier sections with per-drive breakdowns.
Tier 1 (Helyi mentés): Shows per-drive restic repo stats (size, snapshot count) with storage labels. Includes aggregated totals when multiple drives exist, plus DB dump summary, integrity check, and encryption key (all carried over).
Tier 2 (Másodlagos másolat): Groups cross-drive backup items by destination drive, separated into restic and rsync method sections with per-app sizes.
Tier 3 (Távoli mentés): Placeholder for future B2/S3/SFTP remote backup.
Restore UI improvements: Snapshot dropdown now groups by tier (optgroup), shows tier label + drive name per snapshot (e.g., "1. szint, hdd_1"), and marks Tier 1 as recommended. Also lists Tier 2 (secondary restic) snapshots for visibility.
Backend: New
DriveRepoInfostruct,perDriveRepoStats()method,ListAllSnapshots()that includes secondary restic repos, andTier2DriveGrouphandler struct.SnapshotInfonow carriesTierandDriveLabelfields.Files modified (5):
internal/backup/backup.go,internal/backup/restic.go,internal/web/handlers.go,internal/api/router.go,internal/web/templates/backups.html,internal/web/templates/style.css
What was just completed (2026-02-18 session 50)
-
v0.15.0 — Attach Existing Drive (bind mount wizard):
New feature: Settings → "Meglévő meghajtó csatolása" wizard. Allows attaching a drive that already has a filesystem (ext4, etc.) without formatting. Solves the real-world scenario where a customer's drive contains existing data that must be preserved.
How it works: The partition is mounted read-only at a hidden staging path (
/mnt/.felhom-raw/<label>). A directory browser lets the user navigate the drive's contents and create a new folder. The selected folder is bind-mounted at/mnt/<hdd-name>, keeping the controller's data isolated from existing files. Two fstab entries (raw + bind, both withnofail) ensure the mount survives reboots.Wizard flow: Scan → Select partition (only shows partitions with existing FS) → Mount raw + Browse directories → Create folder if needed → Configure mount name + label → Finalize (bind mount + fstab + permissions + register). Cancel cleans up the temp mount.
New files (4):
internal/storage/attach.go,internal/storage/attach_linux.go,internal/storage/attach_other.go,internal/web/templates/storage_attach.htmlModified files (3):internal/web/storage_handlers.go(6 new API handlers),internal/web/server.go(route + activeRawMount field),internal/web/templates/settings.html(button)
What was just completed (2026-02-18 session 49)
-
v0.14.2 — Backup Bug Fixes (4 fixes from code review):
Bug 1 (HIGH): rsync
--deletewas destroying_db/and_config/directories on every single-mount run. Fixed by adding--exclude _*to the rsync command inrunRsyncBackup(). Controller-managed directories (underscore prefix) are now excluded from--deletecleanup. (crossdrive.go)Bug 2 (MEDIUM): Scheduled backups (
RunBackup,RunDBDumps) did not setm.running, so UI showed "not running" during nightly jobs and restore could overlap. Fixed by extractingacquireRunning()/releaseRunning()helpers andrunDBDumpsInternal()/runBackupInternal()internal methods. All three public entry points now guard with the running flag;RunFullBackup()calls the internal methods directly to avoid deadlock. (backup.go)Bug 3 (MEDIUM):
ValidateDestinationsilently succeeded whenGetDiskUsagereturned nil (exotic filesystems, FUSE, NFS). Fixed by logging[WARN]and returning nil (backward-compatible). (crossdrive.go)Bug 4 (MEDIUM): Empty
systemDataPathproduced relative dump paths. Fixed with: startup[WARN]inNewManager(),[ERROR]log inGetAppDrivePath(), and explicit guard inDumpStackDB()that returns an error when path is empty or non-absolute. (backup.go)Files modified (2):
internal/backup/backup.go,internal/backup/crossdrive.go
What was just completed (2026-02-18 session 48)
-
v0.13.1 — UI Polish Fixes Round 2 (4 fixes):
Fix 1: Deploy page "Biztonsági mentés" section now has proper card border. Root cause:
.deploy-cross-driveused undefined CSS variables--card-bgand--border(only--bg-secondaryand--border-colorexist). Fixed by using correct vars (style.css).Fix 2: Auto-generated env values section cleaned up (
deploy.html,style.css). Badge moved inline with label. "Másolás" buttons removed (native select+copy sufficient). Secret fields keep show/hide toggle. Non-secret fields now plain readonly input without button wrapper. RemovedcopyAutoField()JS. CSS updated:.form-group-autonow block layout (was flex row), label usesdisplay: flex; gap: .5rem, badge downsized to0.75rem / normal weight, readonly inputs get muted background.Fix 3: Snapshot table n/a → 0 (
backups.html). Replaced<span class="col-na" title="...">n/a</span>with plain0in all three stats columns. Removed.col-naCSS class (no longer used).Fix 4: Disk warnings moved from top banner to inline under storage bars (
alerts.go,layout.html,handlers.go,dashboard.html,monitoring.html,style.css). AddedInline boolfield toAlertstruct. Disk-related warnings setInline: true. Layout banner skips inline alerts. NewGetInlineAlerts(page)method onAlertManager. Dashboard and monitoring handlers passDiskWarnings. Inline warning block rendered below storage bars. New.inline-warning*CSS classes (compact, subtle, colored).Files modified (8):
alerts.go,handlers.go,templates/style.css,templates/dashboard.html,templates/backups.html,templates/deploy.html,templates/monitoring.html,templates/layout.html
What was just completed (2026-02-18 session 47)
-
v0.13.0 — UI Polish Fixes (8 independent fixes):
Fix 1: backup-status-card border already correct (verified same styling as system-info-card).
Fix 2: Deploy page auto-generated fields now show actual values for deployed apps (
deploy.html,handlers.go). Secrets show as password fields with show/hide toggle; domain/plain values show as readonly text with copy button. JS helperstoggleAutoField()/copyAutoField()added.Fix 3: Temperature display made more prominent (
dashboard.html,style.css). Dot enlarged to 11px; value wrapped in colored pill badge (.temp-value-pill/.temp-pill-{green|yellow|red}).Fix 4: Dashboard backup card reworked (
dashboard.html,handlers.go). Removed "Mentés most" button andtriggerBackup()JS. Removed "Tároló méret" line. Added Tier 2 status line (configured/total apps) + warning row for failed cross-drive backups. Handler now computesCrossDriveTotal,CrossDriveConfigured,CrossDriveFailed.Fix 5: HDD warning banner scoped to dashboard + monitoring pages only (
alerts.go,layout.html,funcmap.go). AddedPageOnly []stringfield toAlertstruct. Disk-related warnings (keywords "meghajtón", "adattároló") get stable ID"disk-not-separate"+PageOnly: ["dashboard", "monitoring"].pageMatch()template function added. Layout renders alerts conditionally.Fix 6: Tárhely section moved up in Rendszermonitor — now appears right after "Rendszer áttekintés", before "Távoli monitoring" (
monitoring.html).Fix 7: Snapshot table improvements (
backups.html,style.css). "MÉRET" renamed to "HOZZÁADOTT (új adat)".–for unavailable data replaced withn/a(with tooltip explaining restic limitations). New.col-subtitleand.col-naCSS classes.Fix 8: Tároló section restructured into tiers (
backups.html,handlers.go,style.css). Tier 1 (restic local), Tier 2 (cross-drive, only shown if configured), DB dump directory + total size. Removed "Távoli másolat: Nincs beállítva" placeholder. Handler passesDBDumpDir,DBDumpTotalBytes,Tier2Dests(deduplicated). New.repo-tier/.repo-tier-titleCSS.Files modified (9):
alerts.go,funcmap.go,handlers.go,templates/style.css,templates/dashboard.html,templates/backups.html,templates/deploy.html,templates/monitoring.html,templates/layout.html
What was just completed (2026-02-18 session 46)
-
v0.12.9 — Tier 2 for All Apps + Status Dot Update:
Fix 1: Tier 2 now configurable for ALL apps — not just HDD apps (
crossdrive.go)- Removed
len(mounts) == 0error gate fromRunAppBackup()— empty mounts = config-only backup - rsync: DB dump copy (
_db/) + config rsync (_config/) still runs even with zero HDD mounts - restic: config dir + DB dump dir still appended even without mount paths
- Non-HDD apps (Mealie, Gokapi, etc.) can now be protected against drive failure via Tier 2
Fix 2: Status dot logic updated, HasHDDData gate removed (
handlers.go)buildAppBackupRows(): "auto" (gray) status removed — all apps start yellow ("Csak helyi mentés")- Green requires Tier 2 configured + last status "ok" (not just "configured but never run")
- Tier2 section is now unconditional — no
if app.HasHDDDatagate - Cross-drive summary loop: removed
if !app.HasHDDData { continue }— all apps in summary
Fix 3: Backup page template updates (
backups.html)- Tier 2 row shown for all apps (removed
{{if .HasHDDData}}gate) - Meta badge: non-HDD apps show "Konfig" or "Konfig + DB" instead of "Auto"
- Tier 3 placeholder row added (grayed out "Hamarosan / távoli offsite")
- Button text: "Összes HDD mentés" → "Összes 2. mentés futtatása most"
Fix 4: Deploy page cross-drive section visible for all deployed apps (
deploy.html)- Removed
{{if .StorageInfo}}double-gate — section now shows for all deployed apps - Updated heading: "Másolat másik meghajtóra (felhasználói adatok)" → "2. mentés — másolat másik meghajtóra"
- Updated hint: "mint az alkalmazás adattárolója" → "a meghibásodás elleni védelem érdekében"
Files modified (4):
internal/backup/crossdrive.go,internal/web/handlers.go,internal/web/templates/backups.html,internal/web/templates/deploy.html - Removed
What was just completed (2026-02-18 session 45)
-
v0.12.8 — Complete Cross-Drive Backup + Per-Tier UI:
Fix 1: Cross-drive backup now includes DB dumps + app config (
crossdrive.go,main.go)CrossDriveRunnergetsdbDumpDirfield +SetDBDumpDir(dir string)settercopyStackDBDumps()helper copies<stackName>_*.sqlfiles to_db/subfolder in rsync destrunRsyncBackup(): after HDD mount rsync loop, copies DB dumps to_db/and rsyncs config dir to_config/— both non-fatal on errorrunResticBackup(): appends config dir and full DB dump dir to restic paths (restic deduplicates)- rsync destination layout:
backups/rsync/<app>/_db/(dumps) +_config/(compose+yaml) + user data main.go:crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir)wired after runner init
Fix 2: UI restructured from per-layer to per-tier (
handlers.go,backups.html,style.css)AppBackupRowstruct rebuilt: dropped oldDBLastRun/Status,VolumeLastRun/Status,HasUserData,UserDataConfigured/Method/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadgefields- New fields:
BackupContents(e.g., "DB + Konfig + Adatok"),Tier1LastRun/LastStatus/DBStatus,Tier2Configured/Method/MethodLabel/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadge/SizeHuman/Browsable buildAppBackupRows()rewritten: destination health now vias.crossDriveRunner.ValidateDestination()instead ofsystem.CheckBackupDestination()backups.html: two tier rows (1. mentés / 2. mentés) replace the old three layer rows (DB / Konfig / Userdata)style.css: added.tier-label,.tier-location,.tier-contents,.tier-size,.tier-browsableclasses
Fix 3: Cleanup (
router.go)filterSnapshotsByPaths()andpathCovers()deleted (were unused since v0.12.7a)
Files modified (6):
internal/backup/crossdrive.go,cmd/controller/main.go,internal/web/handlers.go,internal/web/templates/backups.html,internal/web/templates/style.css,internal/api/router.go
What was just completed (2026-02-18 session 44)
-
v0.12.7a — Post-deploy fixes:
Fix A: Restore now shows snapshots for all apps (
internal/api/router.go)- Root cause:
filterSnapshotsByPathsfiltered older snapshots (pre-v0.12.7) by HDD paths. Older snapshots don't contain HDD paths (backup wasn't mandatory yet), so Immich got zero snapshots. - Fix: removed HDD path filtering entirely from
backupSnapshots. All snapshots contain config + DB dumps and are useful for any app.RestoreAppextracts whatever paths are available from the chosen snapshot. filterSnapshotsByPathsandpathCoversfunctions kept (unused, no compile error).
Fix B: Clarified "no cross-drive" warning (
internal/web/handlers.go,backups.html,style.css)- Root cause: "Nincs beállítva" / red dot implied no backup at all — misleading since nightly restic now always covers HDD data.
handlers.go: status"red"→"yellow", StatusText →"Nincs második másolat (csak helyi mentés)"backups.html: added✓ Helyi mentés autobadge before the⚠ Nincs 2. másolatwarningstyle.css:.layer-auto-okclass added (green text for the auto badge)
Files modified (3):
internal/api/router.go,internal/web/handlers.go,internal/web/templates/backups.html,internal/web/templates/style.css - Root cause:
What was just completed (2026-02-18 session 43)
-
v0.12.7 — Backup Architecture Overhaul (mandatory HDD backup, pre-dump, restore for all apps):
Fix 1: HDD data backup now mandatory (
backup.go,appdata.go,settings.go)resolveAppBackupPaths()rewrote to iterate ALL deployed stacks viaListDeployedStacks()— no longer readsGetAppBackupMap()or checksEnabledflagDiscoverAppData()signature simplified: droppedbackupPrefs map[string]boolparameter;BackupEnabledis now derived fromHasHDDData(if app has HDD data, it's always backed up)RefreshCache()updated to call newDiscoverAppData(m.stackProvider, status.DiscoveredDBs)signature- 5 dead settings methods deleted:
IsAppBackupEnabled,SetAppBackup,GetAppBackupMap,SetAppBackupBulk,GetAppBackupPrefs—AppBackupPrefs.Enabledfield kept in struct for backward-compat JSON loading
Fix 2: Cross-drive backup triggers fresh DB dump first (
crossdrive.go,backup.go,main.go)- New
DBDumperinterface withDumpStackDB(ctx, stackName)incrossdrive.go CrossDriveRunnergetsdbDumperfield +SetDBDumper(d DBDumper)setterManager.DumpStackDB()discovers containers for that stack viaDiscoverDatabases(), runsDumpAll(), persists validation cache — same logic as nightly dump but scoped to one stackRunAppBackup()callsDumpStackDB()beforeValidateDestination()— non-fatal on failure (logs warn, proceeds with user data)main.gowirescrossDriveRunner.SetDBDumper(backupMgr)after both are initialized
Fix 3: Restore dropdown shows ALL deployed apps (
backups.html,restore.go,router.go)restore.gorewritten: noIsAppBackupEnabled()check; resolvesGetStackComposePath+DBDumpDir+ HDD mounts; always restores config+DB, adds user data ifhasHDD; logs restore type (config+DBvsfull (config+DB+userdata))- Restore dropdown template: removed
{{if and .HasHDDData .BackupEnabled}}filter; every app gets an<option>withdata-has-hddanddata-has-dbattributes - New
#restore-type-infodiv added between snapshot selector and warnings onRestoreAppChange()JS updated: readsdata-has-hdd/data-has-dbfrom selected option, shows Hungarian restore type banner (full / config+DB / config only) with color-coded stylingrouter.gobackupSnapshots: added clarifying comment for non-HDD apps (no filter = all snapshots returned)
Fix 4: Honest UI label (
backups.html)- "Docker kötetek" renamed to "Konfiguráció" — Docker named volumes at
/var/lib/docker/volumes/are NOT in the restic backup paths; what's actually backed up is compose files + app.yaml + .felhom.yml
CSS:
.restore-infoand.restore-info-partialclasses added tostyle.cssFiles modified (9):
internal/backup/backup.go,internal/backup/appdata.go,internal/settings/settings.go,internal/backup/crossdrive.go,internal/backup/restore.go,cmd/controller/main.go,internal/web/templates/backups.html,internal/web/templates/style.css,internal/api/router.go
What was just completed (2026-02-18 session 42)
-
v0.12.6 — Cross-Drive Backup Rsync Fixes:
Context: After fixing mount-point validation and system-drive thresholds (v0.12.5), testing revealed two more rsync issues for Immich.
Fix 3: Simplified rsync destination path structure (
internal/backup/crossdrive.gorunRsyncBackup)- Old logic stripped only the first 2 path segments and kept the rest as a subpath, producing redundant nesting:
backups/rsync/immich/storage/immich/<data>instead ofbackups/rsync/immich/<data> - New logic: if app has a single mount, rsync directly into the stack folder (
backups/rsync/immich/); if multiple mounts, use each mount's leaf directory name as subfolder - Duplicate leaf names disambiguated by appending
_Nindex suffix - Loop variable changed from
_, srcMounttoi, srcMountto support the index-based disambiguation - Old nested
storage/immich/folder will remain orphaned after first run (no data loss;--deleteonly affects the target subtree)
Fix 4: Exclude app-internal DB dump files from rsync (
internal/backup/crossdrive.gorunRsyncBackup)- Apps like Immich store their own periodic DB dumps in
<data>/backups/*.sql.gz(~16 MB/day) - The controller already handles DB backups via
pg_dumpseparately — copying these again via rsync is redundant and wastes space - Added
--exclude backups/*.sql.gz,--exclude backups/*.sql,--exclude backups/*.dumpto rsync command - The
backups/directory itself and non-dump files within it are preserved
Files modified (1):
internal/backup/crossdrive.go - Old logic stripped only the first 2 path segments and kept the rest as a subpath, producing redundant nesting:
What was just completed (2026-02-18 session 41)
-
v0.12.5 — Cross-Drive Backup Validation Fix:
Root cause: Immich cross-drive backup failed with
destination /mnt/hdd_placeholder is not a mount pointbecauseValidateDestination()hard-blocked non-mount-point destinations. The/mnt/hdd_placeholderfolder is on the internal SSD (not a separate mount), so the device-ID check returned false.Fix 1: Drive-type-aware space checks in
ValidateDestination(internal/backup/crossdrive.go)onSystemDriveflag replaces the previous boolean-only mount-point check- System-drive destinations: require ≥10 GB free and <90% usage to protect OS stability
- External-drive destinations: require ≥100 MB free (original threshold)
- Updated function comment to reflect the new tiered logic
Fix 2: Aligned
CheckBackupDestinationUI thresholds for system drives (internal/system/mounts_linux.go)- Tier 4 disk checks now branch on
h.SystemDriveflag (set in Tier 3) - System drive: block at <10 GB free OR ≥90% used (matches runner enforcement); Hungarian warning messages
- External drive: warn at ≥90% used, block at ≥95% used (unchanged)
- Removed the
&& h.Severity == "ok"guard that prevented system-drive warnings from being overridden properly
Files modified (2):
internal/backup/crossdrive.go,internal/system/mounts_linux.go
What was just completed (2026-02-18 session 40)
-
v0.12.4 — Correctness & Robustness Bug Fixes (TASK.md — 15 bugs fixed):
CRITICAL fixes (data loss, panics):
- C1:
SetAppBackupBulkdata loss + nil map panic — Fixed: now updates map IN PLACE instead of replacing it, so stacks absent from the input are preserved. Added nil guard fors.AppBackup. (internal/settings/settings.go) - C2:
UpdateStackConfignil Env map panic — Added nil checkif appCfg.Env == nil { appCfg.Env = make(...) }before the field assignment loop. (internal/stacks/deploy.go) - C3:
ValidateDumpmissing scanner.Err() check — Addedif err := scanner.Err()check after the scan loop so I/O errors don't silently mark a partial dump as valid. (internal/backup/dbdump.go)
HIGH fixes (logic errors, resource leaks):
- H1:
nextDailyRunDST bug — Replacednext.Add(24 * time.Hour)withtime.Date(day+1, ...)for correct scheduling across Europe/Budapest DST transitions. (internal/scheduler/scheduler.go) - H2:
nextDailyRunrepeatedLoadLocation— Cached timezone in package-levelsync.Oncevariable;getBudapestLocation()now loaded only once. (internal/scheduler/scheduler.go) - H3:
settings.save().tmp file leak — Addedos.Remove(tmpPath)cleanup onWriteFilefailure path. (internal/settings/settings.go) - H4:
SetNotificationPrefsnil pointer panic — Added nil guard at start of function, returns error instead of panicking. (internal/settings/settings.go) - H5:
appDirSizeignoresSscanfreturn value — Now checksn != 1and returns(0, "?")on parse failure. Same fix applied togetDirSizeBytesinstacks/delete.go. (internal/backup/appdata.go,internal/stacks/delete.go) - H6:
getDirSizeBytesno timeout — Addedexec.CommandContextwith 30s timeout. Added"context"import. (internal/stacks/delete.go) - H7:
dbdump.gotmpFile not usingdefer Close— Replaced explicittmpFile.Close()call withdefer tmpFile.Close()so the file handle is released even on panic. (internal/backup/dbdump.go) - H8:
UpdateCrossDriveStatusmisleading comment — Updated comment to accurately describe the "does nothing if nil" behavior instead of claiming it "creates one if nil". (internal/settings/settings.go)
MEDIUM fixes (code quality, edge cases):
- M1: Custom
contains/containsBytesreplaced — Removed bespokecontainsBytesand simplifiedcontainsto delegate tostrings.Contains. Added"strings"import. (internal/notify/notifier.go) - M2:
scheduler.Every()doesn't validate interval — Added early return with error log ifinterval <= 0to prevent panic intime.NewTicker. (internal/scheduler/scheduler.go) - M3:
executeJobpanic recovery missingLastRun— Panic recovery defer now also setsjob.LastRun = time.Now()so the job status shows a timestamp after a panic. (internal/scheduler/scheduler.go) - M4:
logPostStartStatusgoroutine captures env by reference — Copies the env slice before launching the goroutine (envCopy). (internal/stacks/manager.go) - M5: Multiple
time.LoadLocationcalls in web package — Added package-levelgetTimezone()withsync.Onceinfuncmap.go. Replaced alltime.LoadLocation("Europe/Budapest")calls in the web package withgetTimezone(). (internal/web/funcmap.go,internal/web/handlers.go)
Files modified (8):
internal/settings/settings.go,internal/stacks/deploy.go,internal/backup/dbdump.go,internal/scheduler/scheduler.go,internal/backup/appdata.go,internal/stacks/delete.go,internal/stacks/manager.go,internal/notify/notifier.go,internal/web/funcmap.go,internal/web/handlers.go - C1:
What was just completed (2026-02-17 session 39)
-
v0.12.3 — Security & Correctness Bug Fixes (TASK.md — 33 bugs fixed):
CRITICAL fixes (data races, security vulnerabilities):
- C1: Data race in RefreshCache — Moved
m.lastDBDump.Resultsmutation insidem.mu.Lock(). Was previously mutating shared state without the lock, causing potential torn writes visible toGetFullStatus()goroutines. (internal/backup/backup.go) - C2: SnapshotHistory reversed after unlock — Moved snapshot reversal loop before
m.cachedStatus = status(inside the lock). Previously reversed afterUnlock(), som.cachedStatus.SnapshotHistorywas reversed without protection. (internal/backup/backup.go) - C3: SetStackProvider write without lock —
m.stackProvider = providernow wrapped inm.mu.Lock(). Read byresolveAppBackupPaths()concurrently. (internal/backup/backup.go) - C4: GetFullStatus shallow-copies mutable pointers —
LastDBDumpandLastBackupare now deep-copied (struct + Results slice) so callers cannot mutate shared manager state. (internal/backup/backup.go) - C5: IsSystemDisk 8-bit major mask — Replaced
>> 8 & 0xffwithunix.Major()/unix.Minor()(12-bit extraction). Also compares disk-portion of minor (groups of 16) to correctly distinguish physical disks of the same type. Addsgolang.org/x/sys/uniximport. (internal/storage/safety_linux.go) - C6: No /dev/ prefix validation on DevicePath —
FormatAndMountnow validatesDevicePathstarts with/dev/and does not contain..before any disk operations. (internal/storage/format_linux.go) - C7: Path traversal in extractName —
extractName()now rejects empty string,.,.., and names containing/or\. (internal/api/router.go) - C8: Path traversal in TargetPath — Migration API validates
TargetPathagainst registered storage paths from settings before starting migration job. (internal/web/storage_handlers.go) - C9: Path traversal in DestinationPath — Cross-drive backup config API validates
DestinationPathagainst registered storage paths whenenabled=true. (internal/api/router.go) - C10: Path traversal in ParseComposeHDDMounts —
filepath.Clean()applied before prefix check; uses separator-aware checkcleanHDD + string(filepath.Separator)to prevent${HDD_PATH}/../../etc/passwdescaping. (internal/stacks/delete.go)
HIGH fixes (logic errors, resource leaks):
- H1: ValidateDump reads entire file into memory — Replaced
os.ReadFilewithbufio.Scannerreading line-by-line. 256KB per-line buffer prevents OOM on large (500MB+) SQL dumps during 5-min cache refresh. (internal/backup/dbdump.go) - H2/H3: Double du invocation per mount + no timeout — Replaced
appDirSizeHuman()+appDirSizeBytes()with singleappDirSize()function usingexec.CommandContextwith 30s timeout. Halves subprocess calls per mount point. (internal/backup/appdata.go) - H4: Snapshot validation only checks first 100 — Replaced
ListSnapshots(100)existence check with regex validation (^[0-9a-f]{8,64}$). Allows restoring any snapshot;restic restorereturns a clear error for non-existent IDs. (internal/backup/restore.go) - H5: No pruning for cross-drive restic repos — Added
pruneResticRepo()called after each successful cross-drive restic backup (forget --keep-daily 7 --keep-weekly 4 --prune). Non-fatal — logs warning on failure. (internal/backup/crossdrive.go) - H6: Temp password file management — Reorganized temp file lifecycle: close before deferred remove, remove-on-write-error cleanup. (
internal/backup/crossdrive.go) - H7: dirSizeBytes swallows walk errors —
filepath.Walkcallback now returns errors instead ofnil, propagating permission/IO issues. (internal/backup/crossdrive.go) - H8: Non-atomic fstab write —
AppendFstabEntrynow reads existing fstab, writes to.tmp, then atomically renames. Crash-safe. (internal/storage/safety_linux.go) - H9: IsDeviceMounted naive prefix matching — After prefix check, next character must be digit (
0-9) orp(partition marker). Prevents/dev/sdbmatching/dev/sdba. (internal/storage/safety_linux.go) - H10: eMMC device mapping bug —
partitionToParentDisknow handlesmmcblk0p1 → mmcblk0andnvme0n1p1 → nvme0n1patterns. UsesLastIndex("p")with digit-suffix check before falling back toTrimRight("0-9"). (internal/storage/scan_linux.go) - H11: Data race on bytesCopied in rsync error path — Error return path in
runRsyncnow readsbytesCopiedunder mutex lock. (internal/storage/migrate.go) - H13: Path prefix match without separator — Migration source path check now uses
srcPath == req.CurrentHDDPath || strings.HasPrefix(srcPath, req.CurrentHDDPath+"/"). Prevents/mnt/hddmatching/mnt/hdd_backup/data. (internal/storage/migrate.go) - H14: DeleteStack continues after failed compose down —
docker compose downfailure now returns an error immediately, preventing deletion of files while containers are still running. (internal/stacks/delete.go) - H16: exec.Command("docker") without timeout —
syncFileBrowserMounts()now usesexec.CommandContextwith 60s timeout. (internal/web/handlers.go) - H17: SetNotificationPrefs stores caller's pointer — Deep-copies
NotificationPrefsstruct andEnabledEventsslice before storing. (internal/settings/settings.go) - H18: wipefs error silently discarded — wipefs failure logged as warning via progress channel; continues (wipefs may not be installed). (
internal/storage/format_linux.go) - H19: Orphaned fstab entry on mount failure — New
RemoveFstabEntry()function atomically removes UUID entry. Called as rollback onmountfailure andfindmntverify failure. (internal/storage/safety_linux.go,format_linux.go)
MEDIUM fixes (edge cases, code quality):
- M1: formatBytes duplicate in dbdump.go — Removed
formatBytes()fromdbdump.go; all callers (backup.go, restic.go, dbdump.go) now usehumanizeBytes()from appdata.go. (internal/backup/dbdump.go,backup.go,restic.go) - M2: Dead code .tmp suffix check — Reordered filter in
ListDumpFiles:.tmpcheck now comes before.sqlcheck to correctly skip.sql.tmptemp files (was unreachable before). (internal/backup/dbdump.go) - M3: sizeBytes() returns 0 for string types — Added
case string:tosizeBytes()usingstrconv.ParseUint. (internal/storage/scan_linux.go) - M6: Dead elapsed variable — Removed
_ = elapsed; elapsed time now shown inline in the "done" progress message. (internal/storage/migrate.go) - M7: time.LoadLocation error silently discarded — Two locations in handlers.go now handle
LoadLocationerror, falling back totime.UTC. (internal/web/handlers.go) - M10: filterSnapshotsByPaths imprecise prefix — Added
pathCovers()helper using separator-aware prefix check. Prevents/mnt/hdd_1matching/mnt/hdd_10/data. (internal/api/router.go) - M11: XSS in editStorageLabel innerHTML —
cancelEditLabel()in settings.html now uses DOM manipulation (document.createElement,.textContent) instead ofinnerHTMLfor the label text. (internal/web/templates/settings.html)
Files modified (15):
internal/backup/backup.go,internal/backup/appdata.go,internal/backup/dbdump.go,internal/backup/restore.go,internal/backup/crossdrive.go,internal/backup/restic.go,internal/storage/safety_linux.go,internal/storage/format_linux.go,internal/storage/scan_linux.go,internal/storage/migrate.go,internal/stacks/delete.go,internal/api/router.go,internal/web/handlers.go,internal/web/storage_handlers.go,internal/settings/settings.go,internal/web/templates/settings.html - C1: Data race in RefreshCache — Moved
What was just completed (2026-02-17 session 38)
- v0.12.2 — Restore Section Simplification (Bug 4 from v0.12.1 TASK.md):
- Feature: Snapshot filtering by app —
GET /api/backup/snapshots?stack={name}now filters snapshots to those whosePathsoverlap with the app's HDD mount paths. Uses prefix matching (snapshot path is prefix of required, or vice versa). NewfilterSnapshotsByPaths()helper ininternal/api/router.go. Manager gainsGetStackHDDMounts()method to expose stackProvider's mount resolution. - Feature: Auto-stop/restart on restore —
RestoreApp()now stops the app's containers before runningrestic restoreand restarts them after (even on failure). Avoids data corruption from live writes during restore. Eliminates the "Javasoljuk az alkalmazás leállítását" advisory from the UI. - Interface extension: StackDataProvider — Added
StopStack(name string) errorandStartStack(name string) errorto thebackup.StackDataProviderinterface ininternal/backup/appdata.go.stackAdapterincmd/controller/main.gowires these through tostacks.Manager. - UI simplification: Restore section — Removed confusing "Visszaállítandó útvonalak" path list (technical detail not needed by customer). Snapshot dropdown now populated per-app (filtered) with human-friendly format:
2026-02-17 hétfő 03:00 (a3f2b1). Single calm warning replacing the triple-exclamation block. Empty filtered result shows inline message instead of empty dropdown.data-pathsattribute removed from app dropdown options. - Files modified (6):
internal/backup/appdata.go,internal/backup/backup.go,internal/backup/restore.go,internal/api/router.go,internal/web/templates/backups.html,cmd/controller/main.go
- Feature: Snapshot filtering by app —
What was just completed (2026-02-17 session 37)
- v0.12.0 — Backup Page Overhaul — Unified App Backup Status & Bug Fixes:
- Bug Fix 1: Duplicate unconfigured apps —
GetFullStatus()now returns a deep copy of the cached status.CrossDriveSummary,UnconfiguredApps, andCrossDriveWarningsslices are always nil in the returned copy so the handler builds them fresh on every page load. Previously the handler appended to the cached slices, causing 3× duplication on 3 page loads. - Bug Fix 2: Misleading "drive disconnected" error — Replaced the binary
IsMountPoint || !IsWritablecheck with tieredCheckBackupDestination()validation (new ininternal/system/mounts_linux.goand stub inmounts_other.go). Tiers: path doesn't exist (critical/blocked), not writable (critical/blocked), same block device as/(warning/allowed with note about system drive), disk >95% full (critical/blocked), disk >90% (warning/allowed).isSameBlockDevice()replacesIsMountPoint()for source/dest same-device detection. Used in bothdeployHandler()andbackupsHandler()for display, and incrossdrive.gologic viaCheckBackupDestination(). - Bug Fix 3: Dead BackupEnabled toggle — Removed
settingsAppBackupHandler()from handlers.go and itsPOST /settings/app-backuproute from server.go. The toggle wrote to settings.json but nothing read it to skip apps. UI nightly backup section in deploy.html now shows an informational note instead of the toggle. - Architecture: Unified per-app backup rows — New
AppBackupRowstruct andbuildAppBackupRows()in handlers.go. Replaces old "Alkalmazás adatok" + "Másolatok másik meghajtóra" sections with a single expandable row per app showing all 3 backup layers (DB, Docker volumes, user data). Status dot: green=fully covered, yellow=warning (failed run, system drive, disk full), red=HDD data without cross-drive configured, auto=no user data. Expandable JS toggle with ▶/▼ icon. - Architecture: Sequential backup chaining — Removed independent
cross-drive-daily(03:30) andcross-drive-weekly(04:30) scheduler jobs. Cross-drive backups now run immediately after the restic backup completes (daily jobs every night; weekly jobs on Sunday). This ensures DB dump → restic → cross-drive happen in the same window for file/DB consistency on restore. - Architecture: Deploy page schedule dropdown — Removed "Csak kézi indítás" option (schedule="manual"). Two options remain: "Naponta (az éjszakai mentés után)" and "Hetente, vasárnap (az éjszakai mentés után)". Weekly option shows informational note about DB consistency implications. Existing "manual" configs treated as "weekly" in the dropdown.
- CSS added:
.app-backup-row,.app-backup-row-header,.app-backup-row-name,.app-backup-row-meta,.app-backup-row-detail,.status-dot(green/yellow/red/auto),.backup-layers,.backup-layer-row,.layer-label,.layer-badge,.layer-na,.layer-method,.layer-dest,.layer-schedule,.layer-last,.layer-unconfigured,.layer-actions,.layer-warnings,.backup-layer-warning,.btn-xs,.text-ok,.text-error. - Files modified (9):
internal/backup/backup.go,internal/system/mounts_linux.go,internal/system/mounts_other.go,internal/web/handlers.go,internal/web/server.go,internal/web/templates/backups.html,internal/web/templates/deploy.html,internal/web/templates/style.css,cmd/controller/main.go
- Bug Fix 1: Duplicate unconfigured apps —
What was just completed (2026-02-17 session 36)
- v0.11.9 — UI Polish Fixes for deploy/settings backup section:
- Fix 1: Spacing —
.deploy-cross-drivemargin-bottomincreased from1remto1.5remfor consistent spacing before deploy form. - Fix 2: Tooltip on "Módszer" — Renamed "Verziózott mentés (restic)" to "Titkosított mentés (restic)". Added info
(i)tooltip explaining rsync vs restic tradeoffs. - Fix 3: Nightly backup indicator — Replaced disabled checkbox (with confusing pointer cursor) with a non-interactive green/gray dot indicator.
- Fix 4: Progressive disclosure — Dest/method/schedule selects are disabled until "Engedélyezve" is checked. JS
toggleCrossDriveFields()enables/disables them. Backend handler updated to preserve existing config when disabling (disabled fields not submitted). - Fix 5: Emoji cleanup — Removed all emoji from
deploy.htmlbackup section (h4, warning, status, hint, stale data) andbackups.htmlcross-drive summary (status badges, schedule badge, unconfigured warning). JS callbacks also cleaned up. - CSS added:
.info-tooltip,.info-icon,.info-tooltip-text,.cross-drive-nightly-status,.nightly-status-indicator,.nightly-enabled,.nightly-disabled,.meta-badge-fail. - Files modified (4):
web/templates/deploy.html,web/templates/backups.html,web/templates/style.css,web/handlers.go
- Fix 1: Spacing —
What was just completed (2026-02-17 session 35)
- v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule, second copy on different media):
- Feature: CrossDriveBackup data model —
AppBackupPrefsextended withCrossDrive *CrossDriveBackupfield insettings.go. New methods:GetCrossDriveConfig,SetCrossDriveConfig,UpdateCrossDriveStatus,GetAllCrossDriveConfigs,GetOrCreateCrossDrivePassword. ExistingSetAppBackup/SetAppBackupBulknow preserve cross-drive config. Auto-generated restic password stored insettings.json. - Feature: CrossDriveRunner — New
internal/backup/crossdrive.go. Supports rsync (simple mirror with--delete) and restic (versioned, deduplicated, shared repo). Safety guards: destination ≠ source, mount point check, writable check, per-app concurrency lock.RunAllScheduled(ctx, schedule)iterates all apps matching the given schedule. Status (last_run, last_status, last_error, last_duration, last_size_human) persisted to settings.json after each run. - Feature: Scheduler jobs — Two new daily jobs:
cross-drive-dailyat 03:30 (for apps withschedule: daily),cross-drive-weeklyat 04:30 Sundays only (forschedule: weekly). - Feature: API endpoints — 4 new routes:
POST /api/stacks/{name}/cross-backup,POST /api/stacks/{name}/cross-backup/run,GET /api/stacks/{name}/cross-backup/status,POST /api/backup/cross-drive/run-all. - Feature: Deploy/Settings page UI — New "Biztonsági mentés" card on the deploy page for apps with HDD data. Shows nightly backup toggle (read-only link), cross-drive dropdowns (destination, method, schedule), last run status, manual trigger button. States: no other storage (info message), configured, destination unreachable (warning). Flash messages on save redirect.
- Feature: Backup page summary — New "Másolatok másik meghajtóra" section showing all configured apps with method, destination, last status, size. Warns about unconfigured apps with HDD data. Destination health warnings. "Összes futtatása most" button.
- CSS:
margin-bottom: 1.5remadded to.deploy-stale-data. New styles:.deploy-cross-drive,.cross-drive-list,.cross-drive-item,.cross-drive-header,.cross-drive-meta,.cross-drive-actions. - Files modified (10):
settings/settings.go,backup/crossdrive.go(new),backup/backup.go,api/router.go,web/handlers.go,web/server.go,web/templates/deploy.html,web/templates/backups.html,web/templates/style.css,cmd/controller/main.go
- Feature: CrossDriveBackup data model —
What was just completed (2026-02-17 session 34)
- v0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix:
- Feature: Stale data cleanup — After app data migration, the deploy/settings page now shows leftover data on previous storage paths with size info and a delete button. Two-step confirmation required before deletion. Protected paths (storage root, media, Dokumentumok, appdata) cannot be deleted. Also available immediately after migration on the migration-done page.
- Fix: FileBrowser sync after migration —
syncFileBrowserMounts()now called after successful data migration, ensuring FileBrowser mounts reflect the current storage layout. - Fix: Deploy page title — Already-deployed apps now show "Beállítások" (Settings) instead of "Telepítés" (Deploy) in both the browser page title and the
<h2>heading. - Internal: Exported
ProtectedHDDPaths()from stacks package for reuse in web handlers. - Files modified (7):
internal/stacks/delete.go,internal/web/handlers.go,internal/web/storage_handlers.go,internal/web/templates/deploy.html,internal/web/templates/migrate.html,internal/web/templates/style.css
What was just completed (2026-02-17 session 33)
- v0.11.6 — FileBrowser Auto-Mount Sync + UI Polish (3 fixes):
- Feature: FileBrowser auto-mount sync — Added
syncFileBrowserMounts()andgenerateFileBrowserCompose()tohandlers.go. After a storage path is added (via storage init wizard) or removed, the controller regenerates/opt/docker/stacks/filebrowser/docker-compose.ymlwith volume mounts for all registered paths (/mnt/hdd_1:/srv/hdd_1etc.), then recreates the FileBrowser container. Domain is read from FileBrowser's.env. If FileBrowser isn't deployed, the function silently returns. The generated compose is self-contained (no env vars). - UI Fix 1: Badge color fix —
settings.html: changed "Nincs csatolva!" (redstate-red) badge to "Rendszermeghajtón" (yellowbadge-warn). The path is on the system SSD, which isn't an error — just informational. Added.badge-warn { background: rgba(250, 204, 21, 0.15); color: #facc15; }tostyle.css. - UI Fix 2: Progress bar fix —
storage_init.html: replaced the disk-usage gradient progress bar (green→yellow→red zones, alarming at 30%) with a clean single-colorprogress-bar-taskbar. Added.progress-bar-taskand.progress-bar-task .progress-fillCSS classes tostyle.css. - UI Fix 3: Button text fix —
settings.html: "Alapértelmezett" button (reads as status, confusing) → "Legyen alapértelmezett" (clear action verb). - Files modified (5):
web/handlers.go,web/storage_handlers.go,web/templates/settings.html,web/templates/storage_init.html,web/templates/style.css
- Feature: FileBrowser auto-mount sync — Added
What was just completed (2026-02-17 session 32)
- v0.11.4 — Bugfix: Storage Initialization (FormatAndMount) — 3 bugs + 4 safety improvements:
- Bug 1 (sfdisk): Added
wipefs -abefore sfdisk; changed sfdisk input from,,,L(unsupported GPT type shorthand) to,,(default Linux GUID); added--force --wipe alwaysflags. Previous table confusing sfdisk andLtype not accepted for GPT. - Bug 2 (mount): Replaced
mount mountPath(fstab lookup — uses container's /etc/fstab, not host's) with explicitmount -t ext4 -o defaults,noatime /host-dev/sdb1 /mnt/hdd_1. fstab entry still written to/host-fstabfor host reboot persistence. - Bug 3 (mount propagation): Changed
/mntvolume in compose to long-form bind withpropagation: rshared. Also ranmount --bind /mnt /mnt && mount --make-rshared /mnton demo host. ConfirmedPropagation=rsharedindocker inspect. Mounts created inside container now propagate to host. - Safety 1 (post-mount verification): Added
findmntcheck after mount — fails with clear error if mount isn't actually visible. - Safety 2 (ASCII label): Use
req.MountName(always ASCII) for ext4-Llabel (16-byte limit). Display label (req.Label, may contain UTF-8 Hungarian chars) stays only in settings.json. - Safety 3 (smart partition): In
storageInitAPIHandler, if disk has exactly 1 empty partition (no filesystem), skip wipefs+sfdisk entirely and format existing partition directly. Handles demo sdb case (sdb1 exists, no FS). - Safety 4 (progress messages): Updated
send()calls to include command details (device paths, flags) for remote debugging via UI progress panel. - Files modified (3):
storage/format_linux.go,docker-compose.yml,web/storage_handlers.go
- Bug 1 (sfdisk): Added
What was just completed (2026-02-17 session 31)
- v0.11.3 — Bugfix: Missing sfdisk in container (fdisk package):
sfdiskis in thefdiskpackage on Debian bookworm, notutil-linux. Dockerfile hadutil-linuxbut notfdisk, sosfdiskwas missing and partitioning failed.- Added
fdiskto Dockerfile'sapt-get installlist. Updated comment to clarify which package provides what. - Verified: all six disk tools now present in container (
sfdisk,mkfs.ext4,blkid,mount,lsblk,partprobe). - Files modified (1):
Dockerfile
What was just completed (2026-02-17 session 30)
- v0.11.2 — Bugfix: /dev/sdb not accessible inside container:
- Root cause: Docker always creates a fresh tmpfs at
/devinside containers. Even withprivileged: true, the bind mount- /dev:/devis silently dropped. Block device nodes like/dev/sdbdon't exist inside the container. - Fix: Mount host
/devat/host-devinstead. Withprivileged: true, the kernel allows I/O to the device nodes regardless of path inside the container. - docker-compose.yml: Changed
- /dev:/dev→- /dev:/host-dev:rw. Also applied missingprivileged: true,/etc/fstab:/host-fstab, and/run/udev:/run/udev:roto demo node's live compose (never applied after v0.11.0). - safety.go: Added
HostDevPath = "/host-dev"constant andHostDevicePath(devPath) stringhelper (/dev/sdb→/host-dev/sdb). - format_linux.go: All device operations (os.Stat, sfdisk, partprobe, mkfs.ext4, blkid UUID) use
HostDevicePath(). - safety_linux.go:
IsSystemDisk()stats device viaHostDevicePath(). - scan_linux.go:
enrichWithBlkid()probes each partition individually (blkid -o value -s TYPE/UUID/LABEL /host-dev/sdXN) instead of batchblkid -o export(which fails when/devis Docker's minimal tmpfs). - Verified:
/host-dev/sda,/host-dev/sdb, partitions visible;blkid /host-dev/sdb1returns correct UUID/fstype/label. - Files modified (5):
storage/safety.go,storage/safety_linux.go,storage/format_linux.go,storage/scan_linux.go,docker-compose.yml
- Root cause: Docker always creates a fresh tmpfs at
What was just completed (2026-02-17 session 29)
- v0.11.1 — Bugfix: Storage Scan — System Disk Detection & FSType in Container:
- Bug 1 fix: System disk detection — Replaced mount-point string comparison (
== "/","/boot","/boot/efi") with host fstab parsing. Inside the container,lsblkreports container mount points (e.g./opt/docker/felhom-controller/data), not host mount points. NewgetSystemDiskNames()reads/host-fstab(fallback:/etc/fstab), finds system entries (/,/boot,/boot/efi,swap), resolvesUUID=entries to device paths viablkid -U, and marks parent disks as system.partitionToParentDisk()handles both standard (sda2→sda) and NVMe (nvme0n1p2→nvme0n1) naming. - Bug 2 fix: FSType enrichment —
lsblkreturns null fstype in containers (udev/blkid cache incomplete). NewenrichWithBlkid()runsblkid -o exportafter lsblk scan and fills in missingFSType,UUID,Labelper partition from direct device probing. Runs on bothAvailableDisksandSystemDisks. - Result: sda (system SSD) now correctly appears in SystemDisks; sdb (USB HDD) appears in AvailableDisks; partition fstypes (vfat/ext4/swap) correctly shown; sdb1 genuinely shows "(nincs fájlrendszer)".
- Files modified (1):
storage/scan_linux.go
- Bug 1 fix: System disk detection — Replaced mount-point string comparison (
What was just completed (2026-02-17 session 28)
- v0.11.0 — Phase C: Storage Init, Data Migration & Startup Fixes:
- Step 0: Startup ping + hub report — Controller now fires heartbeat ping, system_health ping, and hub report immediately on startup (5s delay) instead of waiting for first scheduler tick (5-15 min).
hubPusherinstance created once and reused for both startup and periodic reports. Prevents Healthchecks showing stale "Last Ping: X ago" after restarts. - Step 1-3: Storage initialization wizard — New
internal/storage/package (scan.go,format.go,safety.go,format_linux.go,safety_linux.go,scan_linux.go+ non-linux stubs).ScanDisks()vialsblk -J.FormatAndMount()with progress channel (partition via sfdisk → mkfs.ext4 → blkid UUID → fstab backup + UUID-based entry → mount → chown + subdirs). Safety guards: system disk detection via major device numbers, mount path conflict, confirmation "FORMÁZÁS" required. New wizard page at/settings/storage/init. JSON API endpoints at/api/storage/scan,/api/storage/init,/api/storage/init/status. Auto-registers storage path in settings.json after success. - Step 4-5: Data migration — New
MigrateAppData()ininternal/storage/migrate.go. Per-app "Mozgatás" button on deploy page (for deployed apps with HDD data) and settings page storage app list. Migration flow: stop app → rsync with--info=progress2progress parsing → updateapp.yamlHDD_PATH → start app. Rollback on failure (revert config + restart with original path). Old data preserved. New migration page at/stacks/{name}/migrate. JSON API at/api/storage/migrate,/api/storage/migrate/status. - Step 6: Per-app storage display — Deploy page (read-only mode) now shows "Adattárolás" section for deployed apps: current path + label, data size, free space. "Mozgatás" link shown when other storage paths exist.
- Step 7: Container setup — Added
privileged: truetodocker-compose.yml. New volume mounts:/dev:/dev,/etc/fstab:/host-fstab,/run/udev:/run/udev:ro. Docker socket changed from:roto writable.Dockerfileadds:util-linux,e2fsprogs,rsync,parted. - Storage API routing — New
/api/storage/prefix registered inmain.gobefore/api/catch-all (longer prefix takes priority in Go ServeMux).ServeStorageAPImethod on web.Server handles all storage JSON endpoints. - CSS additions —
.disk-step,.disk-step-active,.disk-step-done,.disk-progress-steps,.disk-progress-bar-wrap,.deploy-storage-infostyles. - Files created (13):
storage/scan.go,storage/scan_linux.go,storage/scan_other.go,storage/safety.go,storage/safety_linux.go,storage/safety_other.go,storage/format.go,storage/format_linux.go,storage/format_other.go,storage/migrate.go,web/storage_handlers.go,templates/storage_init.html,templates/migrate.html - Files modified (8):
main.go,web/server.go,web/handlers.go,templates/settings.html,templates/deploy.html,templates/style.css,docker-compose.yml,Dockerfile
- Step 0: Startup ping + hub report — Controller now fires heartbeat ping, system_health ping, and hub report immediately on startup (5s delay) instead of waiting for first scheduler tick (5-15 min).
What was just completed (2026-02-17 session 27)
- v0.10.0 — Phase B: Storage Management UI Polish & Health Severity Fix:
- Step 0: Health severity fix —
checkStoragePaths()mount-point check reclassified from issue (FAIL) to warning (WARN). All storage health messages translated to Hungarian. Added.monitoring-banner-warnCSS class for yellow warning banners. Prevents false FAIL status on demo/test environments where storage is intentionally on SSD. - Step 1: Success flash messages — All 4 storage handlers (add/remove/set-default/toggle-schedulable) now redirect with
?storage_msg=success&storage_detail=...query params. Settings page displays green "alert-info" flash on success. Consistent with backup page flash pattern. - Step 2: Edit storage path labels — New
SetStorageLabel()method insettings.go. NewPOST /settings/storage/labelroute + handler. Inline edit UI with ✏️ button, text input, OK/Cancel. Added.btn-ghostCSS class. - Step 3: App details per storage path — Settings page now shows expandable
<details>list per storage path with app names, sizes, and links to deploy page. NewStorageAppDetailstruct +appDetailsForPath()helper. Added CSS for.storage-app-details,.storage-app-list,.storage-app-row. - Step 4: Storage badge on stacks page — Deployed app cards show "💾 Label" badge indicating which registered storage path the app uses.
StorageLabelsmap built from deployed apps' HDD_PATH → registered storage path label lookup. Added.meta-badge-storageCSS. - Step 5: Deploy dropdown enhancements — Storage path dropdown now shows free space ("234 GB szabad").
DeployStoragePathstruct wrapsStoragePathwithFreeHuman/FreePercentfromGetDiskUsage(). JScheckStorageSpace()shows yellow warning when selected storage has <20% free. - Step 6: Filesystem & disk info — New
FSInfostruct +GetFSInfo()inmounts_linux.gousingfindmntcommand +/sys/block/sysfs reads for disk model. Settings page shows "ext4 · /dev/sdb1 · WD Elements" below disk usage bar. Non-Linux stub returns nil. - Step 7: Backup page storage context — Added
StorageLabelfield toAppBackupInfo. Backup page shows storage label badge per app by matching HDD path prefixes against registered storage paths. Uses existing.meta-badge-storageCSS. - Files modified (12):
healthcheck.go,settings.go,mounts_linux.go,mounts_other.go,appdata.go,handlers.go,server.go,settings.html,stacks.html,deploy.html,backups.html,style.css
- Step 0: Health severity fix —
What was previously completed (2026-02-17 session 26)
- v0.9.0 — Phase A: Storage Paths Foundation & Backup Toggle Fix:
- Root cause: Per-app backup toggles (v0.8.0) didn't appear because
controller.yamlhad nopaths.hdd_pathset →ParseComposeHDDMountsreturned nil. Even with global hdd_path, apps with different HDD_PATH values wouldn't match. - Core fix: Per-app HDD_PATH resolution —
stackAdapter.GetStackHDDMounts()now reads each app's ownHDD_PATHfrom itsapp.yamlenv section (Priority 1), falling back to all registered storage paths (Priority 2). Removed dependency on globalcfg.Paths.HDDPath. - Storage paths registry (
settings.json) — newStoragePathstruct with Path, Label, IsDefault, Schedulable, AddedAt. Thread-safe CRUD methods insettings.go(Get/Add/Remove/SetDefault/SetSchedulable). Multiple external storage paths supported. - Auto-discovery — On startup,
discoverHDDPaths()scans deployed apps'app.yamlforHDD_PATHvalues.AutoDiscoverStoragePaths()registers discovered paths with inferred labels. Legacycfg.Paths.HDDPathused as fallback. - Mount-point validation — New
mounts_linux.go(build-tagged):IsMountPoint()viasyscall.Stat_t.Devcomparison,IsWritable(),PathsOverlap(),GetDiskUsage()viasyscall.Statfs. Non-Linux stubs inmounts_other.go. - Settings page "Adattárolók" section — Lists registered paths with label, path, disk usage bar, app count, badges (default/active/unmounted). Actions: set default, toggle schedulable, remove (with guards). Expandable "Új adattároló hozzáadása" form with 5-step validation (exists, mount point, writable, no overlap, no duplicate).
- Deploy page storage dropdown —
pathfield type renders as<select>dropdown of schedulable storage paths. Falls back to text input with warning if no paths registered. - Health check storage monitoring —
RunHealthCheck()now acceptsstoragePathsparameter. Checks: path accessible (warning), not a mount point (issue — data writes to SSD!), disk usage ≥95% (issue) / ≥90% (warning). - Controller docker-compose.yml — Changed HDD mount from
${HDD_PATH:-/mnt/hdd_placeholder}:...:roto/mnt:/mnt:rwfor multi-storage support + restore capability. - Removed unused
hddPathparam fromDiscoverAppData()signature in backup/appdata.go. - Files created (2):
system/mounts_linux.go,system/mounts_other.go - Files modified (11):
settings.go,main.go,appdata.go,backup.go,handlers.go,server.go,settings.html,deploy.html,style.css,healthcheck.go,docker-compose.yml,report/builder.go
- Root cause: Per-app backup toggles (v0.8.0) didn't appear because
What was previously completed (2026-02-16 session 25)
- v0.8.0 — Phase 7: Storage Overview, Per-App Backup Toggles & Limited Restore:
- Storage overview on backup page — new "Tárhely áttekintés" section as first section on backup page showing SSD/HDD progress bars + backup repo stats (repo size, dump file count, snapshot count). Reuses existing
system.GetInfo()andRepoStats. - Restic password visibility — new "Titkosítási kulcs" section inside the repository card. Masked password field with show/copy buttons (JS toggle). Password synced to hub via periodic report for disaster recovery (
ResticPasswordfield added toBackupReport). - App data discovery — new
internal/backup/appdata.go:StackDataProviderinterface to avoid circular imports between backup and stacks packagesAppBackupInfo,AppDataPath,AppDockerVolumestructsDiscoverAppData()iterates deployed stacks, discovers HDD bind mounts (via adapter callingParseComposeHDDMounts), Docker named volumes (viaparseComposeNamedVolumesusing YAML parser), and DB dump status- Stack adapter in
main.goimplementsStackDataProviderusingstacks.Manager
- Per-app backup toggles — new "Alkalmazás adatok" section on backup page:
- Toggle checkbox per app (only for apps with HDD data)
- Shows HDD paths with sizes, Docker volume info, DB dump notes
POST /settings/app-backuphandler saves preferences tosettings.jsonAppBackupPrefsstruct + bulk getter/setter insettings.goRefreshCache()populatesAppDataInfoviaDiscoverAppData()
- Dynamic backup paths —
RunBackup()now includes enabled app HDD data paths:resolveAppBackupPaths()reads enabled apps from settings, resolves HDD paths via provider- Paths logged at INFO level, included in restic snapshot
BackupPathsdisplay on backup page includes app data paths
- Limited app restore — new restore section on backup page:
RestoreApp()inrestore.go: validates enabled, resolves HDD paths, validates snapshot exists, uses running mutexRestoreAppData()onResticManager: runsrestic restorewith--includeflags for specific pathsPOST /backup/restoreweb handler with confirmation flowGET /api/backup/snapshotsJSON endpoint for restore dropdown- UI: app/snapshot dropdowns, warning box, confirmation checkbox, JS-driven form submission
- Exported
ParseComposeHDDMountsfrom stacks package (was unexportedparseComposeHDDMounts) - Flash messages on backup page via query params (success/error redirects from handlers)
- CSS: New styles for storage overview grid, app backup toggles, encryption key field, restore section, flash messages
- Files created:
appdata.go,restore.go - Files modified:
backup.go,restic.go,handlers.go,server.go,backups.html,style.css,settings.go,delete.go,router.go,types.go,builder.go,main.go
- Storage overview on backup page — new "Tárhely áttekintés" section as first section on backup page showing SSD/HDD progress bars + backup repo stats (repo size, dump file count, snapshot count). Reuses existing
What was previously completed (2026-02-16 session 24)
- v0.7.2 — Fix Notification Preferences Sync (Controller → Hub):
- Two repos changed (deploy-felhom-compose + felhom.eu):
- Hub:
POST /api/v1/preferencesendpoint (hub/internal/api/handler.go):- New route in API handler: same Bearer token auth as /report and /notify
- Accepts JSON payload:
{customer_id, email, enabled_events} - Calls existing
store.SaveNotificationPrefs()— no store changes needed - Logs preference updates at INFO level
- Hub: Notification section on customer detail page (
hub/internal/web/,hub/internal/store/store.go):- New
GetRecentNotifications()store method returns last N notification_log entries handleCustomerDetail()loads NotifPrefs + RecentNotificationsjoinStringstemplate function added for event list displaycustomer.htmltemplate: new "Notifications" section showing email, events, and last 10 notification log entries (time, event, status, message)
- New
- Controller:
SyncPreferencesmethod (internal/notify/notifier.go):- New
preferencesRequeststruct for JSON payload SyncPreferences(email, enabledEvents)— synchronous POST to hub/api/v1/preferencesIsEnabled()getter for checking hub connectivity- Hungarian error messages for user-facing feedback
- New
- Controller: Sync on settings save (
internal/web/handlers.go):settingsNotificationsHandlernow callsSyncPreferencesafter saving tosettings.json- Three flash message variants: success (synced), warning (local save OK, sync failed), error (save failed)
- Local save always succeeds even if hub sync fails
- Controller: Sync on startup (
cmd/controller/main.go):- Non-blocking goroutine syncs preferences to hub when controller starts
- Only runs if hub is enabled and email is configured
- Handles hub DB rebuild recovery (re-populates preferences after hub redeployment)
- Files changed: hub (3 files: handler.go, store.go, server.go, customer.html), controller (3 files: notifier.go, handlers.go, main.go)
- Documentation: README.md updated (version, notify module, phase checklist), CONTEXT.md updated
What was previously completed (2026-02-16 session 23)
- v0.7.1 — Phase 2: Monitoring Warnings, Dashboard Alerts & Notification System:
- Three workstreams across two repos (deploy-felhom-compose + felhom.eu):
- Monitoring page "Távoli monitoring" section (
monitoring.html,handlers.go):- New section between System Overview and System Metrics showing healthcheck ping UUID status
- 5 rows: Heartbeat, System Health, DB Dump, Backup, Backup Integrity — each shows ✅ configured or ⚠️ missing
- Banner: green (all configured), yellow (some missing), red (monitoring disabled)
isPingConfigured()helper checks non-empty AND not "CHANGEME" prefix
- Dashboard alert banners (new
alerts.go,layout.html):AlertManagerstruct withRefresh()+GetAlerts()— generates alerts from health report, missing pings, backup disabled- Alert types:
Alert{ID, Level, Message, Link, LinkText}— levels: error/warning/info - Renders colored banners (red/yellow/blue) after
<main class="content">on all pages - Caps at 5 alerts with "+N more" overflow; monitoring page excludes "pings-missing" (shown in table instead)
- Refreshed every 5 min via system-health scheduler task + once at startup
- Hub notification relay (felhom.eu repo —
hub/internal/api/handler.go,hub/internal/store/store.go):POST /api/v1/notifyendpoint: Bearer auth, JSON payload (customer_id, event_type, severity, message, details)- New
customer_notificationstable (email, enabled_events JSON) +notification_logaudit table - Resend email integration: direct HTTP POST to
https://api.resend.com/emails - Hungarian email template with event details, timestamp, severity
hub.yaml.exampleupdated with notifications config section
- Controller-side notifier (new
internal/notify/notifier.go):Notifierstruct: fires HTTP POST to hub/api/v1/notify, non-blocking (goroutine)- Cooldown tracking per event type (default 6h, configurable via UI)
- Checks notification preferences (email configured + event enabled) before sending
NotifyHealthChange(): only notifies on status degradation (ok→warn, ok→fail, warn→fail)NotifyBackupFailed/NotifyDBDumpFailed/NotifyIntegrityFailedconvenience methodsSendTest()for test email flow- Wired into scheduler: system-health task calls
NotifyHealthChange(), backup tasks call failure notifiers
- Notification preferences UI (
settings.html,handlers.go):- New "Értesítések" Section C on Settings page (only shown when hub enabled)
- Email input, 4 event checkboxes (disk_warning, backup_failed, update_available, security_update)
- Cooldown hours input (default 6)
- "Mentés" + "Teszt email küldése" buttons
- Saved to
settings.jsonviaNotificationPrefsstruct (Email, EnabledEvents, CooldownHours)
- Settings persistence expanded (
settings.go):NotificationPrefsstruct with Email, EnabledEvents, CooldownHoursDefaultEnabledEvents: disk_warning, backup_failed, update_availableGetNotificationPrefs()returns defaults if nil,SetNotificationPrefs()saves atomically
- Files changed: 3 new (alerts.go, notifier.go, notify package), ~12 modified across both repos
- Deployed: Controller v0.7.1 to demo-felhom.eu, verified healthy (0 alerts on clean system)
What was previously completed (2026-02-16 session 22)
- v0.7.0 — Phase 1: Authentication, Persistence & Settings Page:
- New
internal/settings/settings.go: Shared persistence layer viasettings.jsonin the data directory. Atomic writes (tmp + rename), thread-safe withsync.RWMutex. Stores password hash overrides and DB validation cache. Graceful handling if file doesn't exist. - Auth improvements:
- Password resolution priority:
settings.json→controller.yaml→ none (open dashboard) - Startup logs which source is active:
Auth: using password from settings.json/controller.yaml/no password configured - Session duration extended to 7 days (was 24h)
?next=redirect after session expiry — returns user to the page they were on- Flash messages on login page (green info box, used after password change)
- Conditional logout link — hidden when auth is disabled (no password configured)
invalidateAllSessions()method for password change flow
- Password resolution priority:
- New Settings page (
/settings):- "Rendszer konfiguráció" section: read-only display of controller.yaml values (customer ID/name/domain, git repo/sync interval, backup enabled/schedule, monitoring, healthchecks URL, hub status, controller version)
- "Jelszó módosítás" section: form with current password, new password, confirm — validates min 8 chars, match check, bcrypt comparison
- Password saved to
settings.json, all sessions invalidated, redirect to login with flash message - Only shown if auth is enabled; otherwise shows info message to contact operator
- Sidebar update:
- "Beállítások" menu item with ⚙ icon pinned to bottom (above version/logout)
- Version and logout link separated from nav links
- Logout link conditionally shown only when auth is enabled
- DB validation persistence:
- After each successful dump, validation results saved to
settings.json(db_validationsmap keyed by filename) - Cached data survives container restarts
DBValidationCachestruct withvalidated_at,table_count,has_header,error
- After each successful dump, validation results saved to
- 10 files changed (3 new: settings.go, settings.html; 7 modified: main.go, backup.go, auth.go, handlers.go, server.go, layout.html, login.html, style.css)
- Deployed: Controller v0.7.0 to demo-felhom.eu, verified healthy
- New
What was previously completed (2026-02-16 session 21)
- v0.6.3 — Bug fixes from v0.6.2 code scan (4 minor fixes):
- Bug 1:
--hdd-pathindocker-setup.shnow usesrequire_argvalidation like all other flags. Previously,--hdd-pathas the last argument without a value would crash with a cryptic bash error underset -uinstead of a friendly message. - Bug 2:
stackAction()inlayout.htmlnow receiveseventas an explicit parameter instead of relying on the deprecated implicitwindow.event. All 10 onclick call sites indashboard.htmlandstacks.htmlupdated to passeventas first argument. - Bug 3: Page
<title>now has an em dash separator:"Vezérlőpult — Felhom.eu"instead of"VezérlőpultFelhom.eu". - Bug 4:
nextPruneLabel()infuncmap.gonow returns"ma"(Hungarian for "today") on Sunday before 4am, consistent with thenextRunLabelfunction. Previously returned the date in"2006-01-02"format. - Deployed: Controller v0.6.3 to demo-felhom.eu, verified healthy
- Bug 1:
What was previously completed (2026-02-16 session 20)
- Hub Dashboard Bugs + Backup Validation Fix (3 bugs):
- Bug 1&2 (Hub repo, felhom-hub v0.1.2): Hub timestamp parsing failure —
time.Parsewith single hardcoded format silently failed for formats returned bymodernc.org/sqlite. AddedparseSQLiteTime()that tries 6 common formats. Fixed: hub main page showing DOWN despite OK status, and report history timestamps showing 00:00:00. - Bug 3 (Controller repo, v0.6.2): Backup page showing "Hiba" for all DB validations — zero-value
DumpValidation{}(never assigned) hit the{{else}}branch in template. Three fixes:- Template: 4-branch guard (Valid → OK / Error → Hiba / zero-value → "–" with tooltip)
- Debug logging: Added
[DEBUG]and[WARN]log lines to allValidateDump()code paths - Re-validation:
RefreshCache()now cross-checkslastDBDumpresults against freshListDumpFiles()validation, healing stale in-memory state
- Deployed: Hub v0.1.2 to k3s, Controller v0.6.2 to demo-felhom
- Verified: Controller logs show
ValidateDump OKfor all 3 databases (immich: 60 tables, paperless: 67 tables, romm: 14 tables)
- Bug 1&2 (Hub repo, felhom-hub v0.1.2): Hub timestamp parsing failure —
What was previously completed (2026-02-16 session 19)
- v0.6.1 — Code Review Bugfixes (7 fixes):
- Fix 1:
http.NotFound(w, nil)→ pass actual*http.RequestindeployHandlerandappDetailHandler - Fix 2: Dashboard running/stopped counts now computed from the filtered
deployedStacksset (was counting ALL stacks including non-deployed) - Fix 3: Session cookie
Secureflag now dynamic based onr.TLS != nil || X-Forwarded-Proto == "https".SameSitechanged fromStricttoLax(Strict breaks Cloudflare Tunnel redirects) - Fix 4: Removed misleading
subtle.ConstantTimeComparefromisValidSession()(map lookup already leaks timing; comparing token to itself is meaningless). Removed unusedtokenfield fromsessionstruct. Removedcrypto/subtleimport. - Fix 5: Replaced
time.Tick()(goroutine leak) with propertime.NewTicker+donechannel incleanupSessions(). AddedClose()method to Server. Addeddone chan struct{}to Server struct. - Fix 6: Added
http.MaxBytesReader(w, req.Body, 1<<20)(1MB limit) todeployStack,updateOptionalConfig,deleteStackAPI handlers vialimitBody()helper. - Fix 7: Cached
time.LoadLocation("Europe/Budapest")once at top oftemplateFuncMap(), removed 5 per-functionLoadLocationcalls (timeAgo, fmtTime, fmtTimeShort, nextRunLabel, nextPruneLabel). - Post-fix verification: All 4 grep checks pass (0 results for NotFound(w,nil), ConstantTimeCompare, time.Tick(, Secure:.*true).
go vet ./...clean. - Controller version: v0.6.1 — deployed and verified on demo-felhom.eu
- Fix 1:
What was previously completed (2026-02-16 session 18)
- v0.6.0 — Healthcheck Implementation + Central Push + Hub Dashboard:
- Part 1 — Healthcheck enhancements (controller-side):
- Added
heartbeatping — lightweight "I'm alive" signal every 5 min (no logic, just ping) - Added
backup_integrityping — weeklyrestic checkon Sunday 04:00, pings healthchecks with result - Added
HeartbeatandBackupIntegrityfields toPingUUIDsConfig - Added
RunIntegrityCheck()to backup Manager (calls restic Check(), updates lastCheckTime/lastCheckOK, pings) - Updated
controller.yaml.examplewith new monitoring ping_uuids - Created
monitoring/DEPRECATED.mdfor legacy bash monitoring scripts
- Added
- Part 2 — Central hub reporting (controller-side):
- New
internal/report/package: types.go (Report struct), builder.go (BuildReport), pusher.go (HTTP push) - Report builder gathers data from all subsystems: system info (via metrics.GetStaticInfo + system.GetInfo), container stats (via metricsStore.QueryContainerSummary), backup status (via backupMgr.GetFullStatus), health (via monitor.RunHealthCheck), stacks (via stackMgr.GetStacks)
- Report pusher: POST JSON to hub with Bearer token auth, 3 retries with 5s backoff, never fails caller
- Added
HubConfigto config.go (enabled, url, api_key, push_interval) - Wired hub reporting into scheduler (configurable interval, default 15m)
- Hub reporting disabled by default (hub.enabled: false)
- New
- Part 3 — Hub service (felhom.eu repo, new
hub/subfolder):- Full Go service:
cmd/hub/main.go,internal/api/handler.go,internal/store/store.go,internal/web/server.go - SQLite store with WAL mode, auto-migration, denormalized fields for fast queries
- REST API: POST /api/v1/report (Bearer token auth), GET /api/v1/customers, GET /api/v1/customers/{id}, GET /api/v1/customers/{id}/history
- Dark theme dashboard (English): multi-customer overview table with status indicators, customer detail page with system/storage/containers/backup/health sections
- Color coding: green (OK, <30min), yellow (warn or 30-60min), red (fail or >60min)
- K8s manifest: Deployment + Service + Ingress for hub.felhom.eu in felhom-system namespace
- Dockerfile, Makefile, hub.yaml.example config
- 90-day report retention with daily auto-prune
- Full Go service:
- Controller version: v0.6.0 — deployed and verified on demo-felhom.eu (9 scheduler jobs, all new jobs registered)
- Manual steps remaining for Viktor (Part 4 of TASK.md):
- Create 5 healthcheck checks on status.felhom.eu (heartbeat, system-health, db-dump, backup, backup-integrity)
- Update controller.yaml on demo-felhom with real UUIDs
- Build and deploy felhom-hub to k3s cluster
- Configure hub.felhom.eu DNS in Cloudflare
- Enable hub reporting on demo-felhom controller.yaml
- Part 1 — Healthcheck enhancements (controller-side):
What was previously completed (2026-02-16 session 17)
- v0.5.4 — Monitoring Page Frontend Fixes (4 bugs, frontend-only):
- Bug 1: Tooltip "Invalid Date" —
items[0].parsed.xunreliable across Chart.js versions. Fixed tooltip callback to useitems[0].raw.x(direct {x,y} data access) withparsed.xas fallback. - Bug 2: Charts fill full width regardless of data density —
setChartXBounds()settingmin/maxat runtime was ignored because the scale was created without them. Fixed by includingmin: now - defaultRangeMs, max: nowin the initialchartOpts()options. Now "7 nap" shows full 7-day x-axis with data clustered on the right. - Bug 3: Sysinfo values not consistently right-aligned —
.sysinfo-gridusedauto-fillcreating variable-width cells. Fixed to1fr 1fr(fixed 2-column). Addedalign-items: baseline,gap: 1rem,white-space: nowrapon labels,font-weight: 600+word-break: break-wordon values. Removed redundant<style>block from monitoring.html (styles now in style.css). - Bug 4: Charts overflow on mobile — Added
min-width: 0on.chart-box(critical CSS grid fix),overflow: hidden+max-width: 100%on.chart-wrapand.chart-wrap-bar,max-width: 100%on canvas. - Controller version: v0.5.4 — deployed and verified on demo-felhom.eu
- Bug 1: Tooltip "Invalid Date" —
What was previously completed (2026-02-16 session 16)
- v0.5.1 — Monitoring Page Bugfixes:
- Bug 1: Hostname —
os.Hostname()returns the container ID inside Docker. Fixed by mounting/etc/hostname:/host/etc/hostname:roand reading it first insysinfo.go. Now showsdemo-felhom. - Bug 2: Tooltip timestamps — Chart.js tooltip callback used
items[0].parsed.x(category index 0,1,2...) instead ofitems[0].label(actual timestamp). Index 0 worked by accident (0 || labelfalls through), but all other points showed 1970-01-01. - Bug 3+4: Default range + empty charts — Default range was
24hbut new system had only minutes of data. Changed to1hdefault for both system and container detail charts. Movedactiveclass to "1 óra" button. - Controller version: v0.5.1 — deployed and verified on demo-felhom.eu
- Bug 1: Hostname —
What was previously completed (2026-02-16 session 15)
- v0.5.0 — Backup Bugfixes + Monitoring Page with Metrics Store:
- Task 1: Fixed "Helyi mentés" showing "–" after restart —
GetFullStatus()now synthesizesLastBackupfromSnapshotHistoryandLastDBDumpfromDumpFileson disk when the in-memory values are nil (e.g., after controller restart). Dashboard handler also updated to useGetFullStatus()instead ofGetStatus()for consistent behavior. - Task 2: Verified backup page caching — Already implemented in v0.4.7 (
RefreshCache, scheduler job,AfterBackupcallback). No changes needed. - Task 3: New Monitoring Page ("Rendszermonitor") — Full system monitoring subsystem:
- SQLite metrics store (
internal/metrics/store.go,types.go): WAL-mode SQLite viamodernc.org/sqlite(pure Go, no CGO). Stores system metrics (CPU%, memory, temperature, load) and container metrics (CPU%, memory, net/block I/O) with timestamp. Downsampled queries via bucket-basedGROUP BYfor Chart.js. 30-day auto-prune via daily scheduler job at 04:00. - Metrics collector (
internal/metrics/collector.go): Background goroutine collects system + container metrics every 60 seconds. System data fromsystem.GetInfo(), container data fromdocker stats --no-streamwith tab-separated format parsing. - System info provider (
internal/metrics/sysinfo.go,sysinfo_other.go): Reads hostname, OS, kernel, CPU model/cores, uptime from/procfilesystem. Linux-specific with build-tag fallback for cross-compilation. - REST API endpoints (4 new routes in
router.go):GET /api/metrics/system(time-series with range presets),GET /api/metrics/containers/summary(current stats),GET /api/metrics/containers/{name}(per-container time-series),GET /api/metrics/sysinfo(static system info). - Monitoring page template (
monitoring.html): 5 sections — System Overview (sysinfo via API), System Metrics Charts (4 line charts: CPU, Memory, Temperature, Load in 2×2 grid), Container Resources (2 horizontal bar charts: CPU% and Memory), Per-container Detail (click to expand with historical charts), Storage (server-rendered progress bars). Time range selectors (1h/6h/24h/7d/30d). Auto-refresh every 60s. - Chart.js 4.4.7 embedded locally (offline environments, ~200KB UMD), dark theme configuration matching site design.
- CSS: ~100 lines added for monitoring page (
.monitor-card,.charts-grid,.chart-box,.container-charts-row,.storage-bars, responsive rules). - Wiring: 4th sidebar nav item "Rendszermonitor", metrics DB path in named volume (
data/metrics.db),/etc/os-release:/host/etc/os-release:rovolume mount in docker-compose.yml, Dockerfile updated togolang:1.24-bookworm(required bymodernc.org/sqlite),go.modupgraded togo 1.24.0.
- SQLite metrics store (
- Controller version: v0.5.0 — deployed and verified on demo-felhom.eu (metrics collecting, 16 containers reporting, sysinfo showing Intel N100 correctly)
- Task 1: Fixed "Helyi mentés" showing "–" after restart —
What was previously completed (2026-02-16 session 14)
- v0.4.7 — Protected Stack Detail Pages + Backup Page Caching:
- Protected stacks clickable —
data-hrefgating changed from{{if not .Protected}}to{{if .Meta.Slug}}on bothstacks.htmlanddashboard.html. Protected stacks with.felhom.yml(i.e. a slug) are now clickable, linking to/apps/{slug}. Stacks without.felhom.ymlremain non-clickable. - "Részletek" button for protected stacks — Protected stack action section in
stacks.htmlnow shows a "Részletek" link when the stack has a slug, next to the restart button. - FileBrowser
.felhom.ymlresources — Addedresourcessection (mem_request: 128M, mem_limit: 256M, pi_compatible: true, needs_hdd: true) to bothinstall_filebrowser()indocker-setup.shand manually on the demo node. FileBrowser detail page now shows memory/Pi/HDD badges. - Backup page caching —
GetFullStatus()no longer runs expensive subprocess calls (restic stats, docker inspect, disk listing) on every page load. Instead, a newRefreshCache()method runs these in the background:- Every 5 minutes via
backup-cachescheduler job - After each successful backup via
AfterBackupcallback - On startup via a goroutine (non-blocking)
- Every 5 minutes via
GetFullStatus()returns the cachedFullBackupStatusinstantly, updating only dynamic fields (running flag, next run times, snapshot history). Falls back to a minimal status if cache hasn't populated yet.- Controller version: v0.4.7 — deployed and verified on demo-felhom.eu
- Protected stacks clickable —
What was previously completed (2026-02-16 session 13)
- v0.4.6 — MariaDB Validation Fix + Dashboard & Protected Stack UX:
- Bugfix: MariaDB dump validation false positive — MariaDB 11.4+ prepends
/*M!999999\- enable the sandbox mode */before the dump header comment.ValidateDump()now scans the first 10 lines for the expected header pattern instead of just checking line 1. Accepts-- MariaDB dump,-- MySQL dump,-- mysqldumpfor MariaDB and-- PostgreSQL database dumpfor PostgreSQL. - Dashboard shows deployed apps only —
dashboardHandler()filters to deployed + protected stacks only. Non-deployed apps remain on the Alkalmazások page. Section heading changed to "Telepített alkalmazások".TotalCountstat card still shows all 52 apps. - Protected stack restart button — Protected stacks (traefik, cloudflared, felhom-controller, filebrowser) now show an "Újraindítás" restart button when operational, on both dashboard (compact ↻) and Alkalmazások page (full button). "Védett" / "Védett rendszerkomponens" badge still shown.
- API protection guard — Centralized guard in
actionStack()blocks all actions exceptrestarton protected stacks (HTTP 403). Defense-in-depth:StopStack()andDeleteStack()retain their own guards. - FileBrowser
.felhom.yml—install_filebrowser()indocker-setup.shnow creates.felhom.ymlwithsubdomain: filesmetadata, so the controller shows thefiles.DOMAIN ↗URL link. Manually created on demo node. - Controller version: v0.4.6 — deployed and verified on demo-felhom.eu
- Bugfix: MariaDB dump validation false positive — MariaDB 11.4+ prepends
What was previously completed (2026-02-16 session 12)
- v0.4.5 — Dedicated Backup Page ("Biztonsági mentés"):
- New
/backupspage with full backup system visibility — 5 sections:- Status overview cards: Local backup status (green/gray), remote placeholder (gray), DB count, repo size
- Schedule section: DB dump/restic/prune schedule with next-run times, last backup time + duration, retention policy, "Mentés most" button
- Database table: Lists all discovered DBs with type badge (PostgreSQL/MariaDB), dump file size, last dump time, validation (table count), status
- Snapshot history table: Last 20 snapshots with ID, time, data added, files new/changed
- Repository info card: Path, size, snapshot count, integrity check status, backed-up paths list, remote copy placeholder
- Backend extensions:
SnapshotRecordtype + ring buffer (20 entries) in Manager for per-snapshot statsDumpValidation— scans dump files for CREATE TABLE statements, validates header and file sizeValidateDump()runs after each successful dump inDumpOne()ListDumpFiles()scans dump directory for existing.sqlfiles (fallback when in-memory results empty)ListSnapshots()on ResticManager — returns all snapshots from restic (newest first)GetFullStatus()on Manager — single call returns everything the page needsLoadSnapshotHistory()populates history from restic on startup (without delta stats)- Restic check result tracking (
lastCheckTime,lastCheckOK) NextDailyRun()exported from scheduler for next-run time calculation
- Server wiring:
Serverstruct now holds*scheduler.SchedulerNewServer()accepts scheduler parameter/backupsroute +backupsHandler()in handlers.go
- New template functions (
funcmap.go):timeAgo,fmtTime,fmtTimeShort,dbTypeLabel,nextRunLabel,pruneLabel,nextPruneLabel,fmtDuration,fmtBytes,shortID - Navigation: Sidebar now has 3 items (Vezérlőpult, Alkalmazások, Biztonsági mentés)
- Dashboard: Backup card title is now a clickable link to
/backups - Auto-refresh: Page polls
/api/backup/statusevery 3s during backup-in-progress, reloads when complete - CSS: Full dark-theme styles for schedule card, database table, snapshot table, repository card, validation badges, DB type badges, empty state
- Controller version: v0.4.5 — deployed and verified on demo-felhom.eu (2 historical snapshots loaded)
- New
What was previously completed (2026-02-15 session 11)
- v0.4.1 — App Filtering + Bugfixes:
- Filter bar on Alkalmazások page: Four pill-shaped filter buttons (Mind/Futó/Leállítva/Telepíthető) with live count badges computed from DOM. Filters stack cards via
display: none, updates URL with?filter=runningviahistory.replaceState. Reads filter from URL on page load for deep-linking support. - New
filterCategorytemplate function (funcmap.go): Maps container state + deployed flag to filter categories (running/stopped/available). Each stack card gets adata-filter-stateattribute for client-side filtering. - Clickable dashboard stat cards: Stat cards (Futó/Leállítva/Összes) changed from
<div>to<a>withhreflinking to/stacks?filter=running,/stacks?filter=stopped,/stacksrespectively. Hover effect with translateY + box-shadow. - docker-compose.yml synced to demo node: Fixed the stale compose file that still had
dashboard.${DOMAIN}Traefik label (from pre-v0.3.0). Now uses correctfelhom.${DOMAIN}label +/sys:/host/sys:romount. - Controller version: v0.4.1 — deployed and verified on demo-felhom.eu
- Remaining manual tasks for Viktor (Task 2 & 3 from TASK.md):
- Verify
felhom.demo-felhom.euresolves correctly (Cloudflare Tunnel public hostname may need updating fromdashboard.*tofelhom.*) - Update Pi-hole local DNS if applicable
- Enable backup in
controller.yamlon demo node (backup.enabled: true) - Create
/srv/backupsdirectories on demo node
- Verify
- Filter bar on Alkalmazások page: Four pill-shaped filter buttons (Mind/Futó/Leállítva/Telepíthető) with live count badges computed from DOM. Filters stack cards via
What was previously completed (2026-02-15 session 10)
- v0.4.0 — Monitoring & Health + Backups (Phase 2 & 3):
- Central job scheduler (
internal/scheduler/scheduler.go):- Replaces ad-hoc goroutines in main.go with a unified scheduler
Every(name, interval, fn)for periodic jobs,Daily(name, timeStr, fn)for scheduled tasks- Panic recovery, skip-if-running, quiet mode for high-frequency jobs (≤30s)
- Daily jobs use
Europe/Budapesttimezone withtime.Timerfor DST correctness - Graceful shutdown with 30s timeout for running jobs
- CPU usage collector (
internal/system/cpu_linux.go):- Background goroutine samples
/proc/statevery 5s, computes delta-based CPU % - Platform stubs for non-Linux in
cpu_other.go
- Background goroutine samples
- Temperature & load metrics (
internal/system/info_linux.go):- Reads
/proc/loadavgfor 1/5/15 min load averages - Reads thermal zones from
/host/sys/class/thermal/(Docker mount) with/sys/fallback - Handles millidegree values, picks highest zone, with hwmon fallback
- Reads
- Healthchecks.io pinger (
internal/monitor/pinger.go):- HTTP ping client for Healthchecks.io-compatible endpoints
- POST to
/ping/{uuid}(success),/fail(failure),/start(started) - 10s timeout, 3 retries with 2s backoff, skips CHANGEME UUIDs
- System health checks (
internal/monitor/healthcheck.go):- Checks disk, memory, CPU, temperature, Docker reachability, protected containers
- Returns HealthReport with status "ok"/"warn"/"fail" + formatted message for pings
- Database dump engine (
internal/backup/dbdump.go):- Auto-discovers PostgreSQL/MariaDB containers via
docker ps+docker inspect - Dumps via
docker exec pg_dump/mariadb-dumpwith 5min timeout - Atomic writes (
.tmp→.sql), empty file detection, stale temp cleanup
- Auto-discovers PostgreSQL/MariaDB containers via
- Restic integration (
internal/backup/restic.go):- Auto-generates repository password (32 random bytes, base64url)
- Init, snapshot (JSON output), prune, check, stats, latest snapshot
- Stale lock detection with automatic unlock + retry
- Backup orchestrator (
internal/backup/backup.go):- DB dumps + restic snapshots, weekly prune on Sundays
- Thread-safe running flag, Healthchecks.io pings with results
RunFullBackup()for manual trigger (sequential: dumps → snapshot)
- Wiring updates:
main.go: scheduler-based job registration, cpuCollector lifecycle, pinger + backupMgr initapi/router.go:GET /api/backup/status,POST /api/backup/runweb/server.go+handlers.go: pass cpuCollector to GetInfo(), backup status on dashboardfuncmap.go:tempColor,fmtTemp,fmtLoadtemplate functions
- Dashboard UI enhancements:
- CPU usage bar with load average display below
- Temperature with colored indicator dot (green/yellow/red at 60°/75°C)
- Backup status card: last run time, DB count, repo size/snapshots
- "Mentés most" button triggers manual backup via API
- Config updates:
controller.yaml.example: addedsystem_health_interval,hdd_path,system.reserved_memory_mbdocker-compose.yml: added/sys:/host/sys:romount for temperature readingrestic_password_filedefault changed todata/subdir (auto-generated in named volume)
- Central job scheduler (
- Controller version: v0.4.0 — deployed and verified on demo-felhom.eu
What was previously completed (2026-02-15 session 9)
- v0.3.0 — Structural refactoring (templates + server split + domain rename):
- Templates: go:embed migration — moved all 7 HTML templates + CSS from Go string constants to individual files in
internal/web/templates/. Createdembed.gowith//go:embeddirective. Template loading now usesParseFS()instead ofParse(). CSS served from embed.FS viaReadFile(). Zero runtime file dependencies — still compiled into the binary. - Server decomposition — split monolithic
server.go(540 lines) into focused files:auth.go: session struct, auth middleware, login/logout handlers, session managementhandlers.go: page handlers (dashboard, stacks, logs, deploy, app detail)funcmap.go: template FuncMap with 14 custom functionsserver.go: Server struct, NewServer, loadTemplates (3-liner), ServeHTTP routing, render helper, static file serving
- Domain rename — controller subdomain changed from
dashboard.*tofelhom.*in Traefik labels and setup script - Documentation updated — CLAUDE.md, README.md, CONTEXT.md all reflect new file structure
- Reminder for Viktor: Update Cloudflare Tunnel public hostname (
dashboard.demo-felhom.eu→felhom.demo-felhom.eu) and Pi-hole DNS if needed
- Templates: go:embed migration — moved all 7 HTML templates + CSS from Go string constants to individual files in
- Controller version: v0.3.0
What was previously completed (2026-02-15 session 8)
- FileBrowser as infrastructure service:
- Created
scripts/hdd-setup.sh(adapted from deploy-portainer) — sets up HDD folder structure withDokumentumokuser dir - Created
scripts/docker-setup.sh(adapted from deploy-portainer) — installs Docker, Traefik, FileBrowser as infra services - Added
filebrowserto protected stacks incontroller.yaml.example - Removed
templates/filebrowser/from app-catalog-felhom.eu (no longer a catalog app)
- Created
- Orphan stack detection and deletion:
- Added
Orphanedfield to Stack struct +getCatalogTemplateSlugs()helper - Orphan detection in
ScanStacks()— deployed stacks with no matching catalog template marked as orphaned - New
delete.go:DeleteStack()(compose down + HDD cleanup + dir removal),GetStackHDDData(),parseComposeHDDMounts() - Safety: protected HDD paths (root, media, storage, Dokumentumok, appdata) can never be deleted
- New API endpoints:
DELETE /api/stacks/{name}andGET /api/stacks/{name}/hdd-data - UI: orange "Elavult" badge on orphaned stacks, "Törlés" button, delete confirmation modal
- Modal shows HDD data paths/sizes, checkbox for "Felhasználói adatok törlése a merevlemezről"
- Hides "Frissítés" and "Részletek" buttons for orphaned stacks
- Added
- Verified: 1 orphaned stack detected on startup (filebrowser — now infra, removed from catalog)
- Controller version: v0.2.15
Previously completed (2026-02-14 session 7)
- Fixed YAML parse error in romm
.felhom.yml(app-catalog repo):- Root cause: Hungarian opening quote
„(U+201E) paired with ASCII"(0x22) inside YAML double-quoted strings terminated the string prematurely - Affected lines:
help_textfor IGDB Client Secret and SteamGridDB API Key fields - Fix: escaped inner ASCII double quotes with
\"in the YAML strings - This caused
LoadMetadata()to silently fail and return empty defaults for ALL romm metadata (tagline, resources, category — everything)
- Root cause: Hungarian opening quote
- Added error logging to
LoadMetadata()inmetadata.go:[ERROR]log on YAML parse failure (was silently swallowed — critical bug)- Temporary
[DEBUG]log used for diagnosis, then removed
- Fixed deploy command in CLAUDE.md:
sedpattern now targets onlyimage:lines (was matching service name too, breaking YAML)- Added
sudofor both sed and docker compose (directory is root-owned)
- Controller version: v0.2.14
Previously completed (2026-02-14 session 6)
- Bug fix: App info logo SVG rendering —
.app-info-logoCSS intemplates.go:- Added
min-width,min-height,max-width,max-height: 80pxandoverflow: hidden - Prevents SVG images with explicit dimensions or no viewBox from overflowing container
- Logo now reliably renders at 80x80 regardless of SVG intrinsic size
- Added
- Controller version: v0.2.12
Previously completed (2026-02-14 session 5)
- App detail/info pages — new feature:
- New route:
GET /apps/{slug}renders a full info page (was redirect to deploy page) - Hero section with logo, tagline, resource badges
- Screenshots section (graceful — hidden via
onerrorif assets don't exist) - Info cards: use cases, first steps, prerequisites, default credentials, docs link
- Optional config form with AJAX save (POST
/api/stacks/{name}/optional-config) - New
.felhom.ymlfields:app_info(tagline, use_cases, first_steps, prerequisites, default_creds, docs_url) andoptional_config(groups of env var fields) - New structs in
metadata.go:AppInfo,OptionalConfigGroup,OptionalConfigField UpdateOptionalConfigindeploy.go: saves optional env vars toapp.yaml, restarts deployed stacks withdocker compose up -dto pick up new env vars- Navigation updated: stack cards on dashboard/stacks pages now link to
/apps/{slug}, deploy page has "Részletek" link back to info page
- New route:
- RoMM metadata updated (app-catalog repo):
- Full
app_infosection: tagline, 5 use cases, 6 first steps, 3 prerequisites, default creds, docs URL - 6 optional config fields for metadata providers: IGDB (client_id + secret), SteamGridDB, ScreenScraper (user + password), MobyGames
- docker-compose.yml updated with SCREENSCRAPER_USER, SCREENSCRAPER_PASSWORD, MOBYGAMES_API_KEY env vars
- Display name fixed: "ROMM" → "RomM"
- Full
- Controller version: v0.2.11
Previously completed (2026-02-14 session 4)
- Fixed deploy race condition in
internal/stacks/deploy.go:- In-memory
Deployedflag now set BEFOREdocker compose up -d(compose up can take 30-60s for image pulls) - On failure: both in-memory state and disk (app.yaml) are reverted
- Eliminates stale "Telepítés" button during long compose operations
- In-memory
- Added
checkBeforeDeploy()JS guard ininternal/web/templates.go:- Telepítés buttons on Vezérlőpult and Alkalmazások pages now fetch live state from
/api/stacks/{name}before navigating - If app is already deployed (e.g., another tab deployed it), shows alert and reloads page instead of navigating to deploy form
- Catches stale UI state gracefully
- Telepítés buttons on Vezérlőpult and Alkalmazások pages now fetch live state from
Previously completed (2026-02-14 session 3)
- Enhanced debug logging across all stack operations in
internal/stacks/:- Operation timing: All stack ops (start, stop, restart, update, deploy) now log elapsed time
- Post-start container state check: Async goroutine after start/restart/update/deploy
- Image pull detection: Checks local images before deploy/update (debug level)
- GetLogs/ScanStacks improvements: Byte count logging, deployed/available counts
- All verbose checks gated on
cfg.Logging.Level == "debug"; timing always at INFO
- UI improvements in
internal/web/templates.goandserver.go:- Memory bar fix on deploy page: Bar segments now always visible (min-width: 3px), new app segment uses translucent green with distinct border for clear visual separation from committed memory
- Clickable app cards: Cards on Vezérlőpult and Alkalmazások pages are now clickable (navigates to deploy/detail page). Uses
data-hrefattribute + delegated click handler. Protected stacks excluded. Actions area (buttons, state labels) excluded from click-to-navigate - Live-scrolling logs: Logs page now auto-refreshes every 3s via AJAX polling (
?raw=1returns plain text). Fixed-height container (70vh) with auto-scroll to bottom. Pulsing green "Élő" indicator. Pause/resume toggle ("Szüneteltetés"/"Folytatás"). User scroll position preserved when scrolled up to read history - Deployment progress UI: Deploy button no longer shows alert+redirect immediately. Instead shows 3-step progress panel: config saved → containers starting → app initializing. Polls
GET /api/stacks/{name}every 3s to track actual container health state. Handles running (auto-redirect), starting (keep polling), unhealthy (warning), exited (error), and 120s timeout. Shows elapsed time counter
- Mealie healthcheck fix (app-catalog-felhom.eu):
wget --spiderreplaced with Python TCP socket check — mealie image doesn't include wgetstart_periodincreased to 60s (DB migrations take ~40s on first start)
- Healthcheck audit: filebrowser (Alpine, has BusyBox wget — OK), stirling-pdf (Ubuntu, has wget — OK)
Previously completed (2026-02-15 session 2)
- Phase 4: Git Sync + App Catalog Audit — major milestone
- Git sync module (
internal/sync/sync.go):- Clones/pulls app-catalog-felhom.eu repo to local cache on startup
- Periodic sync based on
git.sync_interval(default 15m) - Copies
docker-compose.yml+.felhom.ymlto stacks dir (never overwritesapp.yaml/.env) - SHA-256 content comparison — only writes changed files
- Triggers
ScanStacks()after sync so dashboard updates immediately - Uses
os/execgit CLI — no Go git library dependency
- Manual sync button ("Sablonok frissítése") on Alkalmazások page:
POST /api/syncendpoint with 30s debounce- Toast notification shows result (success/failure/what changed)
- Auto-reloads page if new apps or updates detected
- Sync status added to
/api/system/info(last_sync, last_status, syncing flag) - .felhom.yml files created for all 10 apps (paperless-ngx already had one):
- actualbudget, docmost, filebrowser, homebox, immich, mealie, romm, stirling-pdf, vaultwarden
- All follow the same format: display_name, description, category, subdomain, resources, deploy_fields
- Docker Compose templates audited and fixed for all 10 apps:
- Fixed
{{DOMAIN}}→${DOMAIN}syntax in homebox, mealie, romm, stirling-pdf - Fixed
{{HDD_PATH}}→${HDD_PATH}in romm - Added
deploy.resources.limits.memoryto all services across all templates - Added
TZ=Europe/Budapestto all sidecar services (postgres, redis, mariadb) - Added healthcheck to romm main service
- Added
romm-rediscondition: service_healthy(wasservice_started) - Standardized header comment blocks across all templates
- Fixed
- Documentation updated: app-catalog README, CLAUDE.md, CONTEXT.md
Previously completed (2026-02-15 session 1)
- Memory validation during deployment:
- Pre-deploy memory check: compares
mem_requestsum against usable system RAM - Hard block if requests exceed usable memory (total - 384MB reserved)
- Soft warning if
mem_limitsum exceeds total RAM (overcommit OK for limits) ParseMemoryMB()supports "500M", "1G", "1.5G", "1024" formatsCommittedMemory()sums requests/limits across all deployed stacks- Memory summary bar shown on deploy page before user clicks deploy
system.reserved_memory_mbconfigurable in controller.yaml (default: 384)
- Pre-deploy memory check: compares
- Display:
~prefix on mem_request in UI badges (display-only, exact value stored) - Felhom.eu logo replaced text logos in sidebar and login page with actual SVG logo
- Logo SVG embedded as Go string constant, served at
/static/felhom-logo.svg
- Logo SVG embedded as Go string constant, served at
Previously completed (2026-02-14)
- System info bar on Vezérlőpult dashboard: RAM, SSD, and optional HDD usage
- Progress bars with color coding (green < 70%, yellow 70-85%, red > 85%)
- New
internal/systempackage reads/proc/meminfo+syscall.Statfs - Platform-specific: Linux impl + non-Linux stub (build tags)
- Hungarian labels: "Memória", "SSD tárhely", "Külső HDD"
- Docker Compose memory limits on paperless-ngx template:
- paperless-webserver: 768M, postgres: 256M, redis: 128M
- Added
mem_limitfield to.felhom.ymlResourceHints (total: 1152M)
/api/system/infoendpoint now returns live system metrics (was customer info)- Config: Added
paths.hdd_pathfor external HDD monitoring - Controller image builds via build.sh, pushes to Gitea container registry
Previously completed (2026-02-13)
- Built the entire felhom-controller from scratch (Go, no frameworks)
- Debugged and fixed 7 issues during first real deployment:
- Password validation (empty passwords accepted)
- In-memory Deployed flag not updating after deploy
- Health-aware state parsing (starting/unhealthy detection)
- Random card ordering (Go map iteration)
- "Részletek" button redirect for deployed apps
- Paperless OCR language installation (LANGUAGES vs LANGUAGE env var)
- Documentation: restart vs up -d for image updates
What's next (priorities)
- Test per-app backup — enable backup for Paperless-ngx HDD data, trigger manual backup, verify restic snapshot includes HDD paths
- Test restore — restore app data from snapshot, verify file recovery (now possible with /mnt:rw mount)
- Deploy Immich — tests HDD path + secrets + multi-storage (biggest real-world test)
- Add
app_info+optional_configto more apps (Immich, Mealie, Vaultwarden) - Test on Raspberry Pi (pi-customer-1)
- Self-update mechanism
- Hub alerting (webhook to Healthchecks for stale customers)
- Docker volume backup (mount
/var/lib/docker/volumes:rointo controller)