# felhom-controller

**Central management container for Felhom home servers.** The **in-guest controller**: one per
customer LXC, Docker-only, Hungarian web dashboard for managing the customer's app stacks, app-data
backups, monitoring and notifications. All Proxmox/disk operations are delegated to the host agent
(`felhom-agent`).

**Current version: v0.59.0**

> ## Documentation has moved (and is now code-verified)
>
> The **authoritative architecture & feature documentation** now lives in the central docs home:
> **[`felhom.eu/documentation/controller/`](../../felhom.eu/documentation/controller/README.md)** —
> module map, deploy & stack lifecycle, backup architecture, storage/monitoring/metrics, and
> auth/hub/sync/integrations. Those docs are grounded in current source (v0.59.0).
>
> **Quick build & deploy** is in the "Build & Deploy" section below and in the repo `CLAUDE.md`
> (authoritative for the workflow). NOTE the demo controller runs in an LXC guest (9201) under the
> **bootstrap-managed** mechanism (`/etc/felhom-controller-image` + `felhom-controller-bootstrap.service`),
> not the bare-metal `/opt/docker` compose path some sections below still describe.
>
> The prose below this banner is **retained legacy reference** and may lag the central docs — when they
> disagree, the central docs win. (Some sections still mention restic / pre-8C disk handling that has
> since moved to the host agent.)

---

## Table of Contents

- [Architecture](#architecture)
- [Features](#features)
  - [App Management](#1-app-management)
  - [App Export/Import](#2-app-exportimport-fab-bundles)
  - [Backup System](#3-backup-system)
  - [Storage Management](#4-storage-management)
  - [Monitoring & Health](#5-monitoring--health)
  - [Notifications](#6-notifications)
  - [Update Management](#7-update-management)
  - [Authentication & Settings](#8-authentication--settings)
  - [Central Hub](#9-central-hub-reporting)
  - [Setup Wizard](#10-first-run-setup-wizard)
  - [Disaster Recovery](#11-disaster-recovery)
  - [Asset Sync](#12-asset-sync)
  - [Debug Mode](#13-debug-mode)
  - [Geo-Restriction](#14-geo-restriction)
  - [App-to-App Integrations](#15-app-to-app-integrations)
- [Repository Layout](#repository-layout)
- [Configuration](#configuration)
- [REST API](#rest-api)
- [Build & Deploy](#build--deploy)
- [Roadmap](#roadmap)

---

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│  Customer Hardware (N100 mini PC / Raspberry Pi)                │
│                                                                 │
│  ┌──────────┐   ┌────────────────────────────────────────────┐  │
│  │ Traefik  │   │  felhom-controller (privileged container)  │  │
│  │ (reverse │──▶│                                            │  │
│  │  proxy)  │   │  ┌──────────┐  ┌─────────────────────────┐│  │
│  └──────────┘   │  │ Web UI   │  │ Stack Manager           ││  │
│                 │  │ (HU dash │  │ (compose ops, git sync,  ││  │
│  ┌──────────┐   │  │  board)  │  │  deploy, delete, update) ││  │
│  │cloudflared│   │  └──────────┘  └─────────────────────────┘│  │
│  │ (tunnel) │   │  ┌──────────┐  ┌─────────────────────────┐│  │
│  └──────────┘   │  │ Backup   │  │ Storage Manager         ││  │
│                 │  │ (3-layer │  │ (disk scan, format,     ││  │
│  ┌──────────┐   │  │  restic) │  │  mount, migrate)        ││  │
│  │ App      │   │  └──────────┘  └─────────────────────────┘│  │
│  │ stacks   │   │  ┌──────────┐  ┌─────────────────────────┐│  │
│  │ (docker  │   │  │Scheduler │  │ Monitor & Metrics       ││  │
│  │ compose) │   │  │(cron-like│  │ (health, SQLite         ││  │
│  └──────────┘   │  │  jobs)   │  │  time-series, Chart.js) ││  │
│                 │  └──────────┘  └─────────────────────────┘│  │
│                 │  ┌──────────┐  ┌─────────────────────────┐│  │
│                 │  │ Notify   │  │ REST API + Hub Reporter ││  │
│                 │  │ (events) │  │ (JSON push + events)    ││  │
│                 │  └──────────┘  └─────────────────────────┘│  │
│                 │  ┌──────────┐                              │  │
│                 │  │ Assets   │                              │  │
│                 │  │ (Hub     │                              │  │
│                 │  │  sync)   │                              │  │
│                 │  └──────────┘                              │  │
│                 └────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
         │ events + reports      │ git pull       │ asset sync
         ▼                       ▼                ▼
   hub.felhom.eu           gitea.dooplex.hu  hub.felhom.eu
   (central dashboard)     (stack definitions) (logos, screenshots)
```

### Key Architecture Decisions

- **Pure Go, no frameworks** — stdlib `net/http` + `html/template`. Only external deps: `bcrypt`, `yaml.v3`, `modernc.org/sqlite` (pure Go, no CGO).
- **Privileged container** — Required for disk operations (format, mount, fstab), `/dev` access, and Docker socket control.
- **`/host-dev` indirection** — Docker overrides `/dev` with a tmpfs. The host's `/dev` is mounted at `/host-dev` to access block devices.
- **`StackDataProvider` interface** — Breaks circular import between the backup packages and stacks. Defined in `internal/appbackup` (and re-exposed via a type alias in `internal/backup`). Implemented by `stackAdapter` in `main.go`. Provides `GetStackHDDPath()` for per-drive backup routing.
- **Atomic file writes** — All persistent state (`settings.json`, `app.yaml`) written to `.tmp` then `os.Rename` for crash safety.
- **`go:embed` templates** — All HTML/CSS/JS compiled into the binary. No runtime file dependencies.
- **Design system v2 (v0.96.0, TASK-D0)** — The whole UI renders in the Felhom v2 language (canonical
  reference: `felhom.eu/documentation/design/design-system.md`): navy token palette, single 2px radius,
  no shadows, exception-based status color (nominal = blue/neutral; amber/red only on deviation —
  `stateColor` emits `run/progress/warn/neutral/off`, `usageColor`/`tempColor` emit
  `nominal/warn/crit`; a stopped app is neutral, NOT red). Capacity bars are the hairline `.meter`
  component; state chips are `.tag`; informational pills are `.metarow`. **Fonts (Plus Jakarta Sans +
  JetBrains Mono, variable woff2, latin+latin-ext) and a 30-icon Lucide sprite are vendored in the
  binary** (`internal/web/static/fonts/` served at `/static/fonts/`; `templates/icons.html`) — no CDN,
  no emoji. The setup wizard serves the same embedded stylesheet via `web.StyleCSS()`.
- **Page IA (v0.97.0, TASK-D1)** — the settings monolith is split into four pages, each with its own
  data builder (`systemPageData`/`storagePageData`/`notificationsPageData`/`securityPageData`, sharing
  `settingsBaseData`) and template. Routes: `/settings` (Rendszer), `/settings/notifications`
  (GET→page, POST→save on the same path), `/settings/security`, `/storage` (main-nav **Tárhely** — Meghajtók), and `/storage/network` (Hálózati tárhely / NAS; v0.98.0 split — nested sub-links under Tárhely).
  The enrollment wizards live at `/storage/{init,attach}`; `/settings/storage/{init,attach}` **301**
  to them; all storage action successes redirect to `/storage?storage_msg=…`. `/storage` shows a
  **unified drive view**: server-rendered registry cards enriched in place from the agent `/api/disks`
  (role/durable-id/actions, joined on mount path), plus read-only **Rendszermeghajtók** and
  **Nem regisztrált meghajtók** groups; agent-down degrades to a single warn note. Consequential
  actions use an in-page `.confirm-overlay` (`openDialog`), never native `confirm()`/`prompt()`.
  **Enforcement:** `scripts/template_id_gate.py` (JS element-ID integrity) + `scripts/emoji_gate.py`
  (no emoji — run after any template change; the Go `TestNoEmojiInTemplates` mirrors it).
- **Europe/Budapest timezone** — All scheduled jobs, timestamps, and UI labels use Hungarian timezone.

### Module Map

| Module | Path | Responsibility |
|--------|------|----------------|
| **Config** | `internal/config/` | YAML loader, validation, `FELHOM_*` env overrides |
| **Settings** | `internal/settings/` | Runtime-mutable `settings.json` (passwords, backup prefs, storage paths, notifications) |
| **Stacks** | `internal/stacks/` | Compose operations, scanning, `.felhom.yml` metadata, deploy/delete flow; **base-infra bring-up** (`infra.go` — `EnsureBaseStack`) |
| **Infra** | `internal/infra/` | Pure renderers (embedded `text/template`) for the base-infra stacks (traefik/cloudflared/filebrowser); **pinned image tags as the single source of truth** (web filebrowser sync delegates here) |
| **Crypto** | `internal/crypto/` | AES-256-GCM encryption for sensitive app.yaml values (passwords, secrets), key management |
| **Sync** | `internal/sync/` | Git-based app catalog sync (clone/pull, content-hash copy) |
| **AppBackup** | `internal/appbackup/` | Self-contained app-data backup primitives: DB dump discovery/execution (`DiscoverDatabases`, `DumpOne`), Docker-volume/app-data discovery (`StackDataProvider`, `DiscoverAppData`), keep-side path helpers (`AppDBDumpPath`, `AppVolumeDumpPath`, `AppDataDir`). `DiscoverDatabases` takes the deployed-stack set so a DB container maps to the right stack even when a slug ends in a DB-role token (M19, v0.62.0). `ListDumpFiles` takes an optional `cached(name,size,mod)` lookup so an unchanged dump isn't re-validated (line-scan) every ~5-min cycle (M18, v0.62.0). No dependency on restic/cross-drive/drive-mount. Imported directly by `appexport` and `storage`. |
| **Backup** | `internal/backup/` | Per-drive 3-layer backup: DB dumps → restic snapshots → cross-drive copies, restore. Re-exposes the `appbackup` primitives via aliases/forwarders (`appbackup_bridge.go`) for the disk/host-side code and the web/api/report consumers. |
| **Storage** | `internal/storage/` | Disk scanning (`lsblk`), partitioning (`sfdisk`), formatting (`mkfs.ext4`), mounting, data migration (`rsync`) |
| **System** | `internal/system/` | System info (`/proc`), CPU collector, mount points, disk usage, FS info |
| **Monitor** | `internal/monitor/` | System health checks, storage watchdog, legacy Healthchecks pinger (deprecated) |
| **Metrics** | `internal/metrics/` | SQLite time-series store, system + container metric collection |
| **Scheduler** | `internal/scheduler/` | Central job scheduler (periodic + daily, skip-if-running, panic recovery) |
| **SelfUpdate** | `internal/selfupdate/` | Version checking (registry), update trigger, state persistence, startup verification |
| **Notify** | `internal/notify/` | Email notifications via hub relay, preference sync, per-event cooldowns |
| **Report** | `internal/report/` | Hub report builder + HTTP pusher (system, stacks, backup, health) |
| **Assets** | `internal/assets/` | Hub-managed asset syncer: downloads logos/screenshots with SHA-256 change detection |
| **SelfTest** | `internal/selftest/` | Startup self-test: 9 diagnostic checks (Docker, dirs, storage, hub, restic, metrics) |
| **Util** | `internal/util/` | Shared utilities: `TruncateStr` for debug log output truncation |
| **AppExport** | `internal/appexport/` | Per-app export/import via `.fab` bundles (config + DB + user data), optional AES-256 encryption |
| **API** | `internal/api/` | REST JSON endpoints, diagnostic dump (`/api/debug/dump`) |
| **Web** | `internal/web/` | Hungarian dashboard, auth, page handlers, template functions, alerts |

---

## Features

### 1. App Management

The controller manages Docker Compose stacks through a complete lifecycle: catalog sync, first-time deployment, runtime operations, and deletion.

#### Git Sync (`internal/sync/`)

The app catalog lives in a separate Git repository. The controller:
- Shallow-clones the catalog on startup
- Periodically fetches updates (configurable, default 15 min)
- Copies only `docker-compose.yml` and `.felhom.yml` to the stacks directory
- **Never overwrites** `app.yaml` (user secrets are safe)
- Uses SHA-256 content hashing — only writes files that actually changed
- Triggers stack rescan after sync so the dashboard updates immediately
- **Post-sync hook**: auto-injects missing deploy fields (new secrets, domains) into existing `app.yaml` for stacks whose templates were updated (see Missing Field Injection below)
- Manual sync via "Sablonok frissitese" button or `POST /api/sync`

#### First-Time Deploy Flow

1. Customer sees app card with "Telepites" button
2. Deploy page pre-generates and **displays** all auto-values before the user clicks deploy:
   - `domain` fields: shown as readonly text input with the customer's configured base domain
   - `subdomain` fields: editable text input pre-filled with the default from `.felhom.yml`, shown with `.base-domain` suffix. Validated for DNS-safe format, reserved names, and uniqueness across deployed stacks. Locked after deploy — changing requires Remove + Redeploy
   - `secret` fields: pre-generated and shown as masked password inputs with a "Megjelenítés" reveal button — user can see/copy all DB passwords and keys before deploying
   - User-configurable inputs (admin password, language, storage path) remain editable
   - Section header prompts the user to note down any passwords they need
3. `checkBeforeDeploy()` JS guard fetches live state first (prevents double-deploy from another tab)
4. **Memory validation** (F1, v0.61.0): the controller runs as a Docker container inside an LXC, where
   `/proc/meminfo` shows the **Proxmox host's** RAM (no lxcfs in the container) and the container's own
   cgroup is unlimited (the guest cap lives on the LXC ancestor). So the guest cap is read from the
   **Docker daemon** (`system.GuestMemTotalMB()` → `docker info` MemTotal — the daemon runs in the LXC and
   reports the guest's lxcfs-backed RAM; the cgroup limit is preferred when present, e.g. non-nested):
   - `usable_memory = guest_cap - reserved_memory_mb` (default 384MB reserved)
   - Hard block if `committed_used + new_request > usable_memory`, where `committed_used` =
     `CommittedMemory()` (sum of running apps' mem requests) — the guest-wide RSS is not observable from
     the container, so the controller's own committed accounting is the accurate, cheap "used".
   - `/api/system/info` reports the guest cap as total and committed memory as used.
4b. **Docker-data volume reserved-buffer gate (v0.58.0, storage-split prevention layer):** the OS rootfs
   and Docker data are split onto separate volumes; infra (controller/traefik/cloudflared/filebrowser)
   shares the one Docker data-root (`/var/lib/docker`) and is protected by **prevention, not placement**.
   `system.GetDockerVolumeHeadroom()` measures the Docker-data volume via `statfs("/")` (the controller
   container's overlay root is the upperdir on that volume — true with the golden's **overlay2** driver)
   and reserves `max(5 GB, 10%)`. `deployStack` **refuses a new deploy (HTTP 507)** when free space is
   at/under the buffer; the deploy page shows the warning + disables the button. Fail-open on a statfs
   error. The runtime disk monitor (`healthcheck.go`, warn 80% / crit 90%) watches the same volume and
   trips above the buffer. (Assumes the split guest's large data volume; the golden bakes overlay2 + log
   rotation so images+volumes live on the data volume, not `/var/lib/containerd`.)
5. Pre-generated secret values are submitted as hidden form inputs so the **same values** the user saw are saved to `app.yaml` (no silent re-generation on submit). Controller saves `app.yaml`, sets in-memory `Deployed` + `Deploying` flags, then runs `docker compose up -d` **asynchronously** in a goroutine — API returns immediately so the UI switches to the progress panel without waiting for image pulls. On failure the goroutine reverts both disk and in-memory state and sets `DeployError`.
6. 3-step progress panel polls `GET /api/stacks/{name}` every 3s: config saved → `deploying` (pulling images) → containers starting → health check passed. New `StateDeploying` state shown while compose-up is in progress (no containers yet).
7. Post-deploy: locked fields (DB_PASSWORD, etc.) become read-only; the "Automatikusan generált értékek" section continues to show the saved values on the settings page
8. The deploy/settings page includes **start/stop/restart** buttons for deployed apps, plus a "Megnyitás ↗" link to the app's subdomain URL (only visible when running)

#### Catch-All Page for Stopped Apps

When a user visits a stopped or undeployed app's subdomain (e.g., `travel.demo-felhom.eu`), the controller serves a branded error page instead of Traefik's raw 404:

- **Traefik catch-all router**: The controller's `docker-compose.yml` registers a second router (`catchall`) with `priority=1` (lowest) and `HostRegexp(.+)`. Running apps always win; only requests with no matching container reach the controller.
- **`CatchAllMiddleware`** in `server.go` intercepts requests where `Host` ≠ `felhom.DOMAIN`, serves the catch-all page **without auth** (user has no session on the app subdomain).
- **`findStackBySubdomain()`** identifies the app by matching the subdomain against deployed `app.yaml` `SUBDOMAIN` env or metadata fallback.
- **`catchall.html`** — standalone template (no layout, inline CSS) showing the app name, status ("leállítva" / "nincs telepítve" / "nem található"), and links to the controller dashboard or the app's detail page.
- **Subdomain links** on the Alkalmazások page are only shown for deployed apps (non-deployed apps have no guaranteed subdomain yet).

#### Dashboard "Megnyitás" Button

Running apps on the Vezérlőpult now show a "Megnyitás ↗" button that opens the app's subdomain in a new tab. The `Subdomains` map is built in `dashboardHandler` from `app.yaml` env or metadata fallback.

The optional `.felhom.yml` **`open_path`** field (`Metadata.OpenPath`) is appended to that URL for apps whose UI isn't at `/` — e.g. `gokapi` → `/admin`, `ghost` → `/ghost/`. Empty = bare root. Rendered via `.Meta.OpenPath` in `dashboard.html`, `deploy.html`, and `app_info.html` (the same field flows through all three open-link sites; no handler change needed). Must start with `/`; cosmetic only (does not affect routing).

#### App Info Pages

Each app can define rich metadata in `.felhom.yml`:
- `app_info`: tagline, use_cases, first_steps, prerequisites, default_creds, docs_url
- `optional_config`: groups of post-deploy configurable env vars (e.g., API keys for metadata providers)
- `resources`: mem_request, mem_limit, pi_compatible, needs_hdd, hungarian_ui
- `initial_credentials` (v0.84.0): for apps that **auto-generate a first-login password into a file** at
  first boot (vs taking it from a deploy field) — e.g. Crafty → `/crafty/app/config/default-creds.txt`.
  Fields: `file` (path inside the container), `format` (`json`|`regex`|`plain`), optional `container`
  (defaults to the stack's main container), `username_key`/`password_key` (json), `username_pattern`/
  `password_pattern` (regex, first capture group), `note`. `ReadInitialCredentials`
  (`internal/stacks/initialcreds.go`) reads the file **live** via `docker exec … cat` and parses it with
  the pure `parseInitialCreds`; the value is **never persisted** to `app.yaml`. `appDetailHandler`
  surfaces it on `/apps/{slug}` as a "Kezdeti belépési adatok" card (masked password + reveal/copy),
  labelled as the *initial* credential (stays valid only until the customer changes it in-app). Hidden
  when the container is down / file missing / parse fails. Reuse for any future self-seeding app.

The `/apps/{slug}` page renders hero section, screenshots, setup guide, and optional config form.

#### Stack Operations

| Operation | What it does |
|-----------|-------------|
| Start | `docker compose up -d` — pre-start memory check rejects with 409 if insufficient RAM |
| Stop | `docker compose stop` (blocked for protected stacks) |
| Restart | `docker compose restart` |
| Update | `docker compose pull` + `docker compose up -d` |
| Remove | `docker compose down --volumes` + remove `app.yaml` + optional HDD/backup cleanup; template preserved for redeploy |
| Delete | `docker compose down --rmi local --volumes` + optional HDD data cleanup (orphaned stacks only) |

**Remove vs Delete**: "Eltávolítás" (Remove) is for deployed catalog stacks — it reverts the stack to "Nincs telepítve" state while keeping the template for easy redeployment. "Törlés" (Delete) is for orphaned stacks — it removes the entire stack directory including templates. Both require stopping the stack first.

**Remove modal** shows three sections: (1) always-removed items (Docker volumes, app.yaml, cross-drive schedule), (2) optional HDD data deletion with reimport warning, (3) optional backup data deletion (DB dumps + cross-drive rsync) with restic retention note.

**Protected stacks** (traefik, cloudflared, felhom-controller) cannot be stopped, removed, or deleted from the UI. Restart is allowed.

**Orphan detection**: Deployed stacks with no matching catalog template are marked as orphaned with an "Elavult" badge and can be safely deleted.

#### Base-infrastructure bring-up (`stacks/infra.go` + `internal/infra/`, v0.41.0)

The controller stands up its own base stack — **traefik** (reverse proxy), **cloudflared** (external tunnel), **filebrowser** — instead of relying on the bare-metal `scripts/docker-setup.sh` (which a Proxmox-provisioned guest never runs). `internal/infra` renders the compose + config files from `controller.yaml` via embedded `text/template`s (lifted from `docker-setup.sh`); image tags are **pinned constants there** (`TraefikImage`/`CloudflaredImage`/`FileBrowserImage`) and the web FileBrowser sync path delegates to the same renderers, so the pinned versions can never diverge.

`Manager.EnsureBaseStack()` creates the `traefik-public` network, then deploys traefik → cloudflared → filebrowser under `${stacks_dir}/<name>`. It is:
- **single-flight** (a `TryLock` guard — it's called from both first boot and every health tick, so overlapping runs must not race on the same stack dir),
- **idempotent** (skips a stack whose container is already running; never overwrites an existing filebrowser compose, preserving the storage mounts `SyncFileBrowserMounts` manages),
- **non-fatal** (logs, never crashes the controller).

cloudflared is only deployed when a tunnel token is configured. **Triggers**: a first-boot goroutine (after stack init) and an unconditional call on every `system-health` tick (self-heal — cheap when healthy thanks to the idempotency). `monitor.EffectiveProtected` mirrors the cloudflared condition so a LAN-only node (no tunnel token) doesn't report a perpetual "protected container not running" FAIL.

**Backend transports — self-signed HTTPS backends (`ensureServersTransports` → `RenderServersTransports`, v0.83.0).** Traefik talks **HTTP** to app backends by default, which is correct for every catalog app that serves plain HTTP. The exception is an app that serves its **own self-signed TLS** on the internal docker bridge (the first is Crafty, HTTPS-only on `:8443`): Traefik must speak `https` to it *and* skip verifying a per-container self-signed cert (no CA to verify against; the hop never leaves the host). `insecureSkipVerify` is **not settable via Docker labels** in traefik v3 — it must live in static/file config — so `EnsureBaseStack` writes a file-provider dynamic file `dynamic/serverstransports.yml` defining a **named** transport `insecure-skip-verify` (write-if-changed; hot-loaded by the file watcher). An app **opts in per-service** via two catalog labels — `loadbalancer.server.scheme=https` + `loadbalancer.serverstransport=insecure-skip-verify@file` (the `@file` suffix is the cross-provider reference). Backend verification stays the default (ON) for every other service — there is deliberately **no global `insecureSkipVerify`** in `traefik.yml`. This write runs **outside** `ensureTraefik` (which early-returns when traefik is already up) so an established node still materializes the file on a self-heal tick.

> **Mount prerequisite (Section-G):** the controller writes these stacks under `/opt/docker/stacks` *inside its container*, but `docker compose up` runs on the **guest** Docker daemon. The golden's controller-bootstrap (`felhom-agent` `build-golden.sh`) therefore bind-mounts that path **same-path** (`-v /opt/docker/stacks:/opt/docker/stacks`) so the daemon resolves every relative bind source — without it, all bind-mounted stacks (base infra and customer apps) silently break.

**Controller routing + the wildcard cert anchor (`wireController` → `RenderControllerRoute`, v0.41.1 / v0.42.1).** filebrowser self-registers with traefik via Docker labels + `traefik-public` 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 — that comes from the hub pull). So `EnsureBaseStack` wires the controller **post-pull**: it `docker network connect traefik-public felhom-controller` and writes a traefik file-provider route `dynamic/controller.yml` (`Host(felhom.<domain>) → http://felhom-controller:8080`, write-if-changed). When DNS-01 ACME is configured, that route is **also the wildcard-cert anchor**: its router-level `tls.domains: *.<domain>` makes traefik **proactively obtain the wildcard `*.<domain>` + apex via Cloudflare DNS-01 at startup** (an entrypoint-level `http.tls.domains` does *not* trigger issuance in traefik v3 — only a router-level `tls.domains` does). Every other router then serves that one real wildcard cert by SNI — no per-app `certresolver` labels. This is what lets a LAN client reach the box directly at `*.<domain>` with the real cert (the `felhom-agent` split-horizon resolver depends on it).

#### Missing Field Injection (`deploy.go`)

When app templates are updated (e.g., a new `APP_KEY` secret is added to `.felhom.yml`), existing deployed apps need the new field in their `app.yaml`. The controller handles this automatically:

- **On startup**: `InjectMissingFields()` runs for all deployed stacks
- **After sync**: the post-sync hook runs for stacks whose templates were updated
- For each deployed stack, compares `.felhom.yml` `deploy_fields` against `app.yaml` env vars
- Missing `secret` fields: auto-generated using the field's generator spec (`password:N`, `hex:N`, `base64key:N`)
- Missing `domain` fields: filled with the customer's configured domain
- Missing `subdomain` fields: filled with the field's default value or the `.felhom.yml` `subdomain:` metadata
- Other field types (e.g., `text`, `select`): logged as warning for manual configuration
- Locked fields are added to the locked list automatically

**Generator types**: `password:N` (alphanumeric), `hex:N` (hex-encoded random bytes), `base64key:N` (`base64:` + N random bytes base64-encoded, for Laravel APP_KEY etc.), `static:VALUE` (literal value).

#### Container State Display

| State | Color | Label | Meaning |
|-------|-------|-------|---------|
| Running + healthy | Green | "Fut" | All containers running and healthy |
| Running + starting | Orange | "Indulas..." | Healthcheck not yet passed |
| Deploying | Orange | "Telepítés..." | Compose up in progress (image pull, container creation) |
| Running + unhealthy | Yellow | "Nem egeszseges" | Docker or controller-side healthcheck failing |
| Stopped/exited | Red | "Leallitva" | All containers stopped |
| Restarting | Yellow | "Ujrainditas..." | Restart loop |
| Not deployed | Gray | "Nincs telepitve" | Compose file exists, not deployed |

**Route-unpublished indicator (F5, v0.61.0).** Traefik's Docker provider only publishes a route to a
container that is healthy (or has no healthcheck), so an `unhealthy`/`restarting` deployed app returns a
hard **404** at its URL even though the container is running. The `routeUnpublished` template helper
(`funcmap.go`) drives a distinct "URL nem elérhető – útvonal nincs publikálva" indicator on the dashboard
and stacks cards for such apps, so a dead URL isn't mistaken for a merely-degraded-but-reachable one.

#### Controller-side Health Probes (`internal/stacks/healthprobe.go`)

For apps that declare a `healthcheck:` section in `.felhom.yml`, the controller probes the container directly over the Docker network (both are on `traefik-public`). This complements Docker-level healthchecks and is the **only** health mechanism for distroless/scratch images that lack shell utilities.

Three probe types are supported:
- **`http`** — Any HTTP response (even 4xx/5xx) = service is alive. Only connection refused/timeout = unhealthy.
- **`api`** — HTTP request with response validation (expected status code, body content). Fails if expectations aren't met.
- **`tcp`** — Simple port reachability check via `net.Dial`.

Multiple checks per app are supported (all must pass). The probe scheduler runs every 10 seconds; per-app intervals default to 5 minutes and are configurable via `healthcheck.interval` in `.felhom.yml`. Probe results are stored in `Stack.HealthProbe` and exposed via the API. Failed probes override the stack state to `StateUnhealthy`; the override clears automatically when the next probe passes.

**Fast initial probing:** On start/restart, stale health probe results are cleared (so the stack doesn't immediately appear "unhealthy" from a previous result). Until the first healthy probe, the controller checks every 10 seconds instead of the normal 5-minute interval, giving fast feedback on whether the app came up successfully.

---

### 2. App Export/Import (.fab bundles)

Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypted) that can be stored externally or used to restore the app on the same server. Distinct from the automatic backup system — user-initiated, per-app, produces a single portable file.

**Bundle contents:** `manifest.json` + `config/` (compose, .felhom.yml, app.yaml with plaintext secrets) + `database/` (gzipped SQL dump) + `data/` (HDD bind mount tars or Docker named volume tars).

**Encryption:** Optional AES-256-CTR + HMAC-SHA256 with scrypt key derivation (N=32768). Format: `"FABE"` magic header + salt + IV + encrypted tar.gz + HMAC tag. Streaming for multi-GB files.

**Export flow:** Estimate size → check free space → optionally stop app → copy config → dump DB → tar user data → create tar.gz → optionally encrypt → atomic rename. App restarts automatically after export if it was stopped.

**Import flow:** Decrypt if needed → extract → prepare stack dir (create new or `compose down --volumes` for existing) → restore config (re-encrypt app.yaml with current server key) → restore user data (HDD or volumes) → restore DB (start DB service, wait for ready, import dump) → start full stack → refresh UI.

**Architecture:** `internal/appexport/` package with `ExportStackProvider` adapter interface (same pattern as `backup.StackDataProvider`). `exportAdapter` in `main.go` bridges `stacks.Manager` to the provider.

**API endpoints:** `/api/export/estimate`, `/api/export/start`, `/api/export/status`, `/api/export/bundles`, `/api/export/manifest`, `/api/export/import`, `/api/export/import/status`.

**UI:** Export button on app info page, standalone import page at `/import` accessible from the stacks page header.

---

### 3. Backup System

The backup system implements a **3-2-1 backup architecture**. Each tier is a **complete,
self-sufficient backup** — any single tier can fully restore an app.

| Tier | Contents | Location | Can fully restore? |
|------|----------|----------|--------------------|
| **1. Nightly restic** | DB + Config + User data | Same drive as app | Yes (not against drive failure) |
| **2. Cross-drive** | DB + Config + User data | Different physical device | Yes |
| **3. Remote** | Everything | Cloud / remote server | Future |

**Key principles:**
- User data backup is **mandatory** — every app with HDD bind mounts is included
  automatically. There is no per-app toggle.
- Each tier includes **everything** needed to restore: DB dumps, config, and user data.
  No tier depends on another tier's data.
- **Tier 2 is configurable for ALL apps** — not just apps with HDD data. Non-HDD apps
  back up config + DB dumps to the secondary drive (small but protects against drive failure).
- The `AppBackupPrefs.Enabled` field in settings.json is legacy and not read by any code.

**Per-app Tier 2 contents by app type:**

| App type | Tier 2 contents | Example |
|----------|----------------|---------|
| HDD + DB | Config + DB + User data | Immich, Paperless-ngx |
| HDD, no DB | Config + User data | — |
| Docker volumes + DB | Config + DB + Volume data | Tandoor |
| Docker volumes, no DB | Config + Volume data | Mealie (SQLite) |
| DB, no HDD/volumes | Config + DB | Vikunja |
| Config only | Config | Gokapi, Homepage |

#### Tier 1: Nightly Backup (mandatory, same drive)

The nightly backup has two phases that run sequentially. All paths are **per-drive** — each physical drive gets its own restic repo and per-app DB dump directories.

**Drive layout (v0.26.0):**
```
<drive>/
├── felhom-data/                ← all controller-managed data (namespace, v0.26.0+)
│   ├── appdata/<app>/          ← app user data
│   └── backups/
│       ├── primary/
│       │   ├── restic/              ← one restic repo per drive (all apps on this drive)
│       │   └── <app>/
│       │       ├── db-dumps/       ← per-app DB dump files
│       │       └── volume-dumps/   ← per-app Docker volume tars (v0.33.0)
│       └── secondary/
│           ├── restic/         ← secondary restic repo (cross-drive)
│           ├── _infra/         ← infra config mirror
│           └── <app>/rsync/    ← per-app rsync data
├── .felhom-infra-backup/       ← DR marker (stays at drive root for scanner)
├── Dokumentumok/               ← user files (not controller-managed)
└── media/                      ← user files (not controller-managed)
```

> **Note (Model A — corrected in v0.52.0):** `HDD_PATH` in `app.yaml` is the **in-guest mount point**
> (e.g., `/mnt/felhom-usb`). Under slice-10 Model A the host agent binds `<drive>/felhom-data` directly
> onto that mount, so the in-guest mount **already is** the `felhom-data` namespace root. Neither the
> compose templates nor the path helpers add a `felhom-data` segment for a drive-resident app: app data
> is `${HDD_PATH}/appdata/<app>` and backups `${HDD_PATH}/backups/...`, **single-nested**. Only the
> SSD-only system-data fallback (a bare root, `inGuestDrive=false`) appends `felhom-data`. See
> `NamespaceRoot(drivePath, inGuestDrive)` in `internal/appbackup/paths.go`.
> Earlier catalog templates used `${HDD_PATH}/felhom-data/appdata/<app>`, which double-nested to
> `.../felhom-data/felhom-data/...` on a Model-A drive; v0.52.0 dropped that segment in the catalog and
> locks deploy↔backup path agreement with `internal/stacks/hddpath_agreement_test.go`.

Path computation is centralized in `backup/paths.go` via the `FelhomDataDir = "felhom-data"` constant:
- `PrimaryResticRepoPath(drivePath)` → `<drive>/felhom-data/backups/primary/restic/`
- `AppDBDumpPath(drivePath, stackName)` → `<drive>/felhom-data/backups/primary/<stack>/db-dumps/`
- `AppVolumeDumpPath(drivePath, stackName)` → `<drive>/felhom-data/backups/primary/<stack>/volume-dumps/`
- `AppDataDir(drivePath, stackName)` → `<drive>/felhom-data/appdata/<stack>/`
- `SecondaryResticRepoPath(drivePath)` → `<drive>/felhom-data/backups/secondary/restic/`
- `AppSecondaryRsyncPath(drivePath, stackName)` → `<drive>/felhom-data/backups/secondary/<stack>/rsync/`
- `SecondaryInfraPath(drivePath)` → `<drive>/felhom-data/backups/secondary/_infra/`
- `InfraBackupDir(mountPath)` → `<drive>/.felhom-infra-backup/` (**unchanged** — stays at drive root for DR scanner)

> **⚠️ Stale:** the restic/secondary helpers above (`PrimaryResticRepoPath`, `SecondaryResticRepoPath`,
> `AppSecondaryRsyncPath`, `SecondaryInfraPath`) describe the pre-strip layout — restic/cross-drive was
> removed in slice 8C. This section is rewritten when Tier 2 (Phase 3) lands.

#### Per-app recovery unit (Phase 2, v0.53.x) — SECRET-FREE

Each app's `backups/primary/<app>/` is a self-contained, recreatable **recovery unit**:

```
backups/primary/<app>/
├── compose/        docker-compose.yml + .felhom.yml + a SECRET-STRIPPED app.yaml
├── db-dumps/       app-consistent DB dump(s)
├── volume-dumps/   named-volume tars
└── manifest.json   image pins, secret env-var NAMES, data_key names, checksums, secret_source
```

- **Secret-free by design.** The unit stores **no secret value, no data-encrypting key, and not the
  Docker image** — only the pinned image tag(s) (re-pulled on restore) and the *names* of the secret /
  `data_key` env vars. Rationale: app.yaml + the encryption key live on the guest rootfs → already in
  the PBS whole-guest snapshot, and the hub is deliberately zero-knowledge. Restore recovers the
  original secrets from the guest's own app.yaml (live, or via PBS); for a `data_key` app it
  **fails closed** (refuse + warn) if the key can't be recovered — data-keys are NEVER generated.
  **Resettable secrets (O4, v0.99.0):** an unrecoverable resettable secret (DB password etc.) gets a
  **generated replacement** from its catalog `generate` spec (`stacks.GenerateSecretForField` via the
  `backup.SetSecretGenerator` seam) instead of redeploying blank (which failed compose-up); the new
  value persists encrypted through the normal `RecreateStackFromUnit` → `SaveAppConfig` path. Fields
  with no `generate` spec still proceed with a loud "may fail to start" WARN. Residual case: a restored
  volume tar carrying the OLD internal credential hash may still need a manual in-DB reset.
- Helpers: `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath`
  (`internal/appbackup/paths.go`). Capture: `Manager.CaptureRecoveryUnit` (`internal/backup/recovery_unit.go`),
  run from the daily DB dump and the periodic `RefreshCache` (idempotent checksum-skip). The non-secret
  env comes from `StackDataProvider.GetStackRecoveryInfo` (excludes secret-named + encrypted values, so
  the capture never touches a secret). `data_key` fields are marked in `.felhom.yml`
  (`DeployField.DataKey`).
- **Restore replays the DB dump (F17, v0.61.0).** `RestoreFromRecoveryUnit` (and the `RestoreApp`
  fallback) stops the app → restores named-volume tars → recreates the compose definition + redeploys
  with the recovered env → **replays each `db-dumps/*.sql` into the now-running DB** via
  `backup.reimportDBDumps` → `appbackup.ImportDump` (psql / mariadb client, using the live container's own
  discovered credentials). The DB replay runs AFTER the volume restore, so the **logical SQL dump wins**
  over any volume-tar copy of the database (the dumps use DROP/CREATE — `pg_dump --clean --if-exists`,
  `mariadb-dump` default `--add-drop-table` — so replay is idempotent). Volume-restore and DB-import
  failures now **surface** (restore returns an error) instead of a swallowed WARN. Prior to v0.61.0 the
  per-app restore never replayed the `.sql`, so DB-resident data did not come back.

#### Tier 2 — off-drive copy (Phase 3, v0.55.x)

For every HDD app, Tier 2 (`internal/backup/tier2.go`) rsync-mirrors the recovery unit
(`backups/primary/<app>/`) + the app's `appdata/<app>/` to `<target>/backups/secondary/<app>/` on a
**different physical disk** — the only off-drive protection bind-mounted HDD userdata can get (PBS can't
reach bind mounts). Auto-targeted: **prefer another registered user-data drive** (off-disk via
`system.SamePhysicalDevice`); else the **internal SSD for small units only**, behind a size-aware
**rootfs-headroom guard** (`tier2FitsHeadroom`) that **refuses rather than fills** the ~8 GB guest rootfs
(reserve = `max(2 GB, 20%)`), recording an honest "needs a 2nd HDD" status. Status persists via
`settings.CrossDriveBackup` and drives the "2. mentés" card. Runs daily (`tier2-backup`, 03:30) or via
`POST /api/backup/tier2`. restic is **not** used — a plain browsable mirror.

**In-place file restore from the Tier-2 copy (C2, v0.100.0 — closes drill finding F2)** —
`POST /backup/tier2/restore` (`backup.RestoreTier2Files`, `internal/backup/tier2_restore.go`) + the
**"Fájlok visszaállítása"** button on the healthy Tier-2 layer row. **Additive-only** semantics
(`rsyncRestoreMissing`: `rsync -a --ignore-existing`): files missing from the live `appdata/<stack>`
are copied back from the RECORDED Tier-2 copy; existing live files are **never overwritten** (a
customer edit after the last copy wins) and **nothing is ever deleted** — this exactly serves the
"I deleted my files" scenario with zero risk to newer data. Source = the recorded
`CrossDriveBackup.DestinationPath` (never a fresh target selection). Single-flight with
backup/restore; refusals (no copy / never ran / copy dir gone / either drive disconnected /
decommissioned) happen before the app is stopped, with customer-readable Hungarian reasons;
stop → copy → start → health-wait; zero files copied is a success ("Nincs hiányzó fájl…"). Out of
scope by design: overwrite/point-in-time restore (offbox + operator paths), per-file selection,
`recovery-unit/`. Apps that index their data dir (e.g. Nextcloud) may need a rescan (occ
files:scan) before restored files appear in their own UI.

**Per-app Tier-2 config panel (v0.57.0)** — `GET/POST /stacks/{name}/backup`
(`internal/web/tier2_config_handler.go` + `templates/tier2_config.html`). The "2. mentés" row's
**Beállítás** button links here (was the dead-end deploy page). Shows the effective off-drive target
(pinned or auto), whether it's the size-limited internal SSD, the last-run reason, and lets the customer
**pin a registered drive** (off physical disk) or **toggle Tier 2 off**. Always visible — single-SSD apps
get the "csak DB/konfiguráció" note, non-HDD apps the "already in the PBS whole-guest snapshot" context.
Two preference fields on `CrossDriveBackup` — `UserDisabled` + `PreferredTarget` (set via
`Settings.SetTier2Preference`) — are **preserved across the runner's status writes** (`withTier2Prefs`):
`selectTier2Target` honors a valid pin before auto-picking; `RunTier2` skips a disabled app. The runner
re-validates the pin off-disk at run time. `Manager.Tier2Info(stackName)` is the read-only panel view
(effective target + eligible alternative drives).

**Phase 1 — Database Dumps** (`internal/backup/dbdump.go`, scheduled 02:30)

- **Auto-discovery** of PostgreSQL and MariaDB containers via `docker ps` + `docker inspect`
- Dumps via `docker exec pg_dump` / `docker exec mariadb-dump` with 5-minute timeout
- Dumps are written to the app's **home drive**: `AppDBDumpPath(appDrive, stackName)`
- Atomic writes (`.tmp` → `.sql`) to prevent corruption
- **Validation** after each dump: checks file size, header presence, counts `CREATE TABLE`
- Results cached in `settings.json` surviving container restarts

**Phase 1b — Docker Volume Dumps** (`internal/backup/backup.go` `runVolumeDumps`, part of the same run)

- **F3 (v0.99.0):** re-wired into the nightly/manual app-data backup run (`runDBDumpsInternal`) —
  after the restic removal `DumpAppVolumesSafe` had no caller, so `volume-dumps/` was never produced.
  Runs AFTER the DB dumps and BEFORE `captureAllRecoveryUnits` so the manifests enumerate fresh tars.
- Gate order (load-bearing): protected-stack (`cfg.IsProtectedStack`) and has-volumes
  (`GetDockerVolumes()`) checks come BEFORE `DumpAppVolumesSafe` — the Safe variant stops the stack
  before its own volume check, so unconditional calls would bounce every volume-less app nightly.
  Disconnected/decommissioned drives skip with the same summary style as the DB loop.
- Each volume-bearing stack is stopped before dump, restarted after (`DumpAppVolumesSafe()`) —
  prevents inconsistent tars of live databases.
- For each volume: `docker run --rm -v <vol>:/vol:ro -v <dumpDir>:/out alpine tar cf /out/<vol>.tar -C /vol .`
- 10-minute timeout per volume; a per-stack failure lands in the run summary as `FAIL <app> volumes:`,
  flips the run's Success flag and fails the run (no silent partial) — other stacks still proceed
- Stale tars cleaned up (volumes that no longer exist)
- Volume names resolved with project prefix via `ResolveDockerVolumeNames()` (e.g., `mealie_mealie_data`)
- Dumps written to `AppVolumeDumpPath(nsRoot, stackName)`

**Phase 2 — Restic Snapshot** (`internal/backup/restic.go`, scheduled 03:00)

- Apps are **grouped by drive** via `groupStacksByDrive()` — each drive's apps are backed up to that drive's restic repo
- App drive resolution: `GetStackHDDPath()` (from `StackDataProvider`) → falls back to `SystemDataPath`
- Auto-generated repository password (32 random bytes, base64url), shared across all repos, synced to hub
- **Paths included in each per-drive snapshot (v0.34.0: per-app scoped):**
  - Per-app DB dump dirs on that drive
  - Per-app Docker volume dump dirs (`volume-dumps/*.tar`)
  - Per-app HDD mount paths (user data)
  - Per-app stack config dir (`<StacksDir>/<stackName>/` — only for stacks on this drive)
  - `controller.yaml` — only on the system drive (not duplicated across all drives)
- Auto-detects and unlocks stale locks (restic repo lock)
- Weekly prune on Sundays with configurable retention (keep-daily, keep-weekly, keep-monthly)
- Weekly integrity check (`restic check`) on Sunday 04:00 — checks **all** primary repos

**Protects against:** accidental deletion, data corruption, point-in-time rollback.
Does NOT protect against drive failure (backup is on the same physical drive).

#### Tier 2: Cross-Drive Backup (opt-in, different device) (`internal/backup/crossdrive.go`)

**Complete backup** to a different physical drive. Available for **all apps** — apps with HDD
data back up config + DB + user data + Docker volumes; apps without HDD back up config + DB dumps + Docker volumes.

- **Auto-enable for small apps (v0.14.1):** Apps without HDD mounts (config-only, DB-only) are
  automatically configured for daily rsync Tier 2 when ≥2 storage paths are registered.
  `AutoEnableSmallApps()` runs at the start of each nightly backup cycle. Never overwrites
  existing user-configured cross-drive settings (even disabled ones).
- **Infrastructure config backup (v0.14.1):** `syncInfraConfig()` rsyncs the stacks directory
  and `controller.yaml` to `<dest>/backups/secondary/_infra/` on every secondary destination
  drive. Runs before per-app backups. Cross-drive restic also includes infra paths.
- **Two methods:**
  - **rsync** — Simple mirror with `--delete` (fast, no versioning, **browsable** on disk)
  - **restic** — Versioned, deduplicated, encrypted (shared repo across apps, not browsable)
- Per-app configuration in settings.json: destination path, method, schedule (daily/weekly/manual)
- **Pre-backup DB dump:** `DumpStackDB()` runs fresh pg_dump/mariadb-dump before each cross-drive backup; non-fatal on failure (wired via `DBDumper` interface to avoid circular imports)
- **Pre-backup volume dump (v0.33.0, safe stop/start v0.34.0):** `DumpAppVolumesSafe()` stops the stack, exports Docker named volumes to tar, restarts — wired via `VolumeDumper` interface
- **Empty mounts allowed:** `RunAppBackup` accepts apps with no HDD mounts — the rsync
  mount loop simply doesn't execute, but DB + config copy still runs
- **Drive-type-aware validation** (`ValidateDestination`):

  | Destination type | Space checks |
  |-----------------|--------------|
  | External mount (different device than `/`) | Block if <100 MB free |
  | System drive (same device as `/`) | Require ≥10 GB free AND <90% used; logged warning |

- **Secondary drive layout (v0.14.1):**
  ```
  <dest-drive>/backups/secondary/
  ├── _infra/              ← infrastructure config mirror (v0.14.1)
  │   ├── controller.yaml
  │   └── stacks/          ← full stacks dir (all app configs)
  ├── <app>/rsync/         ← per-app rsync mirror
  │   ├── _db/             ← DB dump files
  │   ├── _config/         ← compose.yml, app.yaml, .felhom.yml
  │   ├── _volumes/        ← Docker volume tars (v0.33.0)
  │   └── <user data>      ← HDD mount contents (if app has HDD data)
  └── restic/              ← shared restic repo (all cross-drive apps)
  ```
  - DB dump files read from **per-app home drive** path (`AppDBDumpPath`)
  - `_` prefix directories prevent collision with user data
  - For non-HDD apps, only `_db/`, `_config/`, and `_volumes/` (if applicable) are present (no user data directory)
- **Restic backup paths:** includes HDD mounts (if any) + config dir + per-app DB dump dir from home drive + stacks dir + controller.yaml (infra, v0.14.1)
- Safety guards: destination ≠ source, path-overlap check (HDD mounts only), writable check
- **Chained execution:** runs immediately after nightly restic — daily apps every night, weekly apps on Sundays
- **Hub reporting after manual triggers (v0.27.2):** `OnCrossDriveComplete` callback on Router pushes infra backup snapshot to Hub + writes local infra backup after both single-app and run-all manual triggers complete (previously only automatic scheduled runs reported)
- Per-app concurrency lock prevents overlapping runs
- Status (last_run, duration, size, error) persisted to settings.json

**Protects against:** primary drive failure, drive theft/damage.

#### Tier 3: Remote Backup (future)

Complete offsite backup for disaster recovery. Not yet implemented.
Placeholder shown in UI ("3. mentés — Hamarosan").

#### Restore (`internal/backup/restore.go`)

Both **Tier 1** (restic) and **Tier 2** (rsync) restores are supported. All deployed apps
appear in the restore dropdown with per-app snapshot filtering.

| App type | Config restored | DB restored | User data restored | Docker volumes restored |
|----------|----------------|------------|-------------------|------------------------|
| Has HDD data | Yes | Yes | Yes (always) | Yes (if present) |
| Docker volumes, no HDD | Yes | Yes | n/a | Yes |
| DB only, no HDD/volumes | Yes | Yes | n/a | n/a |
| Config only | Yes | — | n/a | n/a |

**Snapshot API** (`GET /api/backup/snapshots?stack=<name>` — F1, v0.99.0):
- Backed by `backup.Manager.ListRestorePoints` (`internal/backup/restore_points.go`). The keep-side
  restore has exactly **one** restore point per app — the current recovery unit — so the endpoint
  returns at most one entry: `time` = newest artifact mtime (manifest / db-dumps / volume-dumps),
  `short_id:"helyi"`, `tier:1`, `drive_label` from the storage registry (empty on the SSD fallback)
- **Never emits tier-2 entries**: Tier-2 copies are not restorable via `POST /backup/restore` (it
  only reads the primary unit) — listing them would silently restore tier-1 data while claiming tier-2
- Guards: empty/traversal stack name → 400 (`validStackParam`), unknown stack → 404, known stack
  with no unit yet → `ok:true, data:[]` (the UI shows "Nincs elérhető mentés")
- History: the route was a restic-era leftover fetched by the template but unregistered — the
  dropdown could never populate and the restore button never enabled (drill finding F1)

**Restore type info** shown per-app when selected in dropdown (Hungarian banners):
  - Has HDD or Docker volumes: "Teljes visszaallitas: adatbazis + konfiguracio + felhasznaloi adatok"
  - Has DB, no user data: "Adatbazis es konfiguracio visszaallitasa"
  - Config only: "Csak konfiguracio visszaallitasa"

**Tier 1 restore** (`RestoreApp`):
- Stop app → resolve app's home drive → `restic restore <id> --target / --include <path>...` → populate Docker volumes from restored tars → restart app → health check
- Restore paths: config dir, DB dump dir, volume dump dir, HDD mounts
- Docker volumes restored via `restoreDockerVolumes()`: `docker volume rm -f` → `docker volume create` → `docker run alpine tar xf`

**Tier 2 restore** (`RestoreAppFromTier2`):
- Stop app → rsync config from `_config/` → rsync HDD data (single/multi-mount) → copy DB dumps from `_db/` (streaming `copyFile`) → restore Docker volumes from `_volumes/` tars → restart app → health check
- Uses rsync `--delete` for config and HDD data to ensure exact mirror state
- Single-mount apps: data directly in rsync dir (excluding `_*`); multi-mount: per-leaf subdirectories

**Common:**
- **v0.34.0:** Post-restore health check (`waitForHealthy`) polls container state with `docker ps` refresh every 5s for up to 90s. Warning logged if app doesn't reach running state; restore still returns success (data is restored regardless).
- Running flag prevents concurrent backup/restore operations
- Snapshot ID validated (8-64 lowercase hex, or special `tier2-rsync`)
- Import from `.fab` bundle link shown in restore section for cross-system migration

#### Backup Page UI (`internal/web/templates/backups.html`)

Unified per-app status table with expandable rows showing **per-tier** backup status:

**Status dot per app:**

| Dot color | Meaning |
|-----------|---------|
| Green | 2+ tiers configured with successful backups + destination healthy |
| Yellow | Only 1 tier, or Tier 2 failing, or Tier 2 configured but never run, or destination disconnected/inactive |
| Red | Tier 2 destination blocked or inaccessible |

Every app starts as yellow (1 tier only). Green requires Tier 2 configured with successful backup.

**Per-app backup tiers (3 rows per app):**
- **1. mentes** (Tier 1, always present) — Auto badge + "helyi" + last run + contents (e.g., "DB + Konfig + Adatok")
- **2. mentes** (Tier 2, configurable for ALL apps) — one of:
  - Configured: method (rsync/restic) + destination + schedule + last run + status + contents + browsable indicator (folder icon for rsync) + action buttons
  - Not configured: "1. mentes auto" + "Nincs 2. masolat" + settings link
- **3. mentes** (Tier 3, placeholder) — grayed out "Hamarosan" + "tavoli (offsite)" + future note

**Backup contents per app** (shown per tier):
- Apps with DB + HDD: "DB + Konfig + Adatok"
- Apps with Docker volumes (no HDD): "Konfig + DB + Adatok" or "Konfig + Adatok"
- Apps with DB only: "DB + Konfig"
- Apps with HDD, no DB: "Konfig + Adatok"
- Apps with neither: "Konfig"

**Deploy page** shows cross-drive (Tier 2) configuration form for **all deployed apps**,
not just those with HDD data. Non-HDD apps can configure destination, method, and schedule.

**Other sections:**
- Schedule overview with next run times for DB dump, restic, prune
- Snapshot history table (last 20 snapshots aggregated from all per-drive repos, sorted by time)
- Storage overview card (total size across repos, snapshot count, DB dump count/size, encryption key with show/copy)
- Restore section: app dropdown → per-app snapshot dropdown (Tier 1 + Tier 2 grouped) → restore type info → confirmation checkbox → execute → import from `.fab` bundle link

---

### 4. Storage Management

> **Off-box (NAS) backup — restic-over-SFTP (v0.93.0, Part B).** The "1 off-site" leg of 3-2-1 for the
> app-data tier: each off-box-toggled app's recovery unit + DB dumps + volume tars are backed up to the
> customer's NAS as an **encrypted restic repo over SFTP** (`internal/backup/offbox.go`). No kernel mount —
> restic talks SFTP directly; the NAS sees only ciphertext. Distinct from the local cross-drive rsync copy
> and the agent's PBS whole-CT DR.
> - **Fail-fast (load-bearing):** every restic call uses `-o sftp.command="ssh … -oConnectTimeout=10 … -s
>   sftp"` so a dead NAS errors in ~10 s, never hangs the backup runner; a failure raises the `backup_failed`
>   operator alert. `-oStrictHostKeyChecking=yes` + a pinned `known_hosts` (no blind TOFU).
> - init-if-absent (idempotent), `forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune`,
>   single-flight (shares `m.running`) + migration-guard, restic's own exit code checked, restore to a
>   scratch dir (non-destructive).
> - **Unit discovery (v0.104.0) — durable, deployment-independent.** Each toggled app's recovery unit is
>   **discovered** by scanning the durable storage registry — every registered *schedulable* storage path
>   (`GetSchedulableStoragePaths`) ∪ the system-data fallback drive — for `backups/primary/<app>`, rather
>   than *inferring* the drive from the app's live `app.yaml` `HDD_PATH` (which silently fell back to
>   `systemDataPath` for a toggled-but-undeployed app → offbox looked on the wrong drive, backed up
>   nothing, and reported `ok`/0). If the same app's unit exists on two drives (drive churn), the **newest
>   by manifest `CreatedAt`** is used and the stale one is WARN-logged. The WRITE side (`CaptureRecoveryUnit`
>   / dumps) is unchanged — this only changes offbox's read/discovery path. Boundary: decommissioned or
>   non-schedulable drives are not searched.
> - **No silent success (v0.104.0):** a run where ≥1 app is toggled but **0** were backed up (no unit found
>   anywhere) is a **hard error** → `LastStatus="error"` + operator alert (was a misleading `ok`/0 snapshots).
>   A *partial* run (some units missing) stays `ok` but sets a Hungarian **`LastWarning`** naming the skipped
>   apps, shown on `/backups`.
> - **Crash-lock self-heal (v0.110.0).** A crash mid-prune leaves a restic EXCLUSIVE lock that plain
>   `restic unlock` can't clear (the recreated container's new hostname stops restic proving the dead PID
>   stale for ~30 min). Every backup/prune/restore step runs through `resticStep`, which on a lock error
>   escalates to `unlock --remove-all` + one retry — safe because the repo has a SINGLE legitimate writer
>   (per-customer sub-account isolation + the in-process single-flight mutex). **Boundary:** a DR-cloned
>   SECOND controller writing the same repo would defeat this premise — operator-supervised territory, out of
>   scope for the auto-heal. A crash mid-run also flips a persisted `LastStatus="running"` to a truthful
>   error on the next startup (self-corrects on the next successful run).
> - **Secrets** (SSH key + auto-gen repo password) are **0600 files in the data dir** — never logged/committed.
> - **Password custody + atomicity (v0.105.0, fork-4; pairs with agent v0.77.0).** The repo password is the
>   irreplaceable DATA key for the offsite tier, so it rides the **customer-recovery-code (R) escrow**
>   (age-under-R, in the agent's `IdentityBundle` — custody spike validated a recovered password opens the
>   real repo). **Atomicity gate:** enabling offsite pushes the password to the agent (`StageEscrowSecret` →
>   `POST /escrow/stage-secret`) and marks `EscrowState="pending"`; **no offsite backup runs until the escrow
>   is confirmed** (`OffboxRunnable()`), so an un-recoverable offsite copy can never exist.
>   **Operator ceremony (pilot, supervised):** enable offsite (→ pending) → run
>   `felhom-agent --selftest=escrow-create --identity-bundle <assembled> --upload` (the bundle now carries
>   the staged restic password, auto-injected, then wiped) → hand the customer the fresh **R** (once;
>   supersedes any prior code) → `POST /backup/offbox/confirm-escrow` (or the "Letét megerősítése" button).
>   **DR:** recover R → the escrow yields the password → `POST /backup/offbox/inject-password {password}`
>   pre-places it 0600 → configure offbox → restore. The SFTP access key is **regenerated** at DR (a fresh
>   sub-account key), NOT escrowed; the DR recipe carries only the non-secret `offsite_restic`
>   coordinates (`DRResticCoord`).
> - **Hub-verified auto-confirm (v0.108.0, SLICE 3; pairs with agent v0.79.0 + hub v0.40.0).** The report
>   ACK carries `escrow:{identity_blob_present, restic_pw_sha256, created_at}` (the hash is recorded at
>   ceremony time — non-reversible sha256 of the sealed password, safe to serve). `report.EscrowAutoConfirmer`
>   flips `EscrowState` pending→escrowed ONLY when `sha256(local repo_password)` **matches** — i.e. the
>   stored escrow provably covers the CURRENT key; blob-presence alone never confirms (a stale blob would
>   re-open the fork-4 gap). Mismatch → stays pending + a loud warn naming the ceremony (deduped per hash);
>   no hash / no row / no local file → silently pending; **never un-confirms**. On flip it wipes the
>   agent-staged secret. The canonical hasher (`backup.HashResticPassword`, trimmed-string sha256) is pinned
>   by a cross-repo test vector against the agent's. The manual `POST /backup/offbox/confirm-escrow` is a
>   **deprecated fallback** for legacy hash-less blobs (e.g. the demo's).
> - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh
>   option-injection) or carry metacharacters/traversal; `OffboxConfigured` fails closed on an invalid
>   target. Image: `restic` + `openssh-client` (re-added; restic's sftp backend shells out to `ssh`).
> - UI: the "Külső (NAS) mentés" section on the backups page (configure target, per-app toggles, run-now,
>   restore, status). Config: `settings.OffboxTarget` + per-app `AppBackupPrefs.Offbox`. Daily at 04:15.
> - **Soft quota (v0.109.x, SLICE 4; pairs with hub v0.41.0).** The shared-model quota (`quota_gb`) rides
>   the descriptor into `OffboxTarget.QuotaGB` (0 = no limit — dedicated boxes are Hetzner-enforced; the
>   hash includes it, so a hub-side quota change re-applies via key-auth-first, no password consumed).
>   `RepoSizeBytes` persists from `restic stats` (last-known on failure — stale-but-safe). Pre-run gate:
>   **≥100% refuses NEW backup runs** (Hungarian error + operator alert) but the **prune step still runs**
>   (the only way back under quota) and **restore is never gated**; ≥80% sets a Hungarian usage warning.
>   `/backups` shows a usage bar when quota>0. The hub report carries
>   `offsite:{enabled, escrow_state, last_run, last_status, snapshot_count, repo_size_bytes, quota_gb}` —
>   the hub's OffsiteChecker alerts on fill (90/95%) and staleness (escrowed + no run >48h); the Hetzner
>   `readonly` freeze is an OPERATOR lever on the hub (never automatic). A RE-apply preserves the existing
>   target's EscrowState + runtime status (v0.109.1 — custody tracks the preserved repo password).
> - **Hub-driven provisioning apply-bridge (v0.106.x, SLICE 2; pairs with hub v0.38.x — validated live
>   2026-07-09).** When the hub provisions the offsite tier (a Hetzner Storage Box sub-account or dedicated
>   box), the served `controller.yaml` gains an `offsite:` section (host/user/port/repo_path/quota_gb +
>   **`host_fingerprint`**) and `internal/offsiteapply.Bridge` reconciles it at startup: scan + **verify the
>   box host key against `host_fingerprint` (no blind TOFU)** → generate an ed25519 keypair → **consume the
>   hub's one-time transient password** (`POST /api/v1/offsite/consume-password/{id}`, single-use) → install
>   the pubkey (`sshpass -e ssh-copy-id -p 23 -s -f`, **pinned known_hosts, `StrictHostKeyChecking=yes`**;
>   the installer ensures `~/.ssh` exists — ssh-copy-id's SFTP mode needs it) → verify key-only auth →
>   configure the offbox target → `EscrowState="pending"` (the fork-4 gate above still holds) → persist a
>   descriptor-hash marker LAST. **Idempotent** (marker → no re-consume) and **fail-safe** (any failure →
>   nothing persisted, retried on the next config refresh/restart; a consumed-but-failed install logs a loud
>   "password is spent — reset on the hub"). The 15-min hub-report → config-refresh cycle is the trigger, so
>   descriptor-to-applied latency is ≤ ~15 min.
> - **Key-auth-first (v0.107.0):** on a descriptor change, if the ALREADY-INSTALLED key still authenticates
>   (`SFTPKeyAuthProber`, pinned to the freshly-verified host key — the probe never bypasses the fingerprint
>   verify), the bridge re-pins + reconfigures WITHOUT consuming a one-time password. Fresh guests fall
>   through to the full consume+install path. The hub's "Re-issue offsite credentials" (v0.39.0) is the
>   recovery for a genuinely-spent password on a fresh guest.
> - **Staged-secret wipe (v0.107.0):** confirm-escrow calls the agent's `DELETE /escrow/stage-secret`
>   (agent ≥ v0.78.0) whenever `EscrowState` flips to `escrowed` — best-effort, loud-logged on failure.

> **NAS network storage (v0.92.0, Part A2; pairs with agent v0.50.0).** A customer NAS share (NFS or SMB)
> is a **distinct storage KIND** from a physical drive (`StoragePath.Kind == "network"`), for **bulk media**.
> The controller is a thin proxy over the agent's `/netstorage/*` (A1) + the local registry — it holds **no
> mount authority** and **never persists the SMB password** (it passes the credential to the agent's add
> request, which writes the 0600 file). Endpoints `POST /api/storage/netstorage/{add,remove}` +
> `GET /api/storage/netstorage` (merges the agent's per-share liveness: `ok` | `idle` (benign idle-unmount)
> | `unreachable` | `unknown`). A network share registers as `/mnt/felhom-drives/<name>`, is **selectable as
> a media app's `HDD_PATH`** (Schedulable), and shows in the "Hálózati tárhely (NAS)" settings section.
> - **NOT a drive:** the drive lifecycle (eject/decommission/migrate/wipe/SMART) is **refused** on a network
>   path server-side (`refuseNetworkLifecycle`). The **drive-absent gate** (`planDriveGates`) and the
>   **missing-storage** surface **skip network paths**, so an `unreachable` NAS is a **recoverable warning**
>   (`networkStorageWarnings` → a distinct app-card badge), **never** the drive "missing → stopped" cascade.
> - **Limits (v1):** a share's `+100000` uid mapping is fixed at add-time (one app / same-uid apps); for
>   write apps on a soft NFS mount, an in-flight file can truncate if the NAS vanishes mid-write (prefer
>   atomic-write apps / SSD-staging).

> **⚠️ INTERMEDIARY-MOUNT model (v0.67.x, pairs with agent v0.35.x).** External data drives are now
> visible in the guest at a STABLE path **`/mnt/felhom-drives/<name>`**, NOT the raw `/mnt/<name>`. The
> agent keeps a single permanent parent bind `/mnt/felhom-drives` in the guest and swaps each drive's
> `felhom-data` namespace **underneath it host-side** (`mount --bind`), so the guest sees attach/detach
> **live with no reboot** (mount propagation), the bind source never disappears (C1-immune), and only
> `felhom-data` crosses in (confinement). The per-drive `pct set -mpN` bind is **deprecated**.
> - The registered storage path + every app's **`HDD_PATH`** + the FileBrowser source = the stable
>   `/mnt/felhom-drives/<name>`. The controller maps it back to the raw `/mnt/<name>` (`agentWhere()`)
>   only for agent calls (assign/attach/eject/decommission). `GET /api/disks` carries `guest_path` +
>   `bound_under_parent` (the guest-visible signal).
> - **Drive-absent gate** (`internal/web/intermediary.go`, `driveGateLoop` 30s): an absent
>   `/mnt/felhom-drives/<name>` drive stops + blocks its apps (`StoppedStacks` = the gate-stopped set);
>   a returned drive re-attaches + auto-restarts them; `actionStack` refuses to start an app whose drive
>   is absent ("tárhely nem elérhető"). SSD/system paths are never gated.
> - **H1 endpoints** `POST /api/storage/{disconnect,reconnect,restart-apps}` drive the host-side
>   eject/reconnect (no guest reboot).
> - **Lifecycle (v0.68.x):** a "Leszerelés" button decommissions a drive (migrate-then-decommission OR
>   decommission-anyway type-to-confirm — non-destructive, never touches the parent mp); a
>   "Visszacsatlakoztatás" button one-click re-enrolls a decommissioned/ejected drive (clears the marker,
>   re-binds under the parent, restarts gate-stopped apps). Decommissioning the DEFAULT auto-promotes
>   another schedulable drive (`defaultPromotionTarget`), or BLOCKS if it's the only one. Eject and
>   decommission keep the RAW drive mounted (logical retire) so re-enroll re-binds it.
> - **Guest-reboot convergence is DETERMINISTIC** via the agent's `guest_boot_id`: the controller persists
>   `LastGuestBootID` and, when it changes, recreates EVERY deployed drive-backed app onto the
>   re-propagated drive (`processGuestBootChange` — no fragile container-uptime sampling).
>   **v0.71.0 — boot-race fix:** on a guest `pct reboot`, in-guest dockerd auto-starts the apps ~18s
>   BEFORE the agent re-binds the drive, so their volume bind fails at create-time
>   (`mkdir …/userdata: permission denied`, `RestartCount=0` → never retried → stuck `Exited`). The old
>   recovery sampled the agent's `BoundUnderParent` ONCE, raced that rebind, recreated nothing, and burned
>   its boot-id one-shot. `processGuestBootChange` now **gates on the REAL live in-guest bind**
>   (`driveBindLive`: is `/mnt/felhom-drives/<drive>` an actual mountpoint in the controller's own `/mnt`
>   rslave `/proc/self/mountinfo`?) and **waits** for it (`pollLiveBinds`, bounded ~120s) before recreating
>   — including apps stuck `Exited` with a create-time mount failure (`shouldRecreateOnBoot` is
>   state-independent). The **guest-only reboot path** (which the host-reboot sweep never exercised) is now
>   covered; drives that never go live in the window are left to the drive-absent gate. `processGuestBootChange`
>   also runs on every periodic `driveGateLoop` tick now (idempotent, boot-id gated) so a momentarily-unreachable
>   agent right after a guest reboot no longer permanently strands recovery.
>   **Agent-path prerequisite (also v0.71.0):** the whole drive gate needs `cfg.LocalAPI.Endpoint` (the
>   per-guest agent local API). `bootstrap.MaybeIngest` now calls `ensureLocalAPI` on the already-configured
>   path — merging `local_api` from `bootstrap.json` into an existing controller.yaml that lacks it (seeded
>   before `local_api` existed) — because without it `agentClient()` returns "agent not configured" and the
>   entire gate + boot recovery silently die.
>   **v0.72.0 — FileBrowser convergence on boot-recreate:** FileBrowser is base-infra (it binds each
>   drive's `userdata` but has no `HDD_PATH`, so it is NOT in the drive-backed recreate set) — after a
>   host reboot its mounts could be stale (the early first-boot bring-up bound them before the drives went
>   live). `processGuestBootChange` now, **after** `pollLiveBinds` confirms the binds and the apps are
>   recreated, triggers `go s.SyncFileBrowserMounts()` so FileBrowser converges against the now-live drives.
>   The sync runs unconditionally (FileBrowser reflects the current bind state even if no app needed
>   recreating). The recreate loop is a pure `recreateDriveBackedApps(stacks, present, recreate, syncFB)`
>   that calls `syncFB` exactly once, after every recreate. Live-accepted over two real `felhom-pve`
>   reboots (FileBrowser non-stale, all drive-backed apps recovered, agent tolerated a `/dev/sdX` swap by UUID).
>
> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0), made ROLE-AWARE in v0.44.0, UX-polished in
> v0.45.0.** After the 8C
> de-privileging, the controller holds **no Proxmox/disk credentials and no destructive authority** — disk
> execution + the gate live entirely in the **host agent**. The drive UI is 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** (lock badge, NO destructive controls);
> the customer manages their own **user-data** drives with informed consent. The agent re-enforces role at
> wipe time — the UI lockout is defense-in-depth, not the gate.
> - **Overview** (`settings.html` ← `GET /api/disks`): styled **cards** (not a table) — name, mono
>   device/mount, 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 `system-bar`, from the agent's `total_bytes`/`used_bytes`). Eject/Wipe render **only** for
>   user-data drives mounted under `/mnt`.
>   - **(v0.45.0) Deterministic order** — `agentDisksListHandler` sorts the list server-side
>     (`sortDisksForView`): **user-data → system → backup** (then unrecognized), alpha by name within a
>     tier, so it no longer reorders on each reload (the agent's view iterates an unordered Go map).
>   - **(v0.45.0) Purpose + app-backing clarity (B4)** — `local` and `local-lvm` are both shown (not
>     collapsed); each card carries a plain-Hungarian **purpose description** keyed on the agent's
>     role/type, the app-backing storages are tagged (`local-lvm` → "Alkalmazás-rendszer"; user-data →
>     "Alkalmazás-adatok"), and a one-line tiering note above the list answers "which storage do the
>     apps use?". Pure presentation — role/type stay authoritative from the agent.
>   - **(v0.45.0) Register shortcut (B3)** — a mounted, **unregistered** user-data drive offers
>     **Regisztrálás** as its PRIMARY action: `POST /api/storage/register` → `registerStoragePath` records
>     the existing mount (no format, no eject) + FileBrowser-syncs. Leválasztás/Törlés stay secondary.
> - **Customer wipe/eject** — a **type-to-confirm** modal that names the deployed apps that break
>   (`GET /api/storage/impact` → `appsUsingPath`) and disables the destructive button until the **mount
>   name is typed exactly**. Wipe (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side
>   two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true` bound
>   to it). The agent refuses a protected device regardless of what the controller sends.
> - **Guided init** (`/settings/storage/init`, `POST /api/storage/init`, `web/storage_handlers.go`): format
>   → resolve the new fs UUID → `assign` → register. The selector lists **only user-data** targets. A
>   data-bearing user-data device now uses the **customer-confirmation** flow (type-to-confirm → re-submit
>   `confirmed:true` + durable id), NOT the `felhom-opsign` command. The opsign surface remains a fallback
>   only if a protected device somehow reaches init.
> - **Guided attach** (`/settings/storage/attach`, `POST /api/storage/attach`): non-destructive — resolve
>   the existing fs UUID → `assign` → register. Selector restyled to cards (user-data only).
> - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest
>   warning + the affected-app list (parity with wipe). **The eject is ROLE-GATED at the agent** (felhom-
>   agent v0.24.0): `POST /disks/eject` refuses to unmount a system/backup mount — the UI hiding the button
>   is defense-in-depth, not the control. Only user-data mounts are ejectable.
> - **`agentapi`** (`internal/agentapi`) is the pinned client to the agent local API: `Disks`/`AssignDisk`/
>   `EjectDisk`/`FormatDisk(…, confirmed, durableID)`; `DiskInfo.role`+capacity;
>   `FormatResult.{role,needs_confirmation,durable_id}`; `ErrNeedsConfirmation` (user-data) vs
>   `ErrFormatRefused` (system/backup). `FormatResult.PendingOp.OpsignCommand()` for the operator path.
>   - **(v0.74.0) Client lifecycle — ONE shared client, reused.** `Server.agentClient()` builds the
>     `agentapi.Client` once (memoized via `sync.Once`) and returns the same instance to all ~19 call
>     sites; the `http.Transport` is bounded + expiring (`MaxIdleConnsPerHost:2`, `IdleConnTimeout:90s`).
>     This replaced a per-call `agentapi.New(...)` that leaked one idle ESTABLISHED socket per call and
>     exhausted the ephemeral source-port range to the agent's `:8443` after ~5 days (EADDRNOTAVAIL).
>     Safe because `cfg.LocalAPI` is static per process (a config-apply triggers a graceful self-restart).
>   - **(v0.90.0) Channel health-check — `internal/channelhealth`.** A ~60s scheduler job
>     (`agent-channel-health`) probes the channel via `Server.ProbeAgentChannel` (the SAME memoized
>     client + `GET /storage` — not a fresh client) and classifies failures into
>     `up | down:<reason>` (pin_mismatch / unauthorized / unreachable / timeout / misconfigured /
>     construction_error). Transient reasons (refused/timeout) are **debounced** (N≥2 consecutive)
>     so a clean agent restart's ~1s blip doesn't page; pin/401/DNS/construction alert on the first
>     down. On a transition it fires an English **operator-only** event
>     (`Notifier.NotifyAgentChannelDown/Recovered`, hub 1h cooldown) and sets a Hungarian dashboard
>     banner (`AlertManager.SetAgentChannelAlert`). The first observation seeds state silently. This is
>     the controller half of the self-health story (the agent watches its own privileged capabilities;
>     the controller watches its link to the agent). A construction error (`agentClient()` can't build
>     — a latching config fault) is surfaced distinctly. Detection/surfacing only — it never touches the
>     pin, transport, or gate.
> - The **`StoragePath` registry** (`settings.go`: `AddStoragePath`/default/schedulable/label) is unchanged.
>   - **(v0.64.0) `AutoDiscoverStoragePaths` is now ADDITIVE** — it no longer bails when the registry is
>     non-empty; instead it registers only deployed-app paths missing from the registry. It never removes
>     or mutates an existing entry, never re-adds or reactivates a path already present in ANY state
>     (incl. `Decommissioned`), and never flips `IsDefault` (a new path defaults only if the registry has
>     no default at all). This is NOT auto-register-on-attach (that recommendation was rejected — manual
>     enrollment is by design); it only picks up drives that deployed apps already reference.
>   - **(v0.64.0) `InferStorageLabel` disambiguates the internal SSD** — a path whose basename is the
>     `felhom-data` namespace dir (the internal system volume, e.g. `/mnt/sys_drive/felhom-data`) now reads
>     **"Belső SSD (rendszer)"** instead of the colliding "Tárhely (felhom-data)". 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 via `SetStorageLabel`.
> - **(v0.65.0) Data migration** (`internal/stacks/migrate.go`) — move app data between drives, in-process
>   over the controller's `/mnt:/mnt:rslave` RW mount; crash-safe + resumable via a journal
>   (`<dataDir>/migration.json`). `MigrateAll(source,target)` moves the whole felhom-data namespace (every
>   app + a conflict-merge walk for non-app/customer content); `MigrateApp(app,target)` moves one app's
>   subtree (drive→drive AND SSD→drive). Pipeline: validate → stop → copy (`rsync -a --checksum`, additive,
>   **no `--delete`**) → verify (`rsync -ani --checksum`, zero pending) → flip+redeploy (`RedeployFromEnv`)
>   → cleanup. **CLEANUP — the only destructive step — is gated on every unit verified AND every app
>   redeployed.** Conflict-merge: skip-identical (checksum vs target + its `(N)` siblings), rename-on-differ
>   to lowest-free `<base>(N)<ext>`, never overwrite; idempotent. Single-flight + mutually exclusive with the
>   backup orchestrator. UI: `POST /api/storage/migrate{,-app}` + `GET /api/storage/migrate/status` (poll);
>   migrate-all on the settings page, per-app on the app-info page, shared Hungarian progress panel.
> - **(v0.65.0) Self-serve decommission** (`handleStorageDecommission`, `POST /api/storage/decommission`) —
>   retire a drive, non-destructive (never formats). Two choices (no partial): migrate-all-then-decommission
>   (runs `MigrateAll`; the migration done-hook soft-marks the source + calls the agent on success), or
>   decommission-anyway (type-to-confirm; stops the apps, keeps their `HDD_PATH` so they show "Hiányzó
>   tárhely"). Both end at `settings.SetDecommissioned` (soft marker retained — blocks A1 resurrection) +
>   `agentapi.Decommission` (agent v0.32.0: `POST /disks/decommission`, role-gated user-data, intent +
>   bind-prune + unmount). Re-enrolling a decommissioned drive (`registerStoragePath`) clears the marker +
>   restores `Schedulable`. A deployed app whose drive is decommissioned/disconnected/absent shows the
>   **"Hiányzó tárhely"** warning badge on the dashboard/stacks/app card.
>
> - **(v0.66.0) Userdata layout + shared-storage ownership convention** (`internal/appbackup/userdata.go`).
>   Each drive's felhom-data namespace gains a customer-facing **`userdata/`** tree (sibling of
>   `appdata/`/`backups/`) — the ONLY thing FileBrowser mounts. Skeleton:
>   `media/{movies,tv,music,audiobooks,books,comics,photos}`, `downloads`, `import/{paperless,calibre}`,
>   `roms`, `documents`. **Convention:** every userdata dir is group-owned by `SharedContentGID` (1000),
>   mode **2775** (setgid + group-rwx) — `EnsureUserdataDir` = MkdirAll → explicit `Chmod(ModeSetgid|0775)`
>   (MkdirAll's mode is umask-masked AND drops setgid) → chown group 1000. Setgid makes new files inherit
>   group 1000 so FileBrowser (uid 1000) + the content apps collaborate without permission collisions.
>   - **`USERDATA_PATH`** = `<HDD_PATH>/userdata` is injected into the compose env (the shared
>     `withUserdataPath`, used by BOTH `stackEnv` and `composeExecWithEnv` — the initial deploy builds env
>     from values, not app.yaml). Catalog media mounts use `${USERDATA_PATH}/...`.
>   - **Pre-create**: the full skeleton is created with the convention on `registerStoragePath` +
>     `syncFileBrowserMounts` (system + additional drives); a **deploy belt** in `composeExecCustomEnv`
>     (on `up`) pre-creates every `${USERDATA_PATH}/...` bind source the app declares, so Docker never
>     auto-creates a userdata dir as guest-root (covers apps not in the skeleton).
>   - **FileBrowser** mounts `<drive>/userdata` (was `appdata`) and runs as uid 1000 → it can create
>     folders + upload into the 2775 setgid tree (fixes the old permission-denied); app internals
>     (`appdata/`) are no longer browsable. **(v0.66.2)** its entrypoint is wrapped
>     `["sh","-c","umask 002; exec /home/filebrowser/filebrowser"]` so folders the customer creates come
>     out **2775 (group-writable)** — the gtstef image is a single Go binary that ignores a `UMASK` env
>     (verified), so the wrapper is the mechanism. Without it, customer folders were 2755 (group-read
>     only) and apps in group 1000 couldn't write into them.
>   - **Run-identity**: PUID/PGID-1000 apps (radarr/sonarr/calibre with `UMASK=002`) write **group-
>     writable**, so FileBrowser can fully manage their output. Root-only apps (jellyfin :ro; komga +
>     audiobookshelf RW after the `user:1000` fallback) write **group-readable** via setgid (FileBrowser
>     browses/reads, the app manages its own files).
>   - **Migration-safe**: `migrate.go`'s merge walk preserves the source dir's setgid + group (and
>     `copyFile` the full file mode + group), so the convention survives a whole-drive `MigrateAll`.
>
> The privileged controller-side disk subsections **below are historical** (the `internal/storage/*` scan/
> format code was removed in 8C — execution is the agent's now).

The storage subsystem handles the full lifecycle of external storage: detection, initialization, path registration, and data migration.

> **CURRENT (post-de-privileging + Impl-2b, v0.95.0):** the in-guest storage code below
> (`internal/storage/scan.go`, `format.go`, `attach.go`) is **retired** — all disk ops are delegated to
> the host agent via `internal/agentapi`. The two enrollment wizards (`/settings/storage/init`,
> `/settings/storage/attach`) now populate candidates from the agent's **raw-device scan**
> (`GET /api/disks/candidates` → agent Impl-2a, proxied by `agentDiskCandidatesHandler`): `initialize` =
> every unclaimed disk (blank or data-bearing), `attach` = the mountable-FS subset. The agent's
> unclaimed-disk filter (Impl-1 `claim.go`) is authoritative + fail-safe (never offers OS/enrolled/claimed
> disks), so the controller does NO client- or server-side filtering. Enrollment posts to the unchanged
> `/api/storage/init` (format via the agent's Impl-1 guarded `mkfs` → mount → bind → intent) or
> `/api/storage/attach` (mount + bind, no format). The legacy text below is kept for historical context.

#### Disk Scanning (`internal/storage/scan.go`)

- `ScanDisks()` uses `lsblk -J -b` for block device enumeration
- System disk detection via host fstab parsing (`/host-fstab`) + UUID resolution via `blkid`
- Partitions enriched with filesystem type, UUID, and label from direct `blkid` probing (Docker containers have incomplete udev cache)
- Returns `AvailableDisks` (non-system, non-loop, non-CDROM), `SystemDisks`, and `FormatablePartitions` (empty partitions on system disks that are safe to format)
- Handles NVMe (`nvme0n1p1`), SCSI (`sdb1`), and eMMC (`mmcblk0p1`) naming

#### Disk Initialization Wizard (`internal/storage/format.go`)

A step-by-step UI at `/settings/storage/init`:

1. **Scan** — Lists available disks with model, size, partition info
2. **Select** — User picks a disk and enters a mount name (e.g., `hdd_1`)
3. **Confirm** — User types "FORMAZAS" to confirm destructive operation
4. **Format pipeline**: `wipefs` → `sfdisk` (GPT) → `mkfs.ext4` → `blkid` UUID → backup fstab → append UUID-based fstab entry → mount → `findmnt` verification → `chown 1000:1000` → create `felhom-data/` and `Dokumentumok/` subdirectories
5. Auto-registers new storage path in settings.json
6. Smart partition detection: skips repartitioning for existing empty partitions

Safety guards: system disk detection, mount path conflict check, confirmation required, progress channel for real-time UI feedback.

**System-disk partition formatting:** When the system disk has an empty partition (no filesystem, not mounted, not used for /, /boot, /boot/efi, or swap), the init wizard detects it via `FormatablePartitions` in the scan result and offers to format just that partition. Uses `IsSystemPartition()` (granular per-partition check via fstab) instead of `IsSystemDisk()` (whole-disk block), so sda1 can be formatted while sda3 (root) remains protected.

#### Attach Existing Drive Wizard (`internal/storage/attach.go`)

A step-by-step UI at `/settings/storage/attach` for drives that already have a filesystem (e.g., a previously used ext4 drive). Unlike the init wizard, this does **not** format the drive — existing data is preserved.

**Problem solved:** Mounting a whole drive at `/mnt/<name>` would mix existing user data with the controller's directory structure (`felhom-data/`, `Dokumentumok/`, etc.). The bind-mount approach isolates the controller's working directory from other data on the drive.

1. **Scan** — Lists available disks, filtered to partitions that have an existing filesystem (FSType != "")
2. **Mount raw** — Partition is mounted read-only at a hidden staging path (`/mnt/.felhom-raw/<label>`)
3. **Browse** — Directory browser shows the drive's contents. User can navigate and create a new folder (e.g., `felhom_data`)
4. **Configure** — User enters a mount name and display label. Warning: mount path is immutable until detached
5. **Finalize** — Bind-mounts the selected subfolder at `/mnt/<name>`. Two fstab entries are created (both with `nofail`):
   - Raw mount: `UUID=<uuid> /mnt/.felhom-raw/<x> <fstype> defaults,nofail,noatime 0 2`
   - Bind mount: `/mnt/.felhom-raw/<x>/<subfolder> /mnt/<name> none bind,nofail 0 0`
6. Sets permissions (`chown 1000:1000`), creates `felhom-data/` and `Dokumentumok/` subdirectories
7. Auto-registers the storage path in settings.json + syncs FileBrowser mounts

Cancel at any point cleans up the temporary raw mount. The bind mount path (`/mnt/<name>`) is a real mount point, so all existing code (disk usage, IsMountPoint checks, etc.) works unchanged.

#### Storage Path Registry (`internal/settings/settings.go`)

Multiple external storage paths supported with:
- **Label**: Human-readable name (editable inline)
- **Default flag**: New deploys use this path by default
- **Schedulable flag**: Path appears in deploy dropdown
- **Disconnected state**: `Disconnected`, `DisconnectedAt`, `StoppedStacks` — set by watchdog or safe-disconnect API, cleared on reconnect
- **Auto-discovery**: On startup, scans deployed apps' `HDD_PATH` values and registers unknown paths
- Thread-safe CRUD: Add, Remove, SetDefault, SetSchedulable, SetLabel, SetDisconnected, ClearDisconnected

#### Data Migration (`internal/storage/migrate.go`)

Move app data between storage paths (e.g., SSD → HDD, HDD → new HDD):

1. Validate: stack exists, deployed, has HDD data, target differs from source
2. Estimate total size, check free space on target
3. Stop the application
4. `rsync -a --info=progress2` per mount path with real-time progress parsing
5. Update `app.yaml` HDD_PATH to new location
6. Start the application
7. **Rollback on failure**: reverts config, restarts on old storage

Progress UI at `/stacks/{name}/migrate` with byte counter and percentage.

#### Stale Data Cleanup

After migration, the deploy page detects leftover data on previous storage paths:
- Shows path, size, and a delete button
- Two-step confirmation required
- Protected paths (`felhom-data/`, `felhom-data/appdata/`, `felhom-data/backups/`, `media/`, `Dokumentumok/`) cannot be deleted

#### FileBrowser Mount Sync

When storage paths are added or removed, `syncFileBrowserMounts()` auto-regenerates FileBrowser's `docker-compose.yml` with volume mounts for all registered paths. It then **recreates the container only when the generated `config.yaml` or compose actually changed** (v0.82.0, F2) — gated by the pure helper `fbNeedsRecreate(oldCfg,newCfg,oldCompose,newCompose)`, which compares the on-disk content captured before the writes against the final content read after them (so the integrations' `ReapplyConfigForTarget` edits count). When nothing changed (a controller restart, a no-op sync) it issues a plain `up -d --remove-orphans` that does **not** bounce the running FileBrowser. The restore-mode DB reset (`down -v`) still forces a recreate.

#### Storage Watchdog (`internal/monitor/watchdog.go`)

Continuously monitors registered storage paths for disconnection/reconnection (primarily USB drives):

- **Probe loop**: `ProbeStoragePath()` calls `syscall.Statfs()` with 3-second timeout in a goroutine. Runs every 5s per connected path, 30s per disconnected path.
- **Debouncing**: 3 consecutive probe failures required before declaring a drive disconnected (prevents false positives from transient I/O).
- **Disconnect reaction** (automatic, ~15s detection):
  1. Stops all deployed stacks whose `HDD_PATH` is under the disconnected drive (skips protected stacks)
  2. Persists `Disconnected`, `DisconnectedAt`, `StoppedStacks` to `settings.json`
  3. Lazy-unmounts stale VFS entries (`umount -l`) — for attach-wizard drives, unmounts bind first, then raw
  4. Fires alert refresh (red banner on all pages), notification (`storage_disconnected`), and immediate hub report push
- **Auto-reconnect** (for UUID-based fstab entries):
  1. Checks `/host-dev/disk/by-uuid/<uuid>` for device reappearance
  2. Cleans stale mounts, then `mount -T /host-fstab <path>` (raw + bind for attach-wizard drives)
  3. Verifies with a post-mount probe
  4. Runs `restic unlock` if stale lock files exist
  5. Validates `StoppedStacks` (filters to actually-stopped stacks), clears `Disconnected` flag
  6. Fires alert refresh, notification (`storage_reconnected`), hub report push

**Safe disconnect UI** (manual, Settings page):

- "Leválasztás" button shown for USB drives (detected via sysfs symlink path containing `/usb`)
- Confirmation dialog lists affected apps
- Flow: stop apps → `sync` → `umount` (fallback `umount -l`) → mark disconnected → notification
- Disconnected card: dashed border, red badge, timestamp, stopped apps list, "Csatlakoztatás" (reconnect) button
- After reconnect: "Alkalmazások indítása" button to restart auto-stopped stacks

**USB detection** (`system.IsUSBDevice`): Reads `/host/sys/block/<disk>` symlink — if target path contains `/usb`, it's a USB device. The `removable` sysfs flag is unreliable for USB HDDs (returns 0). USB drives show an orange "USB" badge on their storage card alongside Aktív/Alapértelmezett badges (v0.27.2). Handles findmnt bind-mount suffix stripping (`/dev/sdb1[/subdir]` → `/dev/sdb1`) for attach-wizard drives (v0.32.5).

**Backup guards**: Nightly DB dumps, restic snapshots, and cross-drive backups all skip disconnected, removed, and inactive drives with WARN log (not treated as failures). Cross-drive `RunAppBackup()` returns nil (not error) for unavailable destinations — prevents noisy error aggregation in scheduled runs (v0.32.5).

**Tier2 destination unavailable (v0.32.5)**: When a Tier2 backup destination drive is disconnected, removed from storage, or deactivated (Inaktív), the backup page shows:
- Yellow status dot with "2. mentés szünetel" tooltip (not red)
- Warning badge: "Cél meghajtó leválasztva" (disconnected/removed) or "Cél meghajtó inaktív" (deactivated)
- Grayed-out last-run info and backup contents
- Hidden "Futtatás most" button (prevents futile manual triggers)
- "Beállítás" link preserved for reconfiguration
- Tier2 config persists — backups auto-resume when drive returns/reactivates
- Detection: `IsStoragePathKnown()` catches removed paths, `IsStoragePathSchedulable()` catches inactive/disconnected/decommissioned

**UI integration**: Disconnected drives show with hatched red bars on dashboard, monitoring, and backup pages. Per-app backup rows show "Meghajtó leválasztva" badge. Health check emits warnings for disconnected paths.

---

### 5. Monitoring & Health

#### System Health Checks (`internal/monitor/healthcheck.go`)

`RunHealthCheck()` evaluates multiple subsystems and returns a `HealthReport` with status (`ok`/`warn`/`fail`):

| Check | Warning | Critical |
|-------|---------|----------|
| Disk usage (SSD/HDD) | >= 90% | >= 95% |
| Memory | available < 512MB | available < 256MB |
| CPU temperature | >= 75C | >= 85C |
| Docker daemon | — | unreachable |
| Protected containers | — | not running |
| Storage paths | not a mount point (data on SSD), drive disconnected | path inaccessible, disk >= 95% |

Backup destination validation (`CheckBackupDestination`) has tiered checks:
- Path doesn't exist → critical/blocked
- Not writable → critical/blocked
- Same block device as root → warning (data on system drive)
- Disk >95% full → critical/blocked
- Disk >90% full → warning

#### Healthchecks.io Integration (deprecated)

Legacy pinger (`internal/monitor/pinger.go`) still runs for backward compatibility but is no longer the primary monitoring mechanism. Monitoring is now handled by the Hub event system (see [Notifications](#5-notifications)). A deprecation log is emitted on startup if ping UUIDs are configured.

#### Metrics Store (`internal/metrics/`)

- **SQLite with WAL mode** for concurrent reads during collection
- **System metrics**: CPU%, memory (total/used/available), temperature, load average — collected every 60 seconds
- **Container metrics**: CPU%, memory, network I/O, block I/O per container
- Downsampled queries for chart time ranges (1h, 6h, 24h, 7d, 30d)
- 30-day auto-prune via daily scheduler job

#### Monitoring Page

Full-page system monitor at `/monitoring`:
- **System Overview**: hostname, OS, kernel, CPU model/cores, uptime
- **System Metrics Charts**: 4 line charts (CPU, Memory, Temperature, Load) in 2x2 grid
- **Memory Distribution Bar**: stacked bar showing per-container memory usage, OS/system overhead, and free memory (real-time from `/proc/meminfo` + container stats)
- **Container Resources**: horizontal bar charts (CPU% and Memory per container)
- **Per-container Detail**: click-to-expand historical charts
- **Hub Connection Status**: shows Hub URL, customer ID, connection state (connected/unreachable), last successful push, last error

Chart.js 4.4.7 embedded locally (works in offline environments), dark theme matching site design.

#### Host (Proxmox box) Health — agent-proxied (slice 9, `internal/agentapi` + `agent_host_metrics_handler.go`)

The de-privileged controller (slice 8C) sees only its own cgroup and cannot read the host. The top card of `/monitoring` ("Szerver állapota (gazdagép)") instead shows the **real Proxmox box**, proxied from the host agent's `GET /host/metrics`:
- **Host block**: CPU% + load average, memory used/total, **CPU/chassis temperature** (or "n/a" when the hardware exposes no sensor — graceful-null), uptime.
- **Per-storage capacity**: a used/total bar per host storage target, with thin-pool fill (a full lvmthin pool corrupts every guest on it) and disk SMART temperature/wear.

Path: `GET /api/host-metrics` → `Client.HostMetrics()` (leaf-pinned, per-guest-token agentapi client) → agent `GET /host/metrics`. Host-wide and token-authed (assumption: **one customer per host** — the home-server model). It is a **live** fetch (a fresh agent collect, not the 15-minute hub snapshot), so the page polls it every **8 s** while open. When the agent is unconfigured/unreachable the card shows a "nem elérhető" banner; the controller's own metric charts are unaffected.

**Storage-bar ordering + labels (v0.57.0):** the agent enumerates storages via `pvesm` in a
non-deterministic order, so the per-storage capacity list (`#host-storage-bars`) reordered on every poll.
`enrichHostStorageTargets` (`agent_host_metrics_handler.go`) sorts the response **server-side** —
user-data (`usb`/`local-dir`) → system+apps (`lvmthin`/`lvm`) → builtin `local` → backup
(`pbs`/`nfs`/`cifs`) → other, alphabetical by id within a tier — and attaches a friendly Hungarian
`label` + one-line `purpose` per entry (rendered by `monitoring.html`, with the raw PVE id shown muted).
**Display labels only — the PVE storage ids are never renamed** (vzdump/PBS configs reference them by
name). This is distinct from the server-rendered, user-data-only `buildStorageBars` "Tárhely" list.

#### Alert System (`internal/web/alerts.go`)

State-based alerts displayed on all pages:
- Sources: health issues, Hub connection status, backup disabled, storage disconnected, update available
- Hub alerts: `hub-disabled` (warning) when Hub not enabled, `hub-unreachable` (error) when last push failed and no success in 30 min
- Sorted by severity (error > warning > info), capped at 5 visible
- Refreshed every 5 min + on startup + on storage state changes

---

### 6. Notifications

#### Hub Event System (`internal/notify/notifier.go`)

The controller pushes structured events to the Hub's `/api/v1/event` endpoint. The Hub handles notification dispatch, cooldown management, and dead man's switch detection.

**Core method:** `PushEvent(eventType, severity, message, details)` — non-blocking goroutine, 2 retries with 3s backoff, never blocks the caller.

#### Event Types

| Event Type | Severity | Trigger |
|------------|----------|---------|
| `backup_failed` | error | Nightly restic backup fails |
| `db_dump_completed` | info | Nightly database dumps succeed |
| `db_dump_failed` | error | Nightly database dumps fail |
| `backup_integrity_ok` | info | Weekly `restic check` passes |
| `backup_integrity_failed` | error | Weekly `restic check` fails |
| `crossdrive_completed` | info | Cross-drive secondary backup succeeds |
| `crossdrive_failed` | error | Cross-drive secondary backup fails |
| `health_degraded` | warning | Health status degrades (ok→warn) |
| `health_critical` | error | Health status critical (any→fail) |
| `health_recovered` | info | Health status recovers (fail/warn→ok) |
| `disk_warning` | warning | Disk usage crosses 90% |
| `disk_critical` | error | Disk usage crosses 95% |
| `storage_disconnected` | error | Storage drive physically removed |
| `storage_reconnected` | info | Storage drive reconnected |
| `controller_started` | info | Controller process starts |
| `controller_updated` | info/error | Self-update success or failure |
| `app_deployed` | info | New app deployed via API |
| `app_removed` | info | App removed via API |
| `disaster_recovery_started` | warning | DR restore begins |
| `disaster_recovery_completed` | info/error | DR restore finishes (success/partial) |

Each event carries typed detail structs (e.g., `BackupDetails`, `DiskDetails`, `HealthDetails`) serialized as JSON.

#### Default Enabled Events

Events the customer receives notifications for (configurable in settings):
`backup_failed`, `db_dump_failed`, `disk_warning`, `disk_critical`, `storage_disconnected`, `node_down`, `health_critical`, `expected_backup_missed`, `expected_dbdump_missed`

#### Preference Sync

Notification preferences (email, enabled events, cooldown hours) are:
- Stored locally in `settings.json`
- Synced to Hub on save and on controller startup via `POST /api/v1/preferences`
- Hub sync failure doesn't block local save

---

### 7. Update Management

#### App Catalog Sync

- Periodic `git fetch` + `git reset --hard` of the app catalog repo
- Content-hash comparison prevents unnecessary file writes
- Post-sync stack rescan detects new/changed apps immediately
- **Stale lock recovery**: automatically removes `.git/index.lock`, `.git/shallow.lock`, and `.git/HEAD.lock` before each fetch — prevents permanent sync failures after interrupted operations (e.g. container restart mid-sync)

#### Planned Update Classifications

| Marker | Behavior |
|--------|----------|
| No marker | Optional — shown on dashboard, customer clicks "Update" |
| `UPDATE_REQUIRED=true` | Mandatory — auto-applied during next update window |
| `UPDATE_SECURITY=true` | Critical — applied immediately |

#### Controller Self-Update (`internal/selfupdate/`)

The controller can update itself to the latest registry version with a one-click Settings button. In the
LXC architecture there is **no in-container compose** to drive (the old `docker compose -f
/opt/docker/felhom-controller/docker-compose.yml up -d` path does not exist in the guest — it produced
"docker-compose.yml nem elérhető"). Instead (Phase 1, v0.85.0) the controller **pulls** the target image
in-guest and delegates the container **swap to the host agent**, which owns the restart + health-verify +
**rollback**.

##### How It Works

```
1. Check Gitea Docker Registry V2 API for the latest semver tag (queryRegistry, BasicAuth).
2. If newer than current (ldflags Version): docker login --password-stdin → docker pull <image>
   → docker logout — IN-GUEST over the shared docker socket (token via stdin, never argv).
3. Delegate to the host agent: agentapi.SwapController → POST /controller/swap {image} (202). The agent
   (external to this container) rewrites /etc/felhom-controller-image, restarts
   felhom-controller-bootstrap.service, polls the new controller to healthy, and ROLLS BACK to the
   previous image if it doesn't come up. The controller never docker-rm/recreates itself.
4. On startup the new container reads update-state.json → VerifyStartup marks success (current==target)
   or failure (rollback → version mismatch). The Settings button polls /api/health and reloads.
```

The button is latest-only (no version picker) and opt-in. No host agent wired (un-provisioned guest) →
self-update unavailable.

##### Phase 2 — managed updates: the version FLOOR (v0.86.0)

On top of the opt-in button, the controller now honors an operator-enforced **minimum version (FLOOR)**.
The hub returns the customer's *effective* floor (per-customer override else a global default) on the
**report ACK** (`min_controller_version`, alongside `latest_version`). The controller's report pusher
(`internal/report/pusher.go`, `OnPushResponse`) hands the floor to the updater (`SetFloor`) and calls
`MaybeAutoUpdate()` — **on the existing report cycle, no new timer/endpoint**:

- If the box is **below** the floor it **auto-updates to the floor** (not latest) by reusing the Phase 1
  flow above (`performUpdate`, `initiatedBy="auto-floor"`) — same pull → agent swap → rollback. No
  customer click.
- **At/above** the floor: nothing (it does **not** chase latest — that's the button's job).
- Guards: dev build / no agent / backup running → skip; floor must be **pullable** (floor ≤ latest
  available; floor > latest → warn + do nothing); one attempt per below-floor condition (in-memory flag
  + persisted `update-state.json`) → **no flapping/storm**.
- Settings UI shows "Minimális verzió (üzemeltető): X" and, during an auto-update, the same restart-poll
  panel as the button.

The floor is the **auto-target** (the operator raises it for a controlled fleet rollout); latest stays
the customer's manual opt-in. Floor source + operator UI are hub-side (felhom-hub v0.15.0). **No agent
change — Phase 2 reuses the Phase 1 `POST /controller/swap`.**

##### Pull-based config-refresh (v0.94.0)

The same **report ACK** also carries a per-customer **`config_version`** (a hub-side stored counter,
bumped on every config save; felhom-hub v0.26.0). This is how an operator config edit reaches a running
box — **the hub never connects into the box** (it replaced the retired inbound "Push Config"). Wired in
`OnPushResponse` beside the floor reconcile (`internal/report/config_refresh.go`, `ConfigRefresher.Reconcile`):

- **Changed** vs. the last-applied version (`settings.applied_config_version`) → `bootstrap.RefreshConfig`
  re-pulls `controller.yaml` from the hub and rewrites it, **re-merging the per-guest `local_api`** from
  `bootstrap.json` (reuses the first-boot pull machinery; overwrites `controller.yaml` since the hub is its
  source of truth; **never touches `settings.json`**) → record the new version → **graceful self-restart**
  (`api.GracefulSelfRestart` → `os.Exit(0)` → Docker `restart: unless-stopped` re-reads the new config).
- **First-ever** ACK (nothing recorded) → record the baseline **without restarting** (the first-boot pull
  already has the current config).
- **Unchanged** version → no-op (so no restart storm — after a refresh `applied == ACK`).
- **Failed pull** → keep the current config, do **not** record/restart, retry next report cycle.
- Record-before-restart so the restarted process sees the version applied and doesn't loop. Only the
  `felhom-controller` container restarts; customer app stacks are untouched. (A config apply rotates
  `web.session_secret`, so dashboard sessions are invalidated — same as the old Push-Config path.)

##### Design Philosophy

- **No automatic rollback** — follows the Watchtower pattern (24k+ GitHub stars, no rollback). Docker's `restart: unless-stopped` policy is the crash safety net. The Hub's dead man's switch detects when the controller goes down.
- **Audit state file** — `update-state.json` in the data volume records every update attempt (previous version, target version, initiator, result). Operators can SSH in and revert using `PreviousImage` from this file.
- **Backup-aware** — refuses to start an update while a backup is in progress (`backupRunning()` guard).

##### Package Structure

| File | Purpose |
|------|---------|
| `version.go` | `ParseVersion("X.Y.Z")` → `Version{Major,Minor,Patch}`, `Compare()` returns -1/0/1. Hand-rolled, no external deps. Rejects "dev" and "latest". |
| `state.go` | `UpdateState` struct persisted as JSON. `LoadState()`, `SaveState()` (atomic: `.tmp` + rename), `ClearState()`. Status values: `"pending"`, `"success"`, `"failed"`. |
| `updater.go` | Core `Updater` struct. Registry check via HTTP GET to `gitea.dooplex.hu/v2/admin/felhom-controller/tags/list` with Basic Auth (git username/token). Update trigger: `docker pull` → compose file regex replace → `docker compose up -d`. Thread-safe with `sync.Mutex`. |

##### Update Trigger Flow

1. **Guard checks:** concurrent update lock, dev version check, backup running check, compose file accessible
2. Write `update-state.json` with status `"pending"` (audit trail)
3. `docker pull <image>:<targetVersion>`
4. Read compose file → replace image tag via regexp → atomic write (`.tmp` + rename)
5. `docker compose -f /opt/docker/felhom-controller/docker-compose.yml -p felhom-controller up -d`
6. Docker kills the current container, starts the new one

##### Startup Verification

Called once from `main.go` before the scheduler starts:
1. Load `update-state.json` — if missing or status != `"pending"`, nothing to do
2. Compare running `Version` with `state.TargetVersion`
3. **Match** → mark `"success"`, notify via hub
4. **Mismatch** → mark `"failed"`, notify via hub
5. No rollback attempt — operator reverts manually if needed

##### Auto-Update Scheduling

Two separate scheduler jobs prevent interference with backups:

| Job | Type | Default | Purpose |
|-----|------|---------|---------|
| `selfupdate-check` | `sched.Every` | 6h | Check registry, cache result (for UI). Never triggers update. |
| `selfupdate-auto` | `sched.Daily` | 04:30 | If auto-update enabled + update available + backup not running → trigger. |

The auto-update time (`config.SelfUpdate.AutoUpdateTime`, default `"04:30"`) is deliberately separate from the backup window (02:30-~04:00) to avoid collisions. The `backupRunning()` guard is the hard safety check — if backups run long past 04:30, the update is skipped and retried the next day.

An initial version check fires 30s after startup so the Settings page shows version info quickly.

##### Compose File Access

The controller needs write access to its own `docker-compose.yml`. This is achieved via Docker volume mount ordering:

```yaml
volumes:
  # 1. Directory mount — gives access to compose file + config
  - /opt/docker/felhom-controller:/opt/docker/felhom-controller
  # 2. Read-only override — prevents accidental config writes
  - /opt/docker/felhom-controller/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro
  # 3. Named volume override — persistent data in Docker-managed volume
  - controller-data:/opt/docker/felhom-controller/data
```

##### API Endpoints

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/selfupdate/status` | Session or API key | Current status (cached, no network call) |
| POST | `/api/selfupdate/check` | Session or API key | Force registry check, return result |
| POST | `/api/selfupdate/update` | Session or API key | Trigger update (async, returns immediately) |

Self-update endpoints accept either session auth (for UI) or hub API key as bearer token (for external triggering from build scripts or hub). This enables the post-v0.16.0 deploy workflow:

```bash
# After building + pushing new image:
curl -s -X POST https://felhom.demo-felhom.eu/api/selfupdate/update \
  -H "Authorization: Bearer <HUB_API_KEY>"
```

##### Settings Page UI

The "Verzió és frissítés" card on the Settings page (`/settings`) shows:
- Current version and latest available version
- "Frissítés elérhető" (update available) badge
- Last check time and any errors
- Auto-update status with configured time
- Last update result (success/failed/pending)
- **Buttons:** "Frissítés keresése" (check) + "Frissítés telepítése" (apply)

After triggering an update, the page polls `/api/health` every 3s and reloads when the new container responds.

A global info-level alert ("Új controller verzió elérhető") appears on all pages when an update is available, linking to the Settings page.

##### Configuration

```yaml
self_update:
  enabled: true
  check_interval: "6h"          # How often to check registry
  image: "gitea.dooplex.hu/admin/felhom-controller"  # Default
  auto_update: false             # Set true for unattended updates
  auto_update_time: "04:30"     # When to auto-apply (after backups)
  health_timeout_seconds: 60    # Reserved for future use
```

##### Edge Cases

| Scenario | Behavior |
|----------|----------|
| `Version == "dev"` | `ParseVersion` returns error → no updates reported, trigger refused |
| Registry unreachable | Log warning, return error in check result. No crash. |
| No registry credentials | Return error "Registry hitelesítő adatok hiányoznak" |
| Compose file not writable | Refuse update before doing anything |
| Backup running | Refuse with "Mentés fut, próbálja később" |
| Concurrent update | Mutex prevents duplicates: "Frissítés már folyamatban" |
| Bad update (crash loop) | Docker restarts container. State file stays "pending". Operator SSH-reverts using `PreviousImage`. |
| Corrupt state file | Treated as "no pending update", logged, deleted |

---

### 8. Authentication & Settings

#### Session Auth (`internal/web/auth.go`)

- bcrypt password verification with configurable source priority: `settings.json` → `controller.yaml` → no auth (open access)
- 7-day session duration with random 32-byte hex tokens
- `?next=` redirect after login preserves the page the user was visiting
- Session cleanup every 15 minutes
- All sessions invalidated on password change
- Conditional logout link (hidden when auth is disabled)
- Each session stores a dedicated CSRF token (separate 32-byte random value) alongside the session token

#### CSRF Protection (`internal/web/csrf.go`)

Synchronizer-token CSRF protection on all browser-facing state-mutating endpoints.

**How it works:**
- `CsrfProtect` middleware wraps all route handlers in `main.go`
- Safe methods (GET, HEAD, OPTIONS) pass through without validation
- For POST/DELETE/PATCH: reads token from `_csrf` form field or `X-CSRF-Token` request header; constant-time compares against the session's stored CSRF token
- On rejection: JSON `{"ok":false,"error":"CSRF token missing or invalid"}` for `/api/` paths; HTTP 403 text page for UI routes
- Logs: `[WARN] CSRF rejected: METHOD /path from addr (reason)`

**Exempt paths (no CSRF check):**
- Requests with `Authorization: Bearer ...` header — hub→controller API calls (selfupdate, config/apply). Browsers cannot auto-send Bearer headers, so cross-site requests are impossible on these endpoints.
- Auth-disabled mode (`authEnabled() == false`) — CSRF is meaningless when there is no session.

**Token delivery to templates:**
- `executeTemplate(w, r, name, data)` wrapper in `server.go` auto-injects `CSRFField` (`template.HTML` hidden `<input>`) and `CSRFToken` (raw string) into every page's data map
- `layout.html` emits `<meta name="csrf-token" content="{{.CSRFToken}}">` and defines `csrfHeaders()` JS function in `<head>` (before page scripts)
- Forms: `{{.CSRFField}}` (or `{{$.CSRFField}}` inside `{{range}}` loops — outer scope required)
- JS `fetch()` calls: `headers: csrfHeaders()` — returns `{'X-CSRF-Token': metaContent}`
- Dynamically-created JS forms: read token from `document.querySelector('meta[name="csrf-token"]').content`
- `navigator.sendBeacon()` replaced with `fetch(..., {keepalive: true})` where used — `sendBeacon` cannot send custom headers

#### Settings Persistence (`internal/settings/settings.go`)

Runtime-mutable settings in `settings.json` (separate from infrastructure config):

| Section | Contents |
|---------|----------|
| `password_hash` | bcrypt hash override |
| `notifications` | email, enabled events, cooldown hours |
| `db_validations` | per-DB dump validation results (survives restarts) |
| `app_backup` | per-app map: enabled flag, cross-drive config (method, dest, schedule, runtime status) |
| `storage_paths` | registered paths with label, default flag, schedulable flag, disconnected state |
| `cross_drive_restic_password` | auto-generated restic password for cross-drive repos |

All public methods use `sync.RWMutex`. File writes are atomic (`.tmp` + rename).

#### Settings Page (`/settings`)

Five sections:
1. **System config** — read-only display of `controller.yaml` values
2. **Version & update** — current/latest version, check/update buttons, auto-update status, last update result
3. **Storage paths** — add/remove, edit labels, set default, toggle schedulable, per-path app list with sizes, safe disconnect/reconnect for USB drives
4. **Password change** — current + new + confirm, min 8 chars
5. **Notifications** — email, event checkboxes, cooldown hours, test email button

---

### 9. Central Hub Reporting

#### Report Push (`internal/report/`)

Periodic JSON push (default every 15 min) to the central felhom-hub service:
- System: hostname, OS, CPU, memory, disk usage, uptime
- Containers: running/stopped counts, per-container CPU/memory
- Backup: last DB-dump run, success (disk-tier backup is the host agent's; restic password is no longer reported — removed v0.69.0)
- Health: current status, issues, warnings
- Stacks: deployed apps with versions and states
- Config hash: SHA256 of `controller.yaml` for Hub-side config comparison
- **Geo-restriction (always present, v0.70.0):** `geo_restriction` is always populated — `Enabled=false` with an empty country list when never configured — so the Hub always renders the geo section ("Inaktív" when off) instead of hiding it. `buildGeoRestrictionReport` in `internal/report/builder.go`.
- **App telemetry** (v0.28.0+): Per-stack memory (current/avg/peak) and CPU averages from the last 15 minutes of metrics data, plus log scan results (error/warning counts with deduplicated issues). Only non-protected, deployed stacks are included. Backward-compatible: old Hub versions silently ignore this field.
- **Controller telemetry** (v0.32.4+): The controller's own container (`felhom-controller`) is included as a special entry in the `app_telemetry` array. Its memory/CPU metrics come from the same metrics collector, and its log warnings/errors are scanned via `docker logs` using the same pipeline as app containers. This reuses all existing Hub telemetry infrastructure (memory trend charts, known issues, fleet aggregation) with zero Hub-side changes.
- **DR recipe — customer + apps half (v0.73.0):** `dr_recipe` is the controller half of the secret-free reconstruction recipe (`SPIKE-dr-recipe-2026-06-16.md`) that complements escrow (keys) + PBS/restic (bytes). `BuildDRRecipeAppHalf` (`internal/report/dr_recipe.go`) emits `{recipe_version, customer{id,display,domain}, apps[]}`; each deployed, non-protected app contributes `AppRecipe{catalog_ref, enabled, storage_bindings}` where bindings are parsed from the compose (`${HDD_PATH}`/`${USERDATA_PATH}` volume binds → `{container_path, drive, subpath}`, e.g. romm → `felhom-flash:userdata/roms`). **THE BOUNDARY:** the emitter is the enforcement point — it ships an explicit allowlist of those three fields and reads NOTHING from `AppConfig.Env`, so no `ENC:`/token/password can leak (allowlist, not denylist → new fields excluded by default). The load-bearing `TestBuildAppRecipe_NoSecrets` + its red-proof companion live here. The hub assembles this half with the agent's storage/guest/PBS half into one customer recipe. `recipe_version=1`, ignore-unknown on read.

Bearer token authentication, 3-attempt retry with 5-second backoff. Push status tracked via `PushStatus` struct (LastAttempt, LastSuccess, LastError, consecutive failures) — used by the monitoring page and alert system to show Hub connection health.

**Immediate report push on geo change (v0.70.0):** besides the periodic cycle, a successful geo settings save and a successful manual geo sync fire an **out-of-band, non-blocking** report push (`Router.reportPushNow`, wired in `main.go` to `BuildReport`+`Push` in a goroutine), so the Hub reflects the new geo state / clears a stale `last_sync_error` within seconds instead of after the next ~15-min cycle. Currently scoped to the geo handlers; the same seam can be reused for other settings later.

#### Config apply + self-restart (`internal/api/router.go`, `internal/api/selfrestart.go`)

`POST /api/config/apply` (Hub-authed) writes a new `controller.yaml`, but the new config only takes effect on **restart** — singletons such as the Cloudflare client are built once at startup (so a rotated CF API token would otherwise keep failing). Behaviour (v0.70.0):

- **No-op guard:** if the pushed body is byte-identical to the current file, do nothing — no rewrite, no restart (the Hub may re-push idempotently; never flap on a no-op).
- Otherwise: write `0600`, respond `200` (flushed), then **gracefully self-restart** — `gracefulSelfRestart` waits ~500 ms for the response to flush, then `os.Exit(0)`. The container runs `restart: unless-stopped`, so Docker brings it back with the fresh config, and a startup report is pushed. The exit sits behind an injectable seam (`Router.restart` / `SetRestarter`) for unit testing.

**Manual restart (v0.70.0):** `POST /api/selfrestart` (session auth + CSRF via the `/api/` mount) runs the same helper — surfaced as the **"Vezérlő újraindítása"** button on the settings page (confirm → POST → poll `GET /` every 2 s → reload), so a customer can recover the controller without rebooting the whole guest.

**Full-server restart (v0.81.0):** `POST /api/server/reboot` (session auth + CSRF) reboots the **whole guest** via the host agent's `GuestReboot` primitive (`HandleServerReboot` in `internal/web/storage_handlers.go`, delegating to the testable `serverReboot` core; agent reboots detached + returns 202). Surfaced as a separate **"Kiszolgáló újraindítása"** settings card alongside the controller-only restart, reusing the same `pollRestart()` reload loop. It replaces the retired drive-activation banner (v0.81.0): in the intermediary-mount model an enrolled drive binds **live** into the running guest, so storage no longer needs a reboot to activate — this button is purely a deliberate full-system restart.

#### App Telemetry (`internal/metrics/telemetry.go`, `internal/metrics/logscanner.go`, `internal/report/telemetry.go`)

Each report push now includes per-app telemetry data:

**Metrics collection** (`telemetry.go`):
- `MetricsStore.GetContainerTelemetry(since)` aggregates container-level memory (avg, peak, current) and CPU averages from the `container_metrics` SQLite table for the last 15 minutes.

**Log scanning** (`logscanner.go`):
- `ScanContainerLogs(containerNames, since, logger)` runs `docker logs --since=15m --tail=1000` sequentially on all non-protected deployed containers.
- Classifies lines by keyword match (errors: `error`, `fatal`, `panic`, `crit`, `oom`, `killed`, `exception`, `traceback`; warnings: `warn`, `warning`) on the first 5 words (case-insensitive).
- Deduplicates via fingerprinting: strips ANSI escape codes, ISO timestamps (with timezone offsets), and syslog timestamps (including mid-line); replaces 6+ digit numbers with `<N>`, 8+ char hex with `<HEX>`, UUIDs with `<UUID>`. Groups identical fingerprints, keeps top 10 per container.
- Returns `[]ContainerLogSummary` with `ErrorCount`, `WarnCount`, `RecentIssues []LogIssue`.

**Report integration** (`report/telemetry.go`):
- `buildAppTelemetrySection()` calls both, then `buildAppTelemetry()` aggregates by stack — summing container metrics, merging issues, capping at 10 per app. Additionally, `buildControllerTelemetry()` creates a special entry for the controller container itself (`app_name: "felhom-controller"`).
- Results stored as `[]AppTelemetry` in the `Report` struct field `app_telemetry`.

#### Infrastructure Backup to Hub — RETIRED (2026-06-16)

> **Removed.** The controller no longer pushes any infra-backup to the Hub, and the Hub no longer
> accepts or stores one (hub v0.12.0). The builder (`internal/report/infra_backup.go`) and local
> mirror (`internal/backup/local_infra.go`) were deleted back in slice 8C; the last caller-less stub
> (`Pusher.PushInfraBackup`) and the `backup_completed` event were removed in controller v0.69.0.
> DR now rests on the agent's PBS whole-CT snapshot + the Hub-generated controller.yaml. The text
> below is **historical** and describes the removed mechanism — much of this section (and the
> `local_infra.go` / `setup/scanner.go` / `PullRecovery` / `restore_drives` references elsewhere in
> this README) is stale slice-8C debt. See `felhom.eu/documentation/audits/SPIKE-infra-backup-2026-06-15.md`.

After each backup cycle (including manual Tier 2 triggers via `OnCrossDriveComplete` callback), the controller pushed a full infrastructure snapshot to the Hub for disaster recovery. This snapshot included:
- `controller.yaml` (base64-encoded, full config including secrets)
- `settings.json` (base64-encoded, backup prefs, storage paths, cross-drive configs)
- Disk layout (UUIDs, labels, mount points, fstab options, bind-mount topology)
- Deployed stacks manifest (app names, HDD paths) with actual config files: `docker-compose.yml`, `app.yaml`, `.felhom.yml` (base64-encoded per stack, v0.34.0)
- Restic passwords (primary + cross-drive, base64-encoded)

This enables fully automated recovery when the system drive is replaced — the new controller pulls the snapshot from the Hub, auto-mounts surviving drives by UUID, and restores all applications.

#### Hub Dashboard

The hub service (separate Go app in the `felhom.eu` repo) provides:
- Multi-customer overview table with status indicators and event count badges
- Customer detail page with system/storage/containers/backup/health/events sections
- Event timeline: last 50 events with severity filter, colored badges, source tracking
- Dead man's switch: staleness detection (30min stale, 60min down), missed backup detection (daily at 05:00)
- Notification dispatch: operator (English) + customer (Hungarian) emails via Resend with per-event cooldowns
- Infra backup status per customer (last sync, stack count, disk count)
- Color coding: green (<30min), yellow (30-60min), red (>60min since last report)
- 90-day report + event retention with daily prune at 04:30 Budapest time

### 10. First-Run Setup Wizard

When the controller starts with no valid customer configuration (`customer.id` empty), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`.

#### Setup Mode Detection (`internal/setup/setup.go`)

`NeedsSetup(cfg)` returns true when `customer.id` is empty or a `.needs-setup` marker file exists. In setup mode, the controller skips normal startup (no scheduler, no backup, no stacks) and serves only the wizard UI on two listeners:
- `:8080` — behind Traefik (accessible via domain, e.g. `https://felhom.example.com`)
- `:8081` — direct HTTP (accessible via LAN IP, e.g. `http://192.168.0.100:8081`)

#### Wizard Flow

```
┌──────────────────────────────────┐
│  1. Welcome                      │
│  Choose: Restore / Fresh install │
└─────────┬───────────┬────────────┘
          │           │
    ┌─────▼─────┐  ┌──▼───────────────┐
    │ 2a. Scan  │  │ 2b. Hub download  │
    │ drives for│  │ (customer ID +    │
    │ local     │  │  password)        │
    │ backups   │  │                   │
    └─────┬─────┘  └──────┬────────────┘
          │               │
    ┌─────▼─────┐         │
    │ 2a.2 Hub  │         │
    │ recovery  │         │
    │ (fallback)│         │
    └─────┬─────┘         │
          │               │
    ┌─────▼─────┐  ┌──────▼───────────┐
    │ Execute   │  │ Execute fresh    │
    │ restore   │  │ install          │
    └─────┬─────┘  └──────┬───────────┘
          │               │
          └───────┬───────┘
                  ▼
          os.Exit(0) → Docker restarts
          → normal mode
```

#### Hub Pre-Seeding

When `docker-setup.sh` is run with `--hub-customer` / `--hub-password`, the controller receives
pre-seeded credentials via environment variables:

| Env var | Purpose |
|---------|---------|
| `FELHOM_SETUP_CUSTOMER_ID` | Pre-fills customer ID in wizard forms |
| `FELHOM_SETUP_PASSWORD` | Pre-fills retrieval password for auto-processing |

In hub mode, the welcome page shows three cards instead of two:
1. **"Visszaállítás a Hub-ról"** — auto-calls `PullRecovery()`, shows infra backup details
2. **"Visszaállítás helyi meghajtóról"** — standard drive scan
3. **"Friss telepítés"** — auto-calls `PullConfig()`, downloads config only

Both hub paths auto-process when credentials are pre-seeded (no form entry needed).
On error, the wizard falls back to the manual form with the error displayed.

#### Key Components

| File | Purpose |
|------|---------|
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories (current + `history/`) via `lsblk` + temp mounts; returns rich info (app names, disk count) |
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
| `setup/network.go` | Detects local IPs for LAN access URL display |
| `setup/templates/` | 8 embedded HTML templates (Hungarian, dark theme matching main UI) — includes `setup_hub_versions.html` for Hub backup version picker |

#### Local Infra Backup (`internal/backup/local_infra.go`)

The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `<drive>/.felhom-infra-backup/`. Files:
- `backup.json` — full infra backup (config, settings, disk layout, passwords, stacks)
- `metadata.json` — schema version, timestamp, customer ID, controller version, SHA256 checksum
- `history/` — previous backup versions (last 5), rotated automatically before each write
  - `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`)
  - Oldest entries pruned when count exceeds 5

During setup wizard drive scan, both current and historical backups are discovered, integrity-verified, and offered for one-click restore. The scan results table shows app names/count, disk count, and a "korábbi" badge for historical versions.

#### Recovery Info (`internal/recovery/info.go`)

Generates `recovery-info.txt` on the system data partition with customer ID, Hub URL, retrieval password, and recovery instructions in Hungarian. Updated on startup and after config changes. Also displayed on the Settings page in a "Vészhelyzeti információk" section.

### 11. Disaster Recovery

When a system drive fails and is replaced, the recovery flow uses the setup wizard:

```
1. docker-setup.sh deploys fresh controller with minimal config
   - With --hub-customer: credentials pre-seeded via env vars
   - Without: user enters credentials manually in wizard
2. Controller detects empty customer.id → enters setup mode
3. User opens wizard at http://<LAN-IP>:8081
4. Hub mode: welcome page shows Hub restore / local scan / fresh install
   Non-hub mode: welcome page shows restore / fresh install
5. Hub restore: auto-connects to Hub, shows version picker if multiple versions
   Local restore: scans all drives for .felhom-infra-backup/ directories (current + history/)
6. User selects backup version → restore: config, settings, passwords, disk layout
7. Controller restarts into normal mode with full config
8. Controller auto-mounts surviving drives by UUID from disk layout
9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery
10. User confirms → sequential restore: rsync first, restic fallback, DB import
```

**Backup sources (priority order):**
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password, supports `?version=ID` for specific versions; Hub retains ~14 versions via GFS pruning (7 daily / 4 weekly / 3 monthly)
3. **Manual config** (wizard form) — enter all details manually as last resort

**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).

---

### 12. Asset Sync

App assets (logos, screenshots) are managed centrally by the Hub and downloaded to each controller via a daily sync process. This decouples asset updates from controller image rebuilds — new app icons only require a Hub redeploy.

#### How It Works (`internal/assets/syncer.go`)

```
1. Fetch manifest from Hub: GET /api/v1/assets/manifest (Bearer auth)
2. Compare SHA-256 checksums with local cache (<dataDir>/assets/)
3. Download changed/new files: GET /api/v1/assets/file/{filename}
4. Remove local files not in Hub manifest (stale cleanup)
5. Save local manifest copy for next comparison
```

#### Asset Resolution (two-tier)

| Priority | Path | Source |
|----------|------|--------|
| 1 | `<dataDir>/assets/` | Downloaded from Hub (synced cache) |
| 2 | `/usr/share/felhom/assets/` | Baked into Docker image (fallback) |

The `Resolve(filename)` method checks the synced cache first, then falls back to the baked-in directory. This ensures assets are always available even before the first sync.

The Felhom logo (`/static/felhom-logo.svg`) also uses this two-tier resolution: the logo handler checks synced assets first, then falls back to the embedded SVG constant. This allows logo updates via Hub without a controller rebuild. The logo is also used as an SVG favicon.

#### Configuration

```yaml
assets:
  sync_enabled: true       # Opt-in: download assets from Hub API
  sync_schedule: "05:00"   # Daily sync time (HH:MM, Budapest timezone)
```

Asset sync requires `hub.enabled: true` with valid `hub.url` and `hub.api_key`. The initial sync runs 10 seconds after startup (to let subsystems initialize), then daily at the configured time.

#### Sync Status

The syncer tracks status (last sync time, result, file count, total bytes) accessible via `GET /api/assets/status`. On-demand sync can be triggered via `POST /api/assets/sync`.

#### File Types

The Hub serves three asset types per app:
- `{slug}-logo.svg` — primary SVG logo
- `{slug}-logo.png` — PNG fallback
- `{slug}-screenshot-{N}.webp` — app screenshots

#### Key Design Decisions

- **Opt-in via `sync_enabled`** — backward compatible, baked-in assets still work without Hub
- **SHA-256 change detection** — only downloads files that actually changed (bandwidth efficient)
- **Atomic file writes** — downloads to `.tmp` then `os.Rename` for crash safety
- **Stale file cleanup** — removes local files not in the Hub manifest (e.g., deleted apps)
- **Non-blocking initial sync** — runs in a goroutine with 10s delay, doesn't block startup

---

### 13. Debug Mode

When `logging.level: "debug"` is set in `controller.yaml`, the controller exposes a full diagnostic dashboard at `/debug` with 9 testing sections. All debug endpoints are gated — at `info` level, the sidebar link disappears and all `/api/debug/*` routes return 404.

#### Debug Page Sections

| # | Section | Endpoints | Description |
|---|---------|-----------|-------------|
| 1 | Rendszer diagnosztika | `GET /api/debug/dump` | Full state dump: controller info, storage, stacks, scheduler, health, alerts. JSON download. |
| 2 | Értesítés teszt | `POST /api/debug/event/test`, `GET /api/debug/event/history` | Send test events with configurable type/severity, view event history ring buffer. |
| 3 | Mentés teszt | `POST /api/debug/backup/{dbdump,crossdrive,integrity,infra}` | Trigger individual backup phases independently. |
| 4 | Tárhely teszt | `POST /api/debug/storage/simulate-{disconnect,reconnect}`, `GET /api/debug/storage/watchdog-status` | Simulate drive disconnect/reconnect without unmounting. Per-path probe state with 5s auto-refresh. |
| 5 | Hub & Kapcsolatok | `POST /api/debug/hub/{push,infra-push,test-connectivity,preferences-sync}`, `POST /api/debug/gitea/test-connectivity` | Test Hub/Gitea connectivity with latency. Push reports and sync preferences. |
| — | Telemetria teszt | `GET /api/debug/telemetry` | Run the full telemetry collection pipeline on-demand (metrics query + log scan). Returns per-app table: container list, memory current/avg/peak, CPU avg, catalog limit, log error/warning counts, and top issues. Useful for verifying container→stack mapping and testing log scanner patterns without waiting for the 15-minute report cycle. |
| 6 | Önfrissítés teszt | `POST /api/debug/selfupdate/dry-run` | Dry-run update check: current vs new image lines, compose writability, backup state. |
| 7 | DR / Telepítő varázsló | `POST /api/debug/dr/trigger-setup`, `GET /api/debug/dr/infra-status` | Infra backup status per drive. Trigger setup mode via marker file (requires "RESET" + infra backup pre-check). |
| 8 | Naplóviewer | `GET /api/debug/logs?level=&limit=&after=` | In-memory log viewer (last 1000 entries), level filter, 2s auto-refresh, color-coded entries. |

#### Key Implementation Details

- **Log buffer** (`internal/web/logbuffer.go`): Ring buffer implementing `io.Writer`, created before all modules via `io.MultiWriter(os.Stdout, logBuffer)`. Parses `[DEBUG]`/`[INFO]`/`[WARN]`/`[ERROR]` tags from standard log format.
- **Storage simulation**: `simulatedPaths` map in watchdog prevents the watchdog from re-probing simulated-disconnected paths. Disconnect runs all real steps except `lazyUnmount` (drive stays physically mounted).
- **DR trigger safety**: Uses marker file (`data/.needs-setup`) instead of modifying controller.yaml. Pre-checks that infra backup exists on at least one drive.
- **Routing**: `/api/debug/` carved out in HTTP mux (same pattern as `/api/storage/`), routed to web server with auth + CSRF.
- **DebugCallbacks**: 7 closures wired from main.go for operations needing modules not on Server struct (hub push, infra backup, connectivity tests, telemetry preview).
- **Telemetry debug**: `GetTelemetryPreview` callback calls `report.BuildAppTelemetryForDebug()` (exported wrapper around the private `buildAppTelemetrySection()`). Result renders as a table with collapsible raw JSON. Available regardless of hub configuration.

#### Per-Module Logging

All modules emit structured log lines at `[INFO]`, `[WARN]`, and `[ERROR]` levels for operational events (state changes, completions, failures). When `logging.level: "debug"`, additional detailed `[DEBUG] [module]` prefixed log lines are emitted. Each module with stateful debug (struct-based) exposes a `SetDebug(bool)` method, wired from `main.go`. Modules without a struct use package-level `DebugLogger` variables (e.g., `system.DebugLogger`).

**Standard-level logging** (always active):
- `[INFO]` — Operational events: stack deploy/start/stop, backup completion, config changes, disk operations, sync results
- `[WARN]` — Degraded states: health threshold breaches, unsafe backup destinations, retryable failures, best-effort operation failures
- `[ERROR]` — Hard failures: data restore errors, integration apply failures, compose file update errors, disk format failures

| Module | Debug Field | Prefix | Key Areas |
|--------|------------|--------|-----------|
| `stacks` | `cfg.Logging.Level` | `[DEBUG] [stacks]` | Stack CRUD, compose commands, env vars, HDD mounts, encryption migration, health probes |
| `backup` | `ResticManager.debug` | `[DEBUG] [restic]` / `[DEBUG] [backup]` | Restic commands, snapshot operations, restore scanning, drive mounting |
| `cloudflare` | `Client.debug` + `GeoSyncManager.debug` | `[CF-DEBUG]` / `[DEBUG] [cloudflare]` | API requests/responses, WAF rule CRUD, zone resolution, geo sync diff |
| `integrations` | `Manager.debug` | `[DEBUG] [integrations]` | Toggle apply/revoke timing, lifecycle hooks, config reapply |
| `system` | `DebugLogger` | `[DEBUG] [system]` | Memory/disk/CPU/load/temp collection, mount probing, USB detection |
| `monitor` | `Pinger.debug` | `[DEBUG] [pinger]` | Health ping URLs, retry attempts, response codes |
| `settings` | `Settings.debug` | `[DEBUG] [settings]` | Load/save sizes, storage path ops, geo/integration state changes |
| `scheduler` | `Scheduler.debug` | `[DEBUG] [sched]` | Job registration, execution timing, daily schedule calculations |
| `web` | `cfg.Logging.Level` | `[DEBUG] [web]` | HTTP requests, auth decisions, session management, storage API ops |
| `api` | `Router.debug` | `[DEBUG] [api]` | API routing, handler entry points, request details |
| `selfupdate` | `Updater.debug` | `[DEBUG] [selfupdate]` | Version checks, update preconditions, docker pull timing |
| `assets` | `Syncer.debug` | `[DEBUG] [assets]` | Manifest fetch, hash comparison, file download timing |
| `storage` | logger-based | `[DEBUG] [storage]` | Disk scanning, formatting, attach, drive migration |
| `metrics` | logger-based | `[DEBUG] [metrics]` | Per-container log scanning, error/warning counts |
| `appexport` | `Exporter.debug` | `[DEBUG] [appexport]` | Export/import steps, crypto operations, bundle scanning |

---

### 14. Geo-Restriction

Country-based access control via **Cloudflare WAF Custom Rules**. The controller manages WAF rules in the `http_request_firewall_custom` phase to block requests from non-allowed countries. Rules are identified by a `[felhom-geo]` description prefix — other WAF rules are never touched.

#### Prerequisites

The existing `cf_api_token` (used for DNS-01 ACME) needs **Zone WAF:Edit** permission added. No new token is needed — just expanded permissions on the same token. The settings UI only appears when a CF API token is configured.

#### Architecture

```
┌─────────────┐     ┌──────────────────┐     ┌──────────────────────┐
│  Settings UI │────▶│  GeoSyncManager  │────▶│  Cloudflare WAF API  │
│ (settings.   │     │  (geosync.go)    │     │  /zones/{id}/        │
│  html)       │     │  diff & apply    │     │  rulesets/{id}/rules │
└─────────────┘     └──────────────────┘     └──────────────────────┘
       │                     ▲
       │  POST /api/geo/*    │  Scheduler (6h)
       ▼                     │  + deploy/remove hooks
┌─────────────┐              │
│  API layer  │──────────────┘
│  (geo.go)   │
└─────────────┘
```

**Rule structure:**
- **Global rule**: `(not ip.src.country in {"HU"})` → block (with `http.host ne` exclusions for apps that have per-app overrides)
- **Per-app rule**: `(http.host eq "app.example.com" and not ip.src.country in {"HU" "US"})` → block
- **Block response**: HTTP 403 with Hungarian message

**Local network access** is inherently unaffected — traffic from the LAN goes directly to the server, bypassing Cloudflare entirely.

#### Cloudflare API Client (`internal/cloudflare/`)

| File | Purpose |
|------|---------|
| `client.go` | HTTP client with Bearer token auth, 15s timeout, generic `do()` helper |
| `zone.go` | Zone ID resolution — tries exact domain, then parent domains progressively |
| `waf.go` | WAF rule CRUD, expression builders (`BuildGlobalExpression`, `BuildAppExpression`) |
| `countries.go` | ~250 ISO 3166-1 alpha-2 codes with Hungarian names |
| `geosync.go` | Sync orchestrator — diffs desired vs existing rules, creates/updates/deletes |

**GeoSyncManager** uses a `StackLister` interface (implemented by `geoStackAdapter` in main.go) to get deployed app hostnames without circular imports.

#### Settings Model

Stored in `settings.json` (runtime-modifiable):

```go
type GeoRestriction struct {
    Enabled          bool                      `json:"enabled"`
    AllowedCountries []string                  `json:"allowed_countries"`
    AppOverrides     map[string]AppGeoOverride `json:"app_overrides,omitempty"`
    LastSync         string                    `json:"last_sync,omitempty"`
    LastSyncError    string                    `json:"last_sync_error,omitempty"`
    ZoneID           string                    `json:"zone_id,omitempty"`
    RulesetID        string                    `json:"ruleset_id,omitempty"`
}
```

Thread-safe access via `GetGeoRestriction()`, `SetGeoRestriction()`, `SetGeoAppOverride()`, `RemoveGeoAppOverride()`, `SetGeoSyncState()`.

#### API Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/geo/status` | Current geo settings + sync state |
| POST | `/api/geo/settings` | Update global settings (enable/disable, countries) |
| POST | `/api/geo/sync` | Trigger manual sync |
| GET | `/api/geo/countries` | Full country list for search UI |
| POST | `/api/stacks/{name}/geo/override` | Set per-app country override |
| DELETE | `/api/stacks/{name}/geo/override` | Remove per-app override |

All mutating endpoints trigger an async Cloudflare sync. The `/api/geo/` path accepts both session auth and Hub Bearer token auth (via `selfUpdateAuthMiddleware`), enabling Hub-side geo-disable for lockout recovery.

#### Sync Triggers

1. **Settings change** — user saves geo settings or per-app override
2. **Deploy/remove** — app deployment or removal changes the hostname list
3. **Scheduler** — periodic verification every 6 hours
4. **Startup** — delayed initial sync 15s after boot
5. **Manual** — "Szinkronizálás" button on settings page

#### UI

**Settings page** ("Beállítások" → "Földrajzi korlátozás"):
- Enable/disable toggle
- Searchable country autocomplete with tag-based selection
- Hungary pinned with `confirm()` warning on removal
- Per-app overrides summary with add/edit/remove
- Sync status display (last sync time, errors)

**App detail page** (per-app override, shown when geo is globally enabled):
- Toggle for custom country restriction
- Independent country selector

---

### 15. App-to-App Integrations

Generic framework for connecting deployed applications to each other. Provider apps declare available integrations in `.felhom.yml`, and users enable/disable them via toggle switches on the provider's deploy/settings page ("Beállítások").

#### Architecture (`internal/integrations/`)

- **`integrations.go`** — Core types: `Handler` interface (`Apply`/`Revoke`), `ApplyContext` (carries domain, decrypted env vars, provider metadata, stacks dir, logger, restart func), `StatusInfo` (UI data), `IntegrationKey()`/`ParseIntegrationKey()` key helpers
- **`manager.go`** — `Manager` coordinates toggle operations, builds apply contexts from decrypted app.yaml env vars. Uses `StackProvider` interface (GetStack, GetStacks, RestartStack) to break circular imports with stacks package — adapted via `integrationStackAdapter` in main.go. Key methods:
  - `Toggle(ctx, provider, target, enable)` — Validates both apps deployed+running, calls Apply/Revoke, persists state
  - `ListForProvider(slug)` — Returns `[]StatusInfo` for UI with target deployment/running status
  - `ReapplyConfigForTarget(name)` — Re-applies all active integrations targeting a stack (config-only, no restart). Used by `SyncFileBrowserMounts` after config regeneration
- **`lifecycle.go`** — Lifecycle hooks called from API router goroutines:
  - `OnStackStop` — Revokes active integrations, sets `"provider_stopped"`/`"target_unavailable"` (keeps `enabled=true`)
  - `OnStackStart` — Re-applies enabled integrations after 5s delay (waits for stack state refresh). Accepts both `StateRunning` and `StateStarting` via `isStackUp()` helper
  - `OnStackRemove` — Revokes and permanently deletes integration state
- **Handler implementations** — One file per integration pair (e.g. `onlyoffice_filebrowser.go`, `onlyoffice_nextcloud.go`)

#### Integration State

Stored in `settings.json` under `integrations` map (key: `"provider:target"`):
- `enabled` — User intent (survives stop/restart)
- `status` — Current state: `"active"`, `"error"`, `"disabled"`, `"provider_stopped"`, `"target_unavailable"`
- `last_error` — Most recent error message
- `enabled_at` — RFC3339 timestamp

CRUD methods in settings.go: `GetIntegrationState`, `SetIntegrationState`, `RemoveIntegrationState`, `GetIntegrationsForProvider`, `GetIntegrationsForTarget` (all use existing RWMutex + atomic write pattern).

#### Lifecycle

1. **Enable**: User toggles on → validates both apps deployed+running → calls `Handler.Apply()` → persists state as `"active"`
2. **Disable**: User toggles off → calls `Handler.Revoke()` → persists state as `"disabled"`
3. **Provider/target stops**: `OnStackStop` → calls `Handler.Revoke()` → sets status to `"provider_stopped"` or `"target_unavailable"` (keeps `enabled=true`)
4. **Provider/target starts**: `OnStackStart` (5s delay) → finds enabled integrations with non-active status → re-applies if both sides running/starting
5. **Provider/target removed**: `OnStackRemove` → revokes and deletes integration state permanently
6. **FileBrowser config regen**: `SyncFileBrowserMounts` regenerates `config.yaml` from scratch → `ReapplyConfigForTarget("filebrowser")` patches integration config synchronously → recreates the container **only when the final `config.yaml`/compose differ from the pre-sync content** (`fbNeedsRecreate` gate, v0.82.0)

**Important**: `SyncFileBrowserMounts` uses `--force-recreate` (rather than a plain `up -d`) **when something changed**, because `config.yaml` is a bind mount — without `--force-recreate`, `docker compose up -d` won't recreate the container when only the config file changes (compose only detects compose-file changes). The recreate is now **gated on an actual change** (v0.82.0, F2): a controller restart or no-op sync where the generated config+compose are byte-identical issues a plain `up -d --remove-orphans` and does **not** bounce the customer's file UI. `ReapplyConfigForTarget` calls each handler's `Apply` with a no-op `RestartStack` since the caller handles the restart.

#### Built-in Handlers

**OnlyOffice → FileBrowser** (`onlyoffice_filebrowser.go`):
- Apply: Reads `JWT_SECRET` + `SUBDOMAIN` from OnlyOffice app.yaml (decrypted), strips any existing `integrations:` block from FileBrowser `config.yaml` via `removeIntegrationsSection()`, appends new block with `url` (public HTTPS), `internalUrl` (`http://onlyoffice:80`), `secret`, `viewOnly: false`. Atomic write (`.tmp` + rename). Restarts FileBrowser
- Revoke: Strips `integrations:` block from config.yaml, restarts FileBrowser

**OnlyOffice → Nextcloud** (`onlyoffice_nextcloud.go`):
- Apply: Runs `docker exec -u www-data nextcloud php occ` commands:
  1. `app:install onlyoffice` (tolerates "already installed")
  2. `app:enable onlyoffice`
  3. `config:app:set onlyoffice DocumentServerUrl --value=https://{subdomain}.{domain}`
  4. `config:app:set onlyoffice DocumentServerInternalUrl --value=http://onlyoffice:80`
  5. `config:app:set onlyoffice jwt_secret --value={JWT_SECRET}`
  6. `config:app:set onlyoffice StorageUrl --value=http://nextcloud` (internal callback URL)
- Revoke: Runs `occ app:disable onlyoffice` (tolerates container not running / app not enabled)

**OnlyOffice compose template notes**: Requires Traefik middleware `X-Forwarded-Proto=https` in labels so the Document Server generates HTTPS URLs for editor resources (prevents mixed content errors in browser).

#### Metadata (`.felhom.yml`)

Provider apps declare integrations in their `.felhom.yml`. Parsed into `IntegrationDef` struct in `metadata.go`, with `HasIntegrations()` helper.

```yaml
integrations:
  - target: filebrowser
    label: "FileBrowser integráció"
    description: "Dokumentumok szerkesztése a fájlkezelőben"
  - target: nextcloud
    label: "Nextcloud integráció"
    description: "Dokumentumok szerkesztése a Nextcloudban"
```

#### API Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/integrations/{provider}` | List integrations for a provider app (status, target availability) |
| POST | `/api/integrations/{provider}/{target}` | Enable/disable integration (`{"enabled": true/false}`) |

Routes registered **before** `hasSuffix`-based stack routes in router.go (see router bug pattern).

#### UI

Toggle switches on the provider's deploy/settings page ("Integrációk" section, within `deploy.html`). Data wired in `deployHandler()` for deployed apps only. Each integration shows:
- Label and description from `.felhom.yml` metadata
- Status badge: "Aktív", "Nincs telepítve", "Célalkalmazás leállítva", "Hiba"
- Toggle checkbox (disabled when target not deployed/running)
- JS `toggleIntegration()` → POST to API → reload on success

#### Wiring (main.go)

- `integrationStackAdapter` type implements `integrations.StackProvider` (same pattern as `stackAdapter`, `geoStackAdapter`)
- `integrations.NewManager(sett, adapter, domain, stacksDir, encKey, logger)` — registers built-in handlers
- Wired into API router via `SetIntegrationManager()` and web server via `SetIntegrationManager()`

---

## Repository Layout

```
controller/
├── cmd/controller/main.go           # Entry point, wires all 17 modules (setup mode branch + normal startup)
├── internal/
│   ├── config/config.go             # YAML loader, validation, env overrides
│   ├── crypto/crypto.go             # AES-256-GCM encryption for app.yaml secrets, key management
│   ├── settings/settings.go         # Runtime settings (JSON, atomic writes, RWMutex)
│   ├── stacks/
│   │   ├── manager.go               # Stack scanning, compose ops, container status
│   │   ├── metadata.go              # Parse .felhom.yml app metadata
│   │   ├── deploy.go                # First-deploy: secret gen, app.yaml, compose up; missing field injection
│   │   └── delete.go                # Stack deletion/removal + HDD/backup data cleanup
│   ├── sync/sync.go                 # Git sync: clone/pull app catalog, content-hash copy
│   ├── storage/
│   │   ├── scan.go, scan_linux.go   # Disk detection via lsblk + blkid
│   │   ├── format.go, format_linux.go  # Partition, format, mount pipeline
│   │   ├── attach.go, attach_linux.go  # Attach existing FS drive (raw mount + bind mount)
│   │   ├── safety.go, safety_linux.go  # System disk detection, mount guards, fstab ops
│   │   ├── migrate.go              # App data migration (rsync with progress)
│   │   └── *_other.go              # Non-Linux stubs for cross-compilation
│   ├── backup/
│   │   ├── backup.go               # Orchestrator (per-drive dumps + restic + cross-drive chain)
│   │   ├── paths.go                # Per-drive path helpers (FelhomDataDir constant, PrimaryResticRepoPath, AppDataDir, InfraBackupDir, etc.)
│   │   ├── local_infra.go          # Local infra backup to all drives (.felhom-infra-backup/)
│   │   ├── dbdump.go               # DB auto-discovery + dump (pg_dump, mariadb-dump)
│   │   ├── restic.go               # Restic operations (init, snapshot, prune, check) — repoPath as param
│   │   ├── appdata.go              # StackDataProvider interface, app data discovery
│   │   ├── crossdrive.go           # Per-app backup to secondary storage (rsync/restic)
│   │   ├── restore.go              # Per-app restore from per-drive repo
│   │   ├── restore_scan.go         # DR: scan drives for backup data, build restore plan
│   │   ├── restore_app_linux.go    # DR: per-app restore (rsync config/data + docker compose up)
│   │   └── restore_drives_linux.go # DR: auto-mount drives by UUID from Hub infra backup
│   ├── cloudflare/
│   │   ├── client.go               # CF API client (Bearer auth, generic JSON helper)
│   │   ├── zone.go                 # Zone ID resolution (domain → zone)
│   │   ├── waf.go                  # WAF rule CRUD + expression builders
│   │   ├── countries.go            # ISO 3166-1 country codes + Hungarian names
│   │   └── geosync.go              # Geo sync orchestrator (diff & apply rules)
│   ├── integrations/
│   │   ├── integrations.go          # Core types: Handler interface, ApplyContext, StatusInfo
│   │   ├── manager.go               # Manager: Toggle, ListForProvider, StackProvider interface
│   │   ├── lifecycle.go             # OnStackStop, OnStackStart, OnStackRemove hooks
│   │   ├── onlyoffice_filebrowser.go # OnlyOffice → FileBrowser handler (config.yaml patch)
│   │   └── onlyoffice_nextcloud.go  # OnlyOffice → Nextcloud handler (occ commands)
│   ├── assets/syncer.go             # Hub asset sync (download, SHA-256 compare, resolve)
│   ├── api/
│   │   ├── router.go               # REST API endpoints (~36 routes)
│   │   └── geo.go                  # Geo-restriction API handlers
│   ├── scheduler/scheduler.go      # Central job scheduler (Every, Daily)
│   ├── system/
│   │   ├── info.go, info_linux.go  # RAM, disk, CPU, temperature, load average
│   │   ├── cpu_linux.go            # Background /proc/stat sampling
│   │   └── mounts_linux.go         # Mount points, disk usage, FS info, backup dest checks, storage probing, USB detection
│   ├── monitor/
│   │   ├── pinger.go               # Healthchecks.io HTTP ping client
│   │   ├── healthcheck.go          # System health checks (disk, mem, CPU, temp, Docker)
│   │   └── watchdog.go             # Storage watchdog (probe, disconnect/reconnect, safe eject)
│   ├── metrics/
│   │   ├── store.go                # SQLite time-series (WAL mode, downsampled queries)
│   │   ├── collector.go            # Background collector (60s, system + docker stats)
│   │   └── sysinfo.go              # Static system info (/proc, /etc)
│   ├── selfupdate/
│   │   ├── version.go              # Semver parsing + comparison (hand-rolled)
│   │   ├── state.go                # Update audit state (JSON, atomic writes)
│   │   └── updater.go              # Registry check, update trigger, startup verify
│   ├── notify/notifier.go          # Email relay to hub, preference sync, cooldowns
│   ├── report/
│   │   ├── builder.go              # Hub report builder (all subsystems → JSON)
│   │   ├── pusher.go               # HTTP POST to hub (retry, Bearer auth, parses customer_blocked)
│   │   └── infra_pull.go           # DR: pull recovery/config from Hub (retrieval password auth)
│   ├── setup/                      # First-run setup wizard (web-based, replaces docker-setup.sh wizard)
│   │   ├── setup.go                # NeedsSetup() detection, state persistence
│   │   ├── handlers.go             # HTTP handlers for all wizard steps
│   │   ├── scanner.go              # Drive scanner for local infra backups
│   │   ├── csrf.go                 # Lightweight CSRF (cookie + hidden field)
│   │   ├── network.go              # Local IP detection for LAN access URLs
│   │   └── templates/              # 7 wizard HTML templates (Hungarian)
│   ├── recovery/info.go            # Recovery info file generator (recovery-info.txt)
│   └── web/
│       ├── server.go               # HTTP server, routing, static files, catch-all middleware, executeTemplate wrapper
│       ├── auth.go                 # Session auth + per-session CSRF token, login/logout, session cleanup
│       ├── csrf.go                 # CsrfProtect middleware, csrfToken/csrfField helpers
│       ├── handlers.go             # Page handlers (dashboard, stacks, deploy, backups, etc.)
│       ├── handler_restore.go      # DR: restore page handler + APIs (scan, restore all, skip)
│       ├── handler_debug.go        # Debug page handler + 20 debug API endpoints (debug-mode only)
│       ├── logbuffer.go            # Ring buffer (io.Writer) for in-memory log capture
│       ├── storage_handlers.go     # Storage API handlers (scan, format, attach, migrate, cleanup, disconnect/reconnect)
│       ├── alerts.go               # State-based alert generation
│       ├── funcmap.go              # Template functions (state colors, Hungarian formatting)
│       ├── embed.go                # go:embed for templates + Chart.js
│       └── templates/              # 21 HTML files (4-page settings split) + style.css + icons.html sprite (Hungarian UI, design system v2)
├── configs/
│   ├── controller.yaml.example     # Full config reference
│   └── example-felhom-metadata.yml # .felhom.yml format reference
├── Dockerfile                      # Multi-stage: Go 1.24 builder + debian-slim runtime
├── docker-compose.yml              # Controller's own compose (privileged, /mnt rshared)
└── go.mod                          # Go 1.24, deps: bcrypt, yaml.v3, modernc.org/sqlite
```

---

## Configuration

### Controller config (`controller.yaml`)

Single YAML file per customer, infrastructure-only. Does **not** contain app-specific config.

Key sections:
```yaml
customer:
  name: "Demo Felhom"
  id: "demo-felhom"

paths:
  stacks_dir: "/opt/docker/stacks"
  data_dir: "/opt/docker/felhom-controller/data"
  system_data_path: "/mnt/sys_drive"   # NVMe/system drive — fallback for apps without HDD

git:
  repo_url: "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
  sync_interval: "15m"

# Per-drive backup paths are computed automatically:
#   <drive>/backups/primary/restic/          — restic repo per drive
#   <drive>/backups/primary/<app>/db-dumps/  — DB dumps per app
#   <drive>/backups/secondary/               — cross-drive rsync + restic
backup:
  enabled: true
  restic_password_file: "/opt/docker/felhom-controller/data/restic-password"
  db_dump_schedule: "02:30"
  restic_schedule: "03:00"
  retention: { keep_daily: 7, keep_weekly: 4, keep_monthly: 6 }

monitoring:
  health_interval: "5m"
  ping_uuids:
    heartbeat: "uuid-here"
    system_health: "uuid-here"
    db_dump: "uuid-here"
    backup: "uuid-here"
    backup_integrity: "uuid-here"

web:
  listen: ":8080"
  setup_listen: ":8081"   # Plain HTTP for setup wizard LAN access

hub:
  enabled: true
  url: "https://hub.felhom.eu"
  api_key: "bearer-token-here"

assets:
  sync_enabled: true       # Download app assets (logos, screenshots) from Hub API
  sync_schedule: "05:00"   # Daily sync time (HH:MM, Budapest timezone)

system:
  reserved_memory_mb: 384  # RAM reserved for OS + controller
```

Environment variable overrides: `FELHOM_LOGGING_LEVEL=debug`, `FELHOM_HUB_ENABLED=false`, etc.

### Runtime settings (`settings.json`)

Auto-managed by the controller. Contains password hash overrides, notification preferences, per-app backup configs, storage path registry, DB validation cache, Hub verification state (`hub_verified`, `hub_verified_at`), retrieval password for disaster recovery, and pending event queue. All writes are atomic (write `.tmp`, rename).

### Per-app config (`app.yaml`)

Auto-generated during deployment. Contains env vars, locked fields list, deploy timestamp. Secret fields are locked (read-only after first deploy). Missing fields from updated templates are auto-injected on startup and after sync (see Missing Field Injection).

**Encryption at rest**: Sensitive env values (`type: password` and `type: secret` from `.felhom.yml` metadata) are stored encrypted as `ENC:base64(nonce+ciphertext)` using AES-256-GCM. The 32-byte encryption key is stored at `{dataDir}/encryption.key` (generated on first run, 0600 permissions). Values are decrypted transparently when passed to docker-compose or displayed in the UI. The key is included in infra backups (Hub + local drives) and restored during disaster recovery. On upgrade, existing plaintext values are migrated automatically on startup.

---

## Scheduler Jobs

| Job | Type | When | Purpose |
|-----|------|------|---------|
| status-refresh | periodic | 30s | Refresh container states |
| stack-scan | periodic | 2m | Rescan stacks directory |
| heartbeat | periodic | 5m | Legacy Healthchecks ping (deprecated — Hub handles via event system) |
| system-health | periodic | configurable | Health checks + alert refresh |
| backup-cache | periodic | 5m | Refresh backup status cache |
| hub-report | periodic | 15m | Push report to central hub |
| db-dump | daily | 02:30 | Database dumps |
| backup | daily | 03:00 | Restic backup → cross-drive chain |
| backup-integrity | daily | Sun 04:00 | Restic check |
| metrics-prune | daily | 04:00 | Delete metrics older than 30 days |
| selfupdate-check | periodic | 6h | Check registry for new version (cache for UI) |
| selfupdate-auto | daily | 04:30 | Auto-update if enabled + backup not running |
| asset-sync | daily | 05:00 | Download changed app assets from Hub |

All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent execution. Panic recovery in all jobs.

---

## REST API

### Stack Operations

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check (no auth) |
| GET | `/api/stacks` | List all stacks |
| GET | `/api/stacks/{name}` | Stack details |
| POST | `/api/stacks/{name}/deploy` | First-time deploy |
| POST | `/api/stacks/{name}/start` | Start stack (409 if insufficient memory) |
| POST | `/api/stacks/{name}/stop` | Stop stack |
| POST | `/api/stacks/{name}/restart` | Restart stack |
| POST | `/api/stacks/{name}/update` | Pull + recreate |
| POST | `/api/stacks/{name}/optional-config` | Update optional env vars |
| GET | `/api/stacks/{name}/logs` | Container logs (`?raw=1` for plain text) |
| GET | `/api/stacks/{name}/hdd-data` | HDD data paths + sizes |
| GET | `/api/stacks/{name}/backup-data` | Backup data paths + sizes (DB dumps, cross-drive rsync) |
| POST | `/api/stacks/{name}/remove` | Remove deployed stack (revert to "not deployed") |
| DELETE | `/api/stacks/{name}` | Delete orphaned stack |
| POST | `/api/sync` | Trigger catalog sync |
| GET | `/api/system/info` | System info + sync status |

### Backup & Restore

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/backup/status` | Full backup status |
| POST | `/api/backup/run` | Trigger manual backup |
| GET | `/api/backup/snapshots` | List snapshots (`?stack={name}` for filtering) |
| POST | `/api/stacks/{name}/cross-backup` | Save cross-drive config |
| POST | `/api/stacks/{name}/cross-backup/run` | Trigger cross-drive backup |
| GET | `/api/stacks/{name}/cross-backup/status` | Cross-drive status |
| POST | `/api/backup/cross-drive/run-all` | Run all scheduled cross-drive backups |

### Storage

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/storage/scan` | Scan available disks |
| POST | `/api/storage/init` | Format and mount a disk |
| GET | `/api/storage/init/status` | Format progress |
| POST | `/api/storage/attach/mount-raw` | Temp-mount partition for browsing |
| GET | `/api/storage/attach/browse?path=` | List directories on raw mount |
| POST | `/api/storage/attach/mkdir` | Create folder on raw mount |
| POST | `/api/storage/attach` | Finalize attach (bind mount + fstab) |
| GET | `/api/storage/attach/status` | Attach progress |
| POST | `/api/storage/attach/cancel` | Cleanup temp raw mount |
| POST | `/api/storage/migrate` | Start app data migration |
| GET | `/api/storage/migrate/status` | Migration progress |
| POST | `/api/storage/disconnect` | Safe disconnect (stop apps, unmount) |
| POST | `/api/storage/reconnect` | Reconnect disconnected drive |
| POST | `/api/storage/restart-apps` | Restart auto-stopped apps |
| GET | `/api/storage/status` | All storage paths with connection state |

### Self-Update

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/selfupdate/status` | Update status (cached check result + last state) |
| POST | `/api/selfupdate/check` | Force registry check |
| POST | `/api/selfupdate/update` | Trigger self-update (async) |

Self-update endpoints accept session auth OR `Authorization: Bearer <hub_api_key>` for external triggering.

### Config Management

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/config/apply` | Apply new controller.yaml from Hub (atomic write) |
| GET | `/api/config/hash` | Get SHA256 hash of current controller.yaml |
| GET | `/api/config` | Get raw controller.yaml content (text/yaml) for live diff and pull |

Config endpoints accept session auth OR `Authorization: Bearer <hub_api_key>` (same as self-update). The `/api/config/apply` endpoint:
- Accepts raw YAML body (the generated config from Hub)
- Validates YAML is parseable before writing
- Atomic write: writes to `.tmp` then `os.Rename` for crash safety
- Does NOT reload config — restart required to apply changes
- Returns `{"ok": true, "message": "Config applied. Restart controller to apply changes."}`

### Metrics

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/metrics/system` | System metrics time-series (`?range=1h|6h|24h|7d|30d`) |
| GET | `/api/metrics/containers/summary` | Current container stats |
| GET | `/api/metrics/containers/{name}` | Per-container time-series |
| GET | `/api/metrics/sysinfo` | Static system info |

### Assets

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/assets/sync` | Trigger on-demand asset sync from Hub (async) |
| GET | `/api/assets/status` | Asset sync status (last sync, file count, total bytes) |

### Integrations

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/integrations/{provider}` | List integrations for provider app (status, target availability) |
| POST | `/api/integrations/{provider}/{target}` | Enable/disable integration (`{"enabled": true/false}`) |

### Debug (debug mode only)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/debug/dump` | Full diagnostic JSON dump (controller state, storage, stacks, backup, hub, scheduler, health, alerts). Returns 404 when `logging.level` is not `"debug"`. |
| GET | `/api/debug/telemetry` | Run telemetry collection on-demand; returns per-app metrics + log summary with latency. Response: `{latency_ms, app_count, total_errors, total_warnings, app_telemetry[]}`. |

Response format: `{"ok": true/false, "data": ..., "error": "...", "message": "..."}`

---

## App-email relay (`internal/mailrelay/`)

Gives deployed apps outbound email (password resets, invites, confirmations) through one managed path —
**app → in-process SMTP shim → hub → Resend** — with the Resend key staying **hub-side** (never on the box).
Architecture **Shape 1**: the shim runs in-process inside the controller, reusing the existing hub client.
Implements `felhom.eu/documentation/audits/SPIKE-smtp-app-relay-2026-06-28.md`.

- **The shim (`internal/mailrelay/`)** is a `go-smtp` server with two listeners — `:2525` plaintext+STARTTLS
  and `:2465` implicit-TLS (self-signed cert at boot). It advertises AUTH PLAIN+LOGIN and **accepts any
  credentials, ignoring them** (apps send none; some require the offer). `Data` reads the raw message, enforces
  the **From-header** domain allowlist (reject `5xx` **before** any hub call), then forwards the **raw MIME** to
  the hub `POST /api/v1/mail` with 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). Listeners bind to the app Docker
  network only (the controller container joins `traefik-public`); apps reach the shim by `felhom-controller`.
- **Lifecycle** (`lifecycle.go`): the shim starts/stops at runtime to match the global app-email toggle (no
  controller restart). Wired in `main.go`, gated on a configured hub + the `mail_relay` kill-switch.
- **Toggles + injection:** a **global** toggle (`settings.AppEmail`, Settings page) and a **per-app** toggle
  (`AppConfig.EmailEnabled`, on the app's config page, shown only for apps with an `smtp_mapping`). When both are
  on and the app declares `.felhom.yml` **`smtp_mapping`**, `stackEnv` injects the relay env at compose time
  (host=shim, port=2525, security/from per the mapping, From=`<app>@felhom.eu`) — derived each compose, never
  persisted to `app.yaml`. Config knobs: `mail_relay` (listeners, `shim_host`, `from_domains`, kill-switch).
- **What the box never holds:** the Resend key, or any durable mail queue. v2 (deferred) = a separate
  `felhom-smtp-shim` container + accept-and-spool retry + a `Resend-Idempotency-Key`.

---

## Build & Deploy

### Build

```bash
# On build server (192.168.0.180)
cd ~/build/felhom-controller
git -C ~/git/felhom-controller pull
./build.sh v0.20.0 --push
```

### Deploy on customer node

**Option A: Self-Update API (v0.16.0+)**

After building and pushing the new image, trigger the controller's self-update endpoint:

```bash
curl -s -X POST https://felhom.demo-felhom.eu/api/selfupdate/update \
  -H "Authorization: Bearer <HUB_API_KEY>"
```

The controller pulls the new image, updates its own compose file, and runs `docker compose up -d` to replace itself. The Settings page also has a "Frissítés telepítése" button for manual triggering.

**Option B: Manual SSH (pre-v0.16.0 or fallback)**

```bash
# On customer node (e.g., 192.168.0.162)
cd /opt/docker/felhom-controller
sudo docker pull gitea.dooplex.hu/admin/felhom-controller:<VERSION>
sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:<VERSION>|' docker-compose.yml
sudo docker compose up -d
```

**Important:** Always use `docker compose up -d`, NOT `docker compose restart` — restart doesn't pick up new images.

### Docker Requirements

The controller container needs:
- `privileged: true` (disk operations)
- Docker socket mount (`/var/run/docker.sock`)
- `/mnt` mount with `propagation: rshared` (container mounts visible to host)
- `/dev` mounted as `/host-dev` (block device access)
- `/etc/fstab` mounted as `/host-fstab` (persistent mount config)

See `docker-compose.yml` for the full volume configuration.

---

## Roadmap

### Completed

- [x] Stack management with deploy flow and memory validation
- [x] Git-based app catalog sync
- [x] Central job scheduler
- [x] System monitoring with SQLite metrics and Chart.js charts
- [x] Healthchecks.io integration (5 ping types)
- [x] 3-layer backup system (DB dumps + restic + cross-drive)
- [x] Per-app backup restore with auto stop/restart
- [x] Storage management (scan, format, mount, registry)
- [x] Attach existing drive wizard (v0.15.0) — bind-mount subfolder from pre-formatted drive, directory browser
- [x] App data migration between storage paths
- [x] Storage watchdog (v0.17.0) — USB disconnect detection (~15s), auto-stop apps, auto-remount on reconnect, safe eject UI
- [x] Central hub reporting
- [x] Email notifications via hub relay
- [x] Settings persistence and password management
- [x] Dashboard alert system
- [x] Per-drive backup architecture (v0.14.0) — per-drive restic repos, per-app DB dumps, path helpers
- [x] Cross-drive restic pruning (v0.14.0)
- [x] Auto Tier 2 for small apps (v0.14.1) — auto-enable daily rsync for non-HDD apps when ≥2 drives
- [x] Infrastructure config in cross-drive backup (v0.14.1) — stacks dir + controller.yaml in `_infra/` + restic
- [x] Disaster recovery (v0.15.5) — Hub-based infra backup, auto-mount by UUID, restore UI with full-page takeover
- [x] Controller self-update (v0.16.0) — Watchtower-style pull + restart, Settings page UI, API key auth, auto-update scheduling
- [x] Hub-managed config (v0.20.0) — Config apply endpoint (`POST /api/config/apply`), config hash in reports for sync comparison
- [x] Config content endpoint (v0.21.1) — `GET /api/config` returns raw YAML for Hub live diff and pull operations
- [x] First-run setup wizard (v0.22.0) — Web-based wizard replaces shell scripts, drive scan for local backups, Hub recovery, fresh install flow
- [x] Setup wizard logo fix (v0.22.2) — Use embedded SVG instead of filesystem path
- [x] Hub-managed asset sync (v0.22.3) — Download app logos/screenshots from Hub API with SHA-256 change detection, daily sync schedule

### In Progress / Planned

- [ ] Update classification and auto-apply (optional/required/security markers)
- [x] Docker volume backup + Tier 2 restore (v0.33.0)
- [ ] Raspberry Pi testing (pi-customer-1)
- [x] CSRF protection on POST endpoints (v0.23.0)
- [x] Verbose debug logging across all modules (v0.24.0)
- [x] Diagnostic dump endpoint `/api/debug/dump` (v0.24.0)
- [x] Startup self-test with 9 subsystem checks (v0.24.0)
- [ ] Login rate limiting

---

## Test Environments

| Node | Hardware | Domain | Status |
|------|----------|--------|--------|
| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | Active |
| felhotest | Proxmox VM (4-16G RAM, 8 vCPU, 200G + 100G SCSI) | router.abonet.hu:33022 | Active |
| pi-customer-1 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | Not yet tested |

## Related Repositories

| Repository | Purpose |
|------------|---------|
| [felhom-controller](https://gitea.dooplex.hu/admin/felhom-controller) | This repo — controller + deploy scripts |
| [app-catalog-felhom.eu](https://gitea.dooplex.hu/admin/app-catalog-felhom.eu) | Docker Compose templates + .felhom.yml metadata |
| [felhom.eu](https://gitea.dooplex.hu/admin/felhom.eu) | Website + app assets + felhom-hub service |
