Compare commits

..

2 Commits

Author SHA1 Message Date
admin f35b382c6d Phase 1 gate: lock deploy/backup HDD path agreement (no doubled felhom-data)
The deploy-side double-nest fix lives in the app catalog (templates dropped the
extra felhom-data segment). This adds the controller-side invariant test that
ties the deploy path (ParseComposeHDDMounts) to the backup path
(AppDataDir/NamespaceRoot) so they can't drift again, plus the v0.52.0 CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:24:25 +02:00
admin 2b46619e15 audit: Phase 0 skeleton + baseline results
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 03:57:15 +02:00
38 changed files with 96 additions and 3105 deletions
+34 -388
View File
@@ -1,431 +1,77 @@
# AUDIT — felhom-controller + felhom-agent deep sweep — 2026-06-13
# AUDIT — felhom-controller deep sweep — 2026-06-13
**Branch (both repos):** `audit/2026-06-13-deep-sweep` (off latest `main`)
**Auditor:** Claude Code (Opus 4.8, unattended overnight session)
**Mode:** read-only, evidence-based. No fixes applied. No mutations, deploys, or builds.
**Branch:** `audit/2026-06-13-deep-sweep`
**Auditor:** Claude Code (unattended overnight session)
| Repo | HEAD commit | Version | Note |
|---|---|---|---|
| felhom-controller | `eea235bd6952184c4681b4b133396d6b8b0aaf33` | v0.58.0 | all CTRL findings cite this commit |
| felhom-agent | `d17b5ab45dc7df51836e26d7a38450cd391b94b2` | v0.29.1 | all AGENT findings cite this commit |
| felhom.eu / hub (reference) | `d59691dd826a901da39562aeaa5d989b8ea1a7ee` | hub v0.11.0 | contract reference |
| Repo | HEAD commit | Note |
|---|---|---|
| felhom-controller | `76a570da3284a742829cfeabe588255e3c74095f` | v0.51.0 (2026-06-12) — all findings cite this commit |
| felhom-agent (reference) | `716cbcd70500602f80a3e71869308a171396f1b6` | v0.28.0 |
| felhom.eu / hub (reference) | `d59691dd826a901da39562aeaa5d989b8ea1a7ee` | hub v0.11.0 |
**Tooling:** go1.26.0 windows/amd64; staticcheck (latest); go vet.
**Prior art:** controller `BUGHUNT.md` (2026-02-25, v0.30.3) read in full. NOT re-reported unless regressed. NOTE: BUGHUNT predates slice 8C which deleted ~12.3k LOC (internal/storage/*, restic, restore_drives, monitor/watchdog) — many BUGHUNT items (C1, C3, H9, M11-M20 partial, L8-L21 partial) reference now-deleted code and are MOOT. Agent has no BUGHUNT.
**Tooling:** go1.26.0 windows/amd64; staticcheck (latest, installed this session); go vet.
**Prior art:** `BUGHUNT.md` (2026-02-25, v0.30.3) read in full — findings there are NOT re-reported unless regressed; note that many BUGHUNT items refer to packages deleted in slice 8C.
## Progress log
- 17:20 — Resumed: prior session left only a Phase-0 skeleton stale at v0.51.0 + a duplicate product commit. Reset audit branch onto current main (v0.58.0). Re-baselined.
- 17:25 — Phase 0 complete (both repos). Baselines below.
- 17:35 — Tier 1 dispatched (4 parallel auditors): agent destructive-path, agent auth/authz, controller backup/crypto, controller web/auth/setup. All returned.
- 17:50 — **Verified by hand**: CTRL-001 (import path traversal via manifest.AppName) — read restore.go:320-409 + manifest.go:36-42, confirmed no validator. AGENT-001 (inline wipe TOCTOU) — read disks.go:420-479, confirmed Format targets mutable req.Device.
- 17:55 — Wrote + ran failing evidence test `internal/appexport/traversal_audit_test.go` (CTRL-001). FAILS as expected. Committed Tier-1 findings (this checkpoint).
- (next) Tier 2: stacks deploy invariant, agentapi↔localapi contract diff, report↔hub contract.
- 2026-06-13 ~00:05 — Phase 0 start: repos pulled, branch created, baseline run.
- (in progress)
## Baseline (Phase 0)
| Check | Controller (module at `controller/`) | Agent (module at root) |
|---|---|---|
| `go build ./...` | PASS | PASS |
| `go vet ./...` | PASS (clean) | PASS (clean) |
| `go test ./...` | PASS (BUGHUNT's TestBackupCopiesOnPath now green) | PASS |
| `gofmt -l .` | CRLF noise only (autocrlf=true); not a finding | same |
| staticcheck | 18 reports — triaged (4× SA4010 in backup.go → CTRL-005; SA4006/SA4017 export.go → CTRL-006; 5× U1000 dead code → §Dead code) | clean |
| Check | Result |
|---|---|
| `go build ./...` | PASS |
| `go vet ./...` | PASS (clean) |
| `gofmt -l .` | ~75 files flagged — **all CRLF noise** from `core.autocrlf=true` on this Windows checkout; `gofmt -d` shows whitespace-only diffs. Not a code finding; see Info section (missing `.gitattributes`). |
| `go test ./...` | **1 pre-existing FAIL**: `TestBackupCopiesOnPath` (internal/web/storage_handlers_test.go:295) — see findings. |
| staticcheck | 17 reports — triaged in Phase 1. |
## Executive summary
Both codebases are in good shape; the fail-safe/fail-destructive postures that matter most **hold**. No Critical found. The destructive-storage and operator-signature surfaces of the agent are unusually well-built (locked verify pipeline, hash-only token store, fsync-durable nonce store, narrow privileged fence, TLS pinning, fail-destructive ambiguity defaults — all confirmed). The controller's at-rest crypto (AES-256-GCM), restore data-key fail-closed gate, restic single-flight mutex, and (for the password-set path) full CSRF+auth coverage all hold.
The **one High** is a real path-traversal write primitive: the app-**import** path joins the attacker-controlled `manifest.AppName` from inside a `.fab` bundle straight into `filepath.Join`+`os.MkdirAll` with no validation (the archive-entry zip-slip guard exists, but the *stack-name* segment is unguarded). Verified by a failing evidence test. The remaining findings are Medium edge-cases (a TOCTOU between device-inspect and mkfs on the inline customer-confirmed wipe; decrypt-before-MAC writing transient unauthenticated plaintext) and a tail of Low/Info hardening items.
This session covered **Tier 1 in full** for both repos plus the localapi↔agentapi contract. Tier 2/3 (stacks deploy invariants, reconcile crash-safety, report↔hub contract, templates) are largely **not yet covered** — see §"What was NOT covered".
(to be written in Phase 6)
## Top-10 action list
| # | ID | Sev | Repo | Title | Effort |
|---|---|---|---|---|---|
| 1 | CTRL-001 | **High** | controller | App-import path traversal via unvalidated `manifest.AppName` | S |
| 2 | AGENT-001 | Medium | agent | Inline customer-confirmed wipe formats mutable `/dev` path (classify→mkfs TOCTOU) | M |
| 3 | CTRL-002 | Medium | controller | FAB decrypt streams plaintext to disk *before* verifying HMAC tag | S |
| 4 | AGENT-003 | Medium | agent | `InspectDevice` swallows `blkid` error; `lsblk` is sole "probed" authority | S |
| 5 | AGENT-002 | Medium | agent | Blank-device format runs mkfs un-gated with a probe→mkfs TOCTOU | M |
| 6 | CTRL-005 | Low | controller | Decompression-bomb: import extraction has no size/count cap | S |
| 7 | CTRL-008 | Low | controller | `settings.json` (bcrypt hash + plaintext retrieval pw) written 0644 | S |
| 8 | CTRL-007 | Low | controller | Pre-auth setup CSRF is a forgeable double-submit cookie | M |
| 9 | AGENT-007 | Low | agent | Decommission durable-id scheme unvalidated → silent no-op intent | S |
| 10 | CTRL-009 | Low | controller | Login rate-limiter keys on spoofable `X-Forwarded-For` | S |
---
(to be written in Phase 6)
## Findings — Critical / High
### [CTRL-001] App-import path traversal via unvalidated `manifest.AppName`
Severity: High
Category: security
Location: controller/internal/appexport/restore.go:339, 365, 401 (commit eea235b); root cause manifest.go:36-42
Confidence: **verified-by-test** (`internal/appexport/traversal_audit_test.go`, this branch)
Evidence:
```go
manifest, err := UnmarshalManifest(manifestData) // AppName fully from bundle JSON, no validation
stackDir := filepath.Join(stacksDir, manifest.AppName) // :339
os.MkdirAll(stackDir, 0755) // :365
composePath := filepath.Join(stackDir, "docker-compose.yml") // :401
```
Mechanism: `manifest.AppName` is read from the untrusted `manifest.json` inside an imported `.fab`. `UnmarshalManifest` only validates JSON, no segment check; no `IsValidStackName` exists in the package. `filepath.Join` cleans `..`, so `../../etc/cron.d/x` resolves outside `stacksDir`, then `os.MkdirAll` creates it and `restoreConfig`/`SaveEncryptedAppConfig` write `app.yaml`/`docker-compose.yml` there. The archive-entry zip-slip guard (restore.go:1047) does NOT cover this — it guards tar member names, not the stack-name segment.
Trigger: Import of a crafted `.fab`. The handler `/api/export/import` is behind RequireAuth+CSRF when a dashboard password is set, but on the demo (no password) BOTH are skipped → fully open. The `.fab` must sit under a registered `exports/` dir (`isValidExportPath` validates the file *location*, not its *contents*) — reachable via FileBrowser or a shared/malicious export.
Impact: Arbitrary-directory write as the controller process (config, restored app data, re-encrypted `app.yaml`) outside the stacks namespace — corruption/escape, potential clobber of controller config or writes onto mounted drives.
Fix sketch: In `UnmarshalManifest` (or immediately after) reject `AppName` unless it matches a strict single-segment allowlist (`^[a-z0-9][a-z0-9-]*$`, no `/ \ . ..`). Apply the same to `ConfigFiles`/`VolumeNames`/`HDDSubdirs` entries used in joins.
Verify: `cd controller && go test ./internal/appexport/ -run Traversal -v` → fails at this commit (parent-escape / deep-escape). Manual: craft a `.fab` with `"app_name":"../evil"`, import, observe `stackDir` outside `GetStacksBaseDir()`.
---
(pending)
## Findings — Medium / Low
### [AGENT-001] Inline customer-confirmed wipe formats a mutable `/dev` path (classify→mkfs TOCTOU)
Severity: Medium (data-loss consequence; narrow USB-reenumeration trigger)
Category: security / correctness
Location: agent/internal/localapi/disks.go:429-471 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
probe, err := s.disks.InspectDevice(r.Context(), req.Device) // inspect /dev/sdbN
role := s.deviceRole(r.Context(), req.Device)
deviceDurable, _ := storage.DeviceDurableID(req.Device) // derive id of /dev/sdbN NOW
dec := s.diskGate.AuthorizeWipe(WipeRequest{Role:..., DeviceDurableID: deviceDurable, Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID})
if dec.Allowed { s.disks.Format(r.Context(), req.Device, req.FSType) } // FORMAT the same mutable path
```
Mechanism: The confirmed path inspects + derives + gate-binds the durable id, then formats `req.Device` (the raw `/dev` node) — never re-resolving durable→path immediately before mkfs. The durable id is *checked* but never *used as the wipe target*. A USB re-enumeration between derive and Format makes mkfs hit a different physical disk than the one confirmed. The signed-jobs `WipeExecutor` (signedjobs/wipe.go:74-99) deliberately does resolve→re-derive→re-inspect just before Format — proving the intended pattern this inline path violates.
Trigger: Customer-confirmed user-data wipe where `/dev/sdbN` is reassigned (hot-unplug/replug, udev churn, multi-USB hub) in the sub-second window.
Impact: mkfs on an unintended physical disk → data loss on the wrong drive, defeating "wipe X wipes exactly X" for the inline tier.
Fix sketch: After `AuthorizeWipe` Allowed, `storage.ResolveDurableDevice(deviceDurable)` → re-derive+match → re-inspect `DataBearing()` → Format the *resolved* path, not `req.Device` (mirror `WipeExecutor.Execute`).
Verify: localapi test that swaps the device behind `req.Device` between InspectDevice and Format; assert refuse-or-re-resolve.
### [AGENT-002] Blank-device format runs mkfs un-gated with a probe→mkfs TOCTOU
Severity: Medium
Category: security
Location: agent/internal/localapi/disks.go:430-444 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
probe, err := s.disks.InspectDevice(r.Context(), req.Device)
if err != nil { /* fall through; probe.DataBearing()==true on !Probed (fail-safe) */ }
if !probe.DataBearing() { s.disks.Format(r.Context(), req.Device, req.FSType); writeOK(...); return }
```
Mechanism: When the agent's own probe reads the device blank, it formats with no gate, no durable binding, on the mutable `req.Device`. "blank → benign" is correct in isolation, but a device blank at probe time can be replaced at the same node before mkfs. Same root cause as AGENT-001 (formatting a mutable path). `InspectDevice` itself is fail-safe (unprobed → data-bearing), so classification is sound; the residual risk is the path-vs-physical gap.
Trigger: `/dev/sdbN` reassigned between blank probe and mkfs.
Impact: mkfs on a now-data-bearing device that was never gated. Lower likelihood than AGENT-001 (no durable binding involved).
Fix sketch: Bind even the blank path to a durable id: derive at probe, resolve back immediately before Format, refuse on mismatch.
Verify: Test a device swapped blank→data-bearing between probe and format is not silently mkfs'd.
### [AGENT-003] `InspectDevice` swallows the `blkid` error; `lsblk` is the sole "probed" authority
Severity: Medium
Category: error-handling / security
Location: agent/internal/storage/hostops.go:293-331 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
bout, _, _ := h.runner.Run(ctx, h.bins.Blkid, "-p", "-o", "export", device) // err DROPPED
for k, v := range parseBlkidExport(bout) { ... }
lout, _, lerr := h.runner.Run(ctx, h.bins.Lsblk, "-J", ..., device)
if lerr == nil { probe.Probed = true; ... } // lsblk is the ONLY thing that sets Probed
```
Mechanism: `blkid`'s exit/stderr is discarded; `Probed` is set solely from `lsblk` succeeding. blkid and lsblk cover different signature classes. If blkid errors transiently (busy/partial read) and returns empty while `lsblk -J` parses the device as having no fstype/pttype/children/mount, the probe is `Probed=true` with no positive evidence → `DataBearing()` false → reachable to the un-gated AGENT-002 format. This is exactly the "error in inspection → safe-to-wipe" pattern, narrowed by lsblk's real coverage of fstype/pttype/partitions/mount (so Medium, not Critical).
Trigger: blkid errors/empties while lsblk succeeds on a device whose data signature is blkid-only (some RAID/crypto members reported via blkid USAGE that lsblk's 4 columns miss).
Impact: A device with real data classified blank and formatted without a gate.
Fix sketch: Treat a blkid hard error (non-empty stderr / non-2 exit) as a probe failure; require both reads to complete before `Probed=true`, or fold blkid success into the Probed decision.
Verify: Unit test: blkid runner errors+empty, lsblk returns a clean blank-looking device → assert `Probed==false` (data-bearing).
### [CTRL-002] FAB decrypt streams plaintext to disk *before* verifying the HMAC tag
Severity: Medium
Category: security
Location: controller/internal/appexport/crypto.go:191-227 (commit eea235b)
Confidence: verified-static
Evidence:
```go
stream.XORKeyStream(decrypted, buf[:n]); out.Write(decrypted) // :203-204 plaintext to disk as it goes
...
if !hmac.Equal(mac.Sum(nil), storedMAC) { os.Remove(outputPath); ... } // :222-224 MAC checked only at the end
```
Mechanism: Construction is sound (AES-256-CTR + HMAC-SHA256 Encrypt-then-MAC, distinct scrypt-derived keys, constant-time `hmac.Equal`, random salt+IV per file). But decrypt writes full plaintext to `outputPath` and verifies the tag only afterward; cleanup is a best-effort `os.Remove` whose error is ignored. A same-key bit-flipped ciphertext yields controlled plaintext deltas that briefly land on disk before rejection; a crash between write and verify leaves unauthenticated attacker-influenced data behind. Violates "no unauthenticated plaintext is ever produced."
Trigger: Importing a tampered encrypted `.fab` (decrypted tgz written to a temp file before the tag check rejects).
Impact: Transient unauthenticated plaintext on disk; ignored `os.Remove` error can leave a partial decrypted file. Not a key/confidentiality break.
Fix sketch: Decrypt to temp → fsync → verify MAC → only then rename to `outputPath`; check the `os.Remove` error.
Verify: Flip one ciphertext byte, call `DecryptFile`; instrument to confirm bytes existed at the output path mid-call.
### [CTRL-005] Decompression-bomb: import extraction has no total-size / entry-count cap
Severity: Low
Category: resource-leak
Location: controller/internal/appexport/restore.go:1021-1072 (extractTarGz), :280 (commit eea235b)
Confidence: verified-static
Evidence:
```go
case tar.TypeReg:
outFile, _ := os.Create(target)
io.Copy(outFile, tr) // :1064 unbounded
```
Mechanism: Each gzip-inflated entry copied with unbounded `io.Copy`, no aggregate size or file-count limit, extracted to `os.MkdirTemp` on the ~8GB guest rootfs. Zip-slip guard is present (:1047), so availability-only.
Trigger: Importing an oversized/bomb `.fab`.
Impact: Fills guest rootfs during extraction → breaks controller + other apps until `defer os.RemoveAll(tmpDir)` runs. DoS.
Fix sketch: Track cumulative bytes vs a cap and vs `system.GetDiskUsage(tmpDir)` free; abort on exceed; cap entry count.
Verify: Import a tar.gz inflating beyond rootfs free; observe `/tmp` fill.
### [CTRL-008] `settings.json` (bcrypt hash + plaintext retrieval pw + tokens) written world-readable (0644)
Severity: Low
Category: security
Location: controller/internal/settings/settings.go:241 (commit eea235b)
Confidence: verified-static
Evidence:
```go
os.WriteFile(tmpPath, data, 0644) // file carries PasswordHash (bcrypt) + RetrievalPassword (plaintext)
```
Mechanism: controller.yaml is correctly 0600, but settings.json — holding the dashboard bcrypt hash and the **plaintext** Hub retrieval password — is 0644. Any non-root UID or mounted-in app with read access to the data dir can read it.
Trigger: Any local read access to the data dir.
Impact: Disclosure of the retrieval password (re-pull customer config from Hub) and bcrypt hash (offline cracking). Defense-in-depth; de-privileged single-tenant container lowers exposure.
Fix sketch: Write settings.json 0600; optionally encrypt RetrievalPassword at rest with the existing AES key.
Verify: `stat -c %a settings.json` → 644.
### [CTRL-007] Pre-auth setup CSRF is a forgeable double-submit cookie
Severity: Low
Category: security
Location: controller/internal/setup/csrf.go:33-43 (commit eea235b)
Confidence: verified-static
Evidence:
```go
return cookie.Value == formToken // cookie==form, both attacker-controllable; no server secret/HMAC
```
Mechanism: Pure double-submit with no server-stored secret/HMAC and `HttpOnly:false`. An attacker who can set `felhom_csrf` on the victim (cookie injection over plain-HTTP `:8081`, or a same-site subdomain) can post a matching token to the pre-auth setup wizard.
Trigger: Controller in setup mode (`NeedsSetup`) + attacker can plant/predict the cookie.
Impact: Pre-auth CSRF on `/setup/manual`,`/setup/fresh` → set dashboard password, domain, git repo, Cloudflare tokens. Bounded to the one-time setup window.
Fix sketch: HMAC the token with a server secret, verify Origin/Referer on setup POSTs.
Verify: POST `/setup/manual` with matching cookie+form but no prior GET → accepted.
### [AGENT-007] Decommission durable-id scheme unvalidated → silent no-op intent
Severity: Low
Category: contract-mismatch / correctness
Location: agent/internal/signedjobs/decommission.go:63-79 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
if p.DurableID == "" { return ... "refusing an unbound decommission" }
d.intent.SetDecommissioned(p.DurableID) // any non-empty string accepted; no scheme check
```
Mechanism: Wipe requires the `byid:`/`byuuid:` scheme via `ResolveDurableDevice`; decommission accepts any non-empty string and writes it into the intent map. The watchdog keys on the *storage* scheme (`uuid:…`) at watchdog.go:178. A wrong-scheme id records an intent that never matches → decommission silently no-ops while reporting success.
Trigger: Signed decommission whose `durable_id` uses the device scheme (`byid:`) instead of the storage scheme.
Impact: Operator believes a drive is permanently decommissioned; watchdog still auto-mounts it. Requires operator id-format error; signature is valid.
Fix sketch: Validate `p.DurableID` carries the storage-scheme prefix before recording; reject unknown schemes (fail loud, not no-op).
Verify: Decommission with `byid:…` → should refuse.
### [CTRL-009] Login rate-limiter keys on spoofable `X-Forwarded-For`
Severity: Low
Category: security
Location: controller/internal/web/auth.go:127-161 (commit eea235b)
Confidence: verified-static
Evidence:
```go
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { ip = strings.Split(fwd, ",")[0] } // attacker-controlled
```
Mechanism: Limit key derived from client-supplied XFF with no trusted-proxy allowlist; rotating XFF defeats the 5/min lock. bcrypt cost-10 is the real backstop.
Trigger: Brute-force `/login` with varying XFF.
Impact: Online password brute force (hardening gap, not an immediate break).
Fix sketch: Honor XFF only from a configured trusted-proxy CIDR; else key on `r.RemoteAddr`; add a global failed-login cap.
Verify: 5 failed logins with distinct XFF each reach bcrypt (no lock).
### [CTRL-011] Open redirect via protocol-relative `?next=//host`
Severity: Low
Category: security
Location: controller/internal/web/auth.go:187-191 (commit eea235b)
Confidence: verified-static
Evidence:
```go
if nextURL != "" && strings.HasPrefix(nextURL, "/") { redirectTo = nextURL } // "//evil.com" passes
```
Mechanism: `next` only required to start with `/`; `//evil.com` is protocol-relative and most browsers redirect to that host.
Trigger: `POST /login?next=//evil.com` after valid credentials.
Impact: Post-login open redirect (phishing). Requires a valid login first.
Fix sketch: Reject `next` starting with `//` or `/\`.
Verify: Log in with `next=//example.com` → 302 to `//example.com`.
### [AGENT-004] Signed-op execution runs under `context.Background()` (no timeout)
Severity: Low
Category: resource-leak
Location: agent/internal/signedjobs/runner.go:81-85 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
go func() { if _, err := r.RunOnce(context.Background()); err != nil { ... } }() // no deadline → mkfs/pct can hang forever
```
Mechanism: ctx flows to `exec.CommandContext`; a wedged mkfs/pct never gets cancelled, and the single-flight `running` flag then short-circuits every later pass → the signed-jobs consumer stalls until restart.
Trigger: A destructive mkfs hangs (IO stall, DM hang).
Impact: Runner goroutine wedged; no further signed ops until restart.
Fix sketch: `context.WithTimeout` per pass in `OnEnvelope`, and/or per-op deadline around Format.
Verify: Inject a blocking Format; assert RunOnce returns within timeout.
### [AGENT-012] FileNonceStore loads expired nonces; `MemoryNonceStore` never evicts
Severity: Low
Category: resource-leak
Location: agent/internal/authz/noncestore.go:92-112 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
for _, line := range ... { ... s.idx[r.Nonce] = r.Exp } // no expiry filter on load; compaction only after CompactEvery new appends
```
Mechanism: `Open` loads all logged nonces incl. expired; compaction driven only by new appends (default 1000). Frequent restarts + long validity window keep expired nonces resident. Bounded by op-rate × window; correctness unaffected (time-window check rejects expired ops first).
Trigger: High op volume + frequent restarts.
Impact: Slow nonce-log growth on a busy host. Not a security hole.
Fix sketch: Skip `r.Exp.Before(now)` during `load()`; run one compaction on `Open`.
Verify: Open a store whose log holds only expired records → assert `len(idx)==0`.
### [AGENT-009] localapi cross-guest scope check covers only the query `vmid` (latent)
Severity: Low
Category: security
Location: agent/internal/localapi/server.go:263-308 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
if q := r.URL.Query().Get("vmid"); q != "" { if want != vmid { ...403 } }
fn(w, r, vmid) // handler ALWAYS receives the token's vmid
```
Mechanism: `withGuest` rejects a mismatching *query* vmid; a mismatching *body* vmid is caught separately by per-handler `scopedFromBody`. Self-scoping holds today on all 14 routes (handler always uses the token's vmid), but the invariant relies on per-handler discipline, not one chokepoint — a future route that reads an id from body/path and forgets `scopedFromBody` would regress it.
Trigger: New handler omitting the body-vmid check.
Impact: None today; latent cross-guest exposure on future routes.
Fix sketch: Centralize the body-vmid check, or assert handlers never read an id other than the wrapper's `vmid`.
Verify: Table test POSTing `{"vmid":<other>}` to every mutating route → expect 403.
### [AGENT-006] `wholeDiskOf` symlink-resolution failure falls back to raw string
Severity: Low
Category: correctness
Location: agent/internal/storage/role.go:69-87 (commit d17b5ab)
Confidence: verified-static
Evidence:
```go
if resolved, err := filepath.EvalSymlinks(device); err == nil { dev = resolved }
... return "", false // unrecognized → caller treats as system (fail-safe)
```
Mechanism: Direction is fail-safe (unrecognized → system/protected). Drift: `SystemDisks` only adds a disk when `wholeDiskOf` succeeds; an EvalSymlinks failure on a system mount's device omits that OS disk from `sysDisks`, so a sibling user-data disk could flap. No unsafe verdict, but classification of user-data disks can be unstable when topology resolution is flaky.
Trigger: Transient `/dev` churn failing EvalSymlinks on a system mount during classification.
Impact: No unsafe wipe; possible user-data classification flapping.
Fix sketch: On EvalSymlinks error for a system mount, treat the whole `SystemDisks` result as `ok=false` (all candidates → system).
Verify: `SystemDisks` test with a `/boot` device that fails to resolve → assert `ok==false`.
---
(pending)
## Findings — Info
### [CTRL-005b] DB-dump per-DB summary built then discarded (SA4010 ×4)
Severity: Low → Info (observability only)
Category: error-handling / dead-code
Location: controller/internal/backup/backup.go:179-253 (commit eea235b)
Confidence: verified-static
Evidence: `summary` accumulates `OK/SKIP/FAIL <db>` lines (`:188,:193,:204,:208`) but is never read; caller returns generic `"some database dumps failed"` (:249). SKIP reasons ("drive disconnected/decommissioned") are lost.
Impact: Operators lose the per-DB failure/skip breakdown — can't tell which app/drive is unprotected.
Fix sketch: Fold `summary` into the returned error / `m.lastDBDump` status, or remove the dead var.
Verify: Wire `summary` into the error → SA4010 clears.
### [CTRL-006] `executeExport` step-5 timing baseline dead-assigned (SA4006/SA4017)
Severity: Info • Category: dead-code • Location: appexport/export.go:337 (eea235b)
`stepStart = time.Now()` reassigned but never read; encrypt step uses its own `encStart`. Cosmetic timing-log gap. Fix: remove the dead assignment or add the missing `time.Since(stepStart)` log.
### [CTRL-010] Setup writes config to a hardcoded path, ignoring resolved `Paths`
Severity: Info • Category: correctness • Location: setup/handlers.go:372 (eea235b)
`configPath := "/opt/docker/felhom-controller/controller.yaml"` hardcoded. On a deployment whose runtime config path differs, setup could write where the runtime never reads → `NeedsSetup` stays true, re-exposing the pre-auth wizard persistently. Fix: pass the resolved config path into `setup.NewServer`.
### [CTRL-012] `os.MkdirAll`/`os.Remove`/`Sync` errors swallowed in export staging
Severity: Info • Category: error-handling • Location: appexport/export.go:280,298,319,571,605 (eea235b)
Staging dir/remove errors ignored → less precise later failures. Critical bundle paths (tar, EncryptFile, final rename) do check errors. Fix: `failJob` on staging MkdirAll errors.
### [AGENT-005] `bindsToAction` is self-referential for one-shot jobs (defense-in-depth note)
Severity: Info • Category: invariant-drift • Location: signedjobs/runner.go:125-148; reconcile/gate.go:265-276 (d17b5ab)
For hub-queued jobs the gate compares the verified blob's params against an intent copied from the same blob → `bindsToAction` is tautologically true. By design (the executor's durable resolve+re-inspect is the real binding), but the gate adds no re-binding to the agent-surfaced `PendingOp`. The executor's data-bearing re-check is the sole backstop. Document explicitly or add a sanity assert of blob durable-id vs the host's current view.
### [AGENT-010] localapi `TokenStore.Lookup` constant-time compare is a self-comparison
Severity: Info • Category: security • Location: localapi/tokenstore.go:138-154 (d17b5ab)
The secret-bearing step is the `byHash[want]` map lookup (variable-time); the subsequent `subtle.ConstantTimeCompare(want, byVMID[vmid])` compares a value to itself (always 1) — no timing benefit. Not exploitable (SHA-256 of a 256-bit random token). Risk is documentary: don't claim a timing guarantee the code doesn't provide. Hash-only invariant still HOLDS.
### [AGENT-011] Host-wide metrics/disk topology served to any guest token
Severity: Info • Category: security • Location: localapi/host_metrics.go:28-49, disks.go:109-147 (d17b5ab)
`/host/metrics` and `/disks` return host-wide CPU/temp/SMART + storage targets to any authenticated guest, by design (one-customer-per-host model, documented inline). Revisit only if multi-tenancy is ever introduced.
### [AGENT-013] `DestroyLXC` uses blanket destructive flags; narrowness is external (gated upstream)
Severity: Info • Category: security • Location: proxmox/mutate.go:105-115 (d17b5ab)
`DestroyLXC` always passes `force=1&purge=1&destroy-unreferenced-disks=1` with no internal vmid/scratch guard; safety depends entirely on the upstream `reconcile.Gate`. The only destructive consumer (signed-jobs runner) routes through the gate (verified). Add a routing test asserting no non-gated caller reaches it.
### [AGENT-008] (positive) Fail-destructive defaults are correctly wired end-to-end
Severity: Info • Category: security • Location: storage/hostops.go:64-69,449-453; reconcile/classify.go:99-112; gate.go:146-150 (d17b5ab)
Confirmed compensating control: unprobed device → data-bearing; `NoopHostOps` → every device data-bearing; unknown op class → Destructive; nil verifier → refuse `pending_signature`; role default → system. The dangerous direction (ambiguity→allow-wipe) is the default nowhere. This downgrades AGENT-002/003 from Critical to Medium.
---
(pending)
## Contract checks (controller↔agent, controller↔hub)
| Contract | Status | Note |
|---|---|---|
| agent `internal/localapi` ↔ controller `internal/agentapi` | **partially checked — clean so far** | Auth auditor walked all 14 localapi routes; self-scoping + envelope shape consistent. Full field-by-field JSON-tag diff vs `agentapi` NOT yet done — see "NOT covered". |
| controller `internal/report/types.go` ↔ hub ingest | **NOT checked** | Deferred to Tier 3. |
| Operator-signature namespace `felhom-op-v1` (agent verifier) ↔ opsign tool | clean | verifier.go:99-181 fixed namespace; `cmd/felhom-opsign` present. |
(pending)
## Invariant checklist results
**Agent**
- Data-bearing classification from device inspection only (never caller claims) — **HOLDS** @ localapi/disks.go:429-435 + storage/hostops.go:283-333 (caller "blank/force" claim explicitly ignored). Caveat AGENT-003 (transient blkid-error read drop).
- Fail-destructive on ambiguity — **HOLDS** @ hostops.go:64-69, classify.go:111, role.go:91-99 (AGENT-008).
- Signature gate before destructive op; no un-gated path — **HOLDS for signed-jobs** @ runner.go:134-148; **DRIFTED for inline path** → AGENT-001/AGENT-002 (authorized resource ≠ mutated resource; blank branch un-gated).
- Token store hash-only — **HOLDS** @ tokenstore.go:106-170 (only sha256 hashes persisted; AGENT-010 note).
- localapi self-scoping (cross-guest→403) — **HOLDS** @ server.go:263-308 (latent-discipline AGENT-009).
- Privileged ops narrow + TLS pinned — **HOLDS** @ proxmox/privileged.go:11-178 (3 fenced exceptions, no shell), tls.go:33-75 (CAFile | leaf-SHA256 pin; InsecureSkipVerify off by default). Blanket primitive AGENT-013 gated upstream.
- Signed-job sig verified before destructive op + nonce-replay prevented — **HOLDS** @ verifier.go:99-181 (verify over raw bytes, allow-list by key material), noncestore.go:114-135 (fsync-durable, fail-safe). Growth note AGENT-012.
- No `pct exec` in provision back-half / bootstrap.json chown 100000:100000 — **NOT verified this session** (Tier 2, provision pkg).
- No secrets in logs — **HOLDS** (token "Never logged"; escrow logs only short FPs; pty discards passphrase echo).
**Controller**
- No `:latest` anywhere — **NOT fully verified** (Tier 3 templates/infra sweep pending; spot-check clean).
- Secrets never logged (keys only) — **HOLDS** spot-checked (handlers.go:56 logs presence only); CTRL-008 is at-rest file mode, not logging.
- Protected stacks unstoppable server-side — **NOT verified** (Tier 2, stacks pkg).
- `Deployed` set before `up -d`, reverted on failure (mem+disk) — **NOT verified** (Tier 2, stacks/deploy.go).
- restic behind running mutex — **HOLDS** @ backup.go:144-148, restore.go:31-42 (restic moved to agent; all entry points single-flight).
- filebrowser compose preserved if present — **NOT verified** (Tier 2).
- `EnsureBaseStack` non-fatal+idempotent — **NOT verified** (Tier 2).
- CSRF on every state-changing route incl. setup — **HOLDS for runtime mux** (password-set), full route×CSRF/auth table in session notes; **setup uses weaker CSRF** → CTRL-007.
- Restore data-key fail-closed gate — **HOLDS** @ appbackup/restore_unit.go:38-50,113-116.
- At-rest crypto AEAD — **HOLDS** @ crypto/crypto.go:50-61 (AES-256-GCM, fresh nonce). FAB export Encrypt-then-MAC HOLDS w/ CTRL-002 caveat.
(pending)
## Refactor & shared-code opportunities
- **Durable-resolve-before-mutate helper (agent):** AGENT-001/002 + AGENT-007 all stem from formatting/recording a device by a *path/string* instead of re-resolving the *durable id* at the moment of action. A single `resolveAndReinspect(durableID) (path, probe, error)` used by both the inline localapi format path and `WipeExecutor` would make "act on exactly the confirmed device" structural.
- **Single path-segment validator (controller):** CTRL-001 shows `manifest.AppName` and friends reach `filepath.Join` unvalidated while API stack routes have `extractName`. Extract one `ValidateStackName` and apply at every untrusted boundary (import manifest, archive subdir names, export filenames).
- **Decrypt-then-rename pattern (controller):** CTRL-002 — the export side already does tmp→rename; the decrypt side should too. Centralize an `atomicDecrypt(verify, then rename)`.
- (Carried from BUGHUNT, still relevant where code survived 8C) atomic-write helper unification; injected-logger consistency (`crypto.DecryptMap` global log).
(pending)
## Test-gap analysis
- **appexport import path** — ZERO tests in the package (no `_test.go` existed before this audit's evidence test). The highest-severity finding (CTRL-001) lived in untested code. Import/restore/crypto-decrypt all lack unit coverage.
- **agent inline format/wipe path** (localapi/disks.go format handler) — needs a swap-device-between-inspect-and-format test (would catch AGENT-001/002).
- **storage classify under inspection error** — no test for blkid-error/lsblk-success (AGENT-003).
- Stacks deploy crash-safety, reconcile Recover() ground-truth — coverage not yet assessed (Tier 2).
(pending)
## Dead code inventory (staticcheck U1000, controller)
## Dead code inventory
- `cmd/controller/main.go:1172` `fileExists` — unused (also BUGHUNT L1; still present).
- `internal/system/info.go:11` `debugf` — unused.
- `internal/web/alerts.go:233` `countLevel` — unused.
- `internal/web/handlers.go:1083` `(*Server).countAppsUsingPath` — unused.
- (agent: staticcheck clean — no dead-code reports.)
(pending)
## Session notes, assumptions, open questions
- Session unattended; conservative assumptions inline. Severity reflects consequence; confidence kept honest (most findings verified-static, CTRL-001 verified-by-test).
- **Assumption:** "one customer per host" deployment model holds (makes AGENT-011 Info not Medium). Documented in arch doc 03.
- **Assumption:** AGENT-001/002 trigger (USB `/dev` re-enumeration in the inspect→mkfs window) is rare on these N100 boxes → rated Medium not High; raise to High if USB churn proves common.
- gofmt noise = CRLF/autocrlf artifact, not a finding.
- Two controller Tier-1 auditors independently numbered findings CTRL-001..; merged + renumbered here into one namespace.
- **Full mutating-route × CSRF/auth coverage table** (password-set path) was produced and shows no uncovered mutating runtime route; available on request — every `/api/*` + `/` subtree wrapped in RequireAuth+CsrfProtect (main.go:703-728); only `/api/health` (GET) and `/api/host-metrics` (GET) are CSRF-exempt by nature.
- Session is unattended; conservative assumptions recorded inline.
- gofmt noise: repo is checked out with CRLF (`core.autocrlf=true`); `gofmt -l` flags nearly every file. Treated as environment artifact.
## What was NOT covered (defines next session)
## What was NOT covered
Tier 1 is complete for both repos + the localapi route walk. NOT yet done:
- **Tier 2 agent:** `internal/reconcile` engine/plan/queue/journal/**recover**/normalize/bringup — crash-safety (marker-before-mutation, `ListLXC` ground truth in `Recover()`, defer-unquiesce); `internal/provision` back-half (token mint, bootstrap.json chown 100000:100000, no `pct exec`); `internal/proxmox` task/upid/WaitTask parsing; `internal/pbs` fingerprint pinning + verify semantics.
- **Tier 2 controller:** `internal/stacks` (Deployed-before-up-d invariant + rollback in mem+disk, protected-stack server-side enforcement, EnsureBaseStack idempotent, filebrowser-compose preserve) — these are the highest-value un-audited invariants; `internal/quiesce`, `internal/selfupdate`, `internal/recovery`, `internal/agentapi` (TLS/fingerprint, timeouts, error mapping).
- **Tier 3 contracts:** field-by-field `agentapi``localapi` JSON-tag/status-code diff; `report/types.go`↔hub ingest types; templates funcmap completeness + XSS + leftover-emoji.
- **Tier 3 cross-cutting:** goroutine/ticker lifecycle & shared-state races (BUGHUNT flagged many in surviving pkgs — scheduler late-registration M10, watchdog deleted); panic isolation in scheduler/loop jobs; os/exec timeout hygiene across `docker`/`pct` calls.
- **`-race` run:** not executed (would need the build server; CGO/sqlite on Windows). Recommended next session in a throwaway dir on 192.168.0.180.
- **Live read-only inspection** (docker logs/df/findmnt on demo) — skipped; static-only this session.
(to be written honestly in Phase 6)
-182
View File
@@ -1,187 +1,5 @@
## Changelog
### v0.58.0 — infra-protection prevention layer for the OS/Docker-data split (2026-06-13)
Phase 2 of the storage-split slice (Phase 1 = felhom-agent golden + provision). The OS rootfs and
Docker data are split onto separate volumes for resilience; infra (controller/traefik/cloudflared/
filebrowser) shares the one Docker data-root and is protected by **prevention, not placement**.
- **Reserved-buffer headroom guard (`internal/system/dockervol.go`):** `GetDockerVolumeHeadroom()`
measures the Docker-data volume via `statfs("/")` (the controller container's root overlay is backed
by the guest's `/var/lib/docker` volume) and computes a reserved floor `DockerVolumeReserveGB` =
`max(5 GB, 10% of total)`. Fail-open on a measurement error (the buffer is a safety net, not a
security control).
- **Deploy-time hard gate (`internal/api/router.go` `deployStack`):** a new deploy is **refused** (HTTP
507 + Hungarian message) when free space on the Docker-data volume is at/under the reserved buffer,
so customer apps can't fill the volume the infra containers depend on.
- **Deploy-page surfacing (`deploy.html`):** for a new deploy, when below the buffer the page shows a
clear Hungarian warning and **disables** the "Telepítés indítása" button (mirrors the memory-blocked
pattern) — the customer sees it before clicking; the API gate is the hard backstop.
- **Runtime monitoring (2C):** confirmed `monitor/healthcheck.go` already watches `sysInfo.DiskPercent`
= the Docker-data volume post-split (statfs `/`); warn 80% / crit 90% used trip ABOVE the 10%-free
reserved buffer, so the customer is warned before the deploy gate engages. Comment added to make the
"SSD disk" alert's target explicit.
- **Log rotation (2D):** baked into the golden's `daemon.json` (`max-size 10m`, `max-file 3`) in the
felhom-agent golden build — every guest inherits it. Per-app xfs-project-quota caps deferred.
- Tests: `DockerVolumeReserveGB` floor/scale.
### v0.57.0 — UI fixes: stable host-storage list + per-app Tier-2 config panel (2026-06-13)
Part A of the UI-fixes/storage-spike spec (Part B is a build-nothing findings report).
- **A1 — host storage list no longer reorders (item 2):** the monitoring page's `#host-storage-bars`
list (the client-side one filled from the agent's PVE-storage list — `local`, `local-lvm`,
`felhom-pbs`, `felhom-usb` with thin-pool % + temperature) reordered on every 8 s poll because the
agent enumerates `pvesm` in a non-deterministic order and the list never passed through a Go sort.
Now `enrichHostStorageTargets` (`agent_host_metrics_handler.go`) sorts the `/api/host-metrics`
response server-side (user-data → system+apps → backup → other; alphabetical by id within a tier)
and attaches a **friendly Hungarian label + one-line purpose** per entry (e.g. `local-lvm`
"Belső SSD rendszer és alkalmazások"). The raw PVE id is kept and shown muted — **display labels
only; PVE storage ids are never renamed** (vzdump/PBS configs reference them by name). The
monitoring JS renders the friendly label + the purpose sub-line. (Note: this is the JS-driven list,
NOT the server-rendered user-data `buildStorageBars` list that v0.56.0's 4C already sorted.)
- **A2 — per-app Tier-2 config panel (item 4):** the "2. mentés" row's **Beállítás** button used to
link to the app's deploy page, which has no backup-location setting (a dead end). New route
`GET/POST /stacks/{name}/backup` (`tier2_config_handler.go` + `tier2_config.html`) is the real
surface: it shows the current/effective off-drive target, whether it's the size-limited internal
SSD, the last-run status, and lets the customer **pin a different registered drive** or **turn
Tier 2 off**. The control is **always visible** — even when only the internal SSD qualifies (shows
"automatikus: belső SSD — csak DB/konfiguráció" + the rootfs-headroom note) and for non-HDD apps
(shows honest "already in the PBS whole-guest snapshot; the off-drive copy is supplementary"
context). The button is repointed on every "2. mentés" branch (incl. the unconfigured + disabled
states).
- Persistence: two preference fields on `settings.CrossDriveBackup``UserDisabled` and
`PreferredTarget` — set via `SetTier2Preference` and **preserved across the runner's status
writes** (`withTier2Prefs`). `selectTier2Target` now honors a valid pinned target (off-disk,
registered) before the auto-pick; an invalid pin silently falls back to auto. `RunTier2` skips a
customer-disabled app. Saving with Tier 2 on for an HDD app triggers an immediate run so the
result shows on return.
- Tests: `enrichHostStorageTargets` order/labels/determinism; `selectTier2Target` honors/falls-back
on a pin; status writes preserve the preference.
### v0.56.0 — Phase 4: FileBrowser scoping + deploy DB-on-SSD note + monitoring storage descriptions (2026-06-13)
Polish layer closing the slice.
- **4A FileBrowser scoping (safety):** the FileBrowser bind mount is now scoped to each drive's
`appdata/` subtree (`<drive>/appdata:/srv/<name>`) instead of the whole drive root. The recovery
units + Tier 2 copies under `backups/` are therefore **not mounted into FileBrowser at all** — the
customer browses their userdata but cannot reach (or even see) the thing that restores them. The
appdata dir is `mkdir`-ed before the bind so the source exists. (`syncFileBrowserMounts`.)
- **4B Deploy-UI communication:** the storage-selection step now states plainly (Hungarian) that the
chosen drive holds the app's **files**, while its **database runs on the fast internal SSD** and is
backed up alongside the app — so "the DB is on the SSD" stops being a surprise. (`deploy.html`.)
- **4C Monitoring storage list:** `buildStorageBars` now sorts deterministically (by path) and carries a
**purpose description** explaining the user-data drives (rendered on the monitoring "Tárolók
kapacitása" list). Note: this list is the controller's registered user-data drives only (the agent's
local/local-lvm/pbs storage is not in this registry), so the role-tier sort/`local`-vs-`local-lvm`
descriptions belong to the agent-backed storage-management page, not here.
### v0.55.0 — Phase 3: auto off-drive Tier 2 (rootfs-headroom guard, durable off-disk target) (2026-06-13)
Tier 2 = an **off-drive copy** of each HDD app's recovery unit + bulk userdata to a **different physical
disk** — the only off-drive protection browsable HDD userdata can get (PBS can't reach bind mounts).
Auto-enabled for every HDD app; the target is auto-picked and the dangerous case (the small guest
rootfs) is refused rather than filled.
- **Engine** `internal/backup/tier2.go` (`RunTier2`/`RunAllTier2`): rsync `-a --delete` of the recovery
unit (`backups/primary/<app>/`) and the app's `appdata/<app>/` to `<target>/backups/secondary/<app>/`.
restic is **not** revived — plain browsable mirror.
- **Auto target selection:** prefer another registered user-data drive on a **different physical disk**
(can hold bulk userdata); else fall back to the internal SSD for **small units only**. Off-disk is
enforced by `system.SamePhysicalDevice` (block-device identity; new exported helper, linux + stub) —
defense-in-depth re-checked before the copy.
- **Rootfs-headroom guard (the key safety):** the SSD target is the ~8 GB guest rootfs, so a size-aware
guard (`tier2FitsHeadroom`, unit-tested) **refuses** unless the unit fits while leaving a reserve free
(`max(2 GB, 20% of total)`). When nothing fits, it records an **honest** "needs a 2nd HDD" status
rather than silently doing nothing or endangering the rootfs.
- **Status + UI:** results persist via the surviving `settings.CrossDriveBackup` (rsync method, dest,
last-run/status/size). The "2. mentés" card is now **populated** (`buildAppBackupRows`): real target
("belső SSD (csak DB/konfiguráció)" vs an external drive) on success, or the honest no-off-drive-target
reason. Notifications via the surviving `NotifyCrossDrive{Completed,Failed}` hooks.
- **Scheduling + trigger:** daily `tier2-backup` job (03:30, after the DB dump); manual
`POST /api/backup/tier2`.
- Fixed a stale pre-existing test (`TestBackupCopiesOnPath`) that still used the old
`felhom-data/backups/secondary` layout — now the Model-A in-guest layout the Tier 2 copies actually use.
### v0.54.0 — Phase 2b: restore-from-recovery-unit + fail-closed data-key gate (2026-06-13)
Restore now recreates an app from its on-drive recovery unit **plus the guest's own secrets** — never
from secrets stored in the unit (there are none), and **regenerating nothing**.
- **Fail-closed data-key gate** (`reconcileRestoreSecrets`, `internal/backup/restore_unit.go` — a pure,
exhaustively unit-tested function): merges the unit's non-secret env with the secret values recovered
from the guest's live app.yaml. A missing/empty **data-encrypting key** (`data_key`) **aborts the
restore** with a clear message (a PBS whole-guest restore is required) — because regenerating it would
render stored data unreadable. A missing *resettable* secret (DB/admin password) is non-fatal (warn +
proceed; the app may need a credential reset). Secrets are recovered, never regenerated.
- **`RestoreFromRecoveryUnit`**: reads the unit manifest → recovers secrets from the guest
(`RecoverStackSecrets`) → applies the gate → restores named-volume data from the unit's tars →
recovers the app definition from the unit and redeploys with the reconstructed env (re-pulling the
pinned image). Falls back to the legacy volume-only `RestoreApp` if no unit exists. Wired into the
`/backup/restore` web handler.
- **New seams:** `StackDataProvider.RecoverStackSecrets` / `RecreateStackFromUnit` (main.go
`stackAdapter`, with the controller `encKey` for decrypting the live app.yaml); `stacks.Manager.
RedeployFromEnv` (writes app.yaml from the full env incl. locked secrets, then `compose up -d`).
- **Tests:** the gate (all recovered / data-key missing → refuse / empty data-key → refuse / resettable
missing → proceed+warn, recovered values used verbatim) and `data_key` parsing from `.felhom.yml`
(`Metadata.DataKeyEnvVars()`).
- **Live-validated on guest 9201 (AdventureLog, a real data_key app):** its recovery-unit manifest
correctly carries `data_key_env_vars: [SECRET_KEY]` (catalog→metadata→manifest flow proven live); and
with `SECRET_KEY` made unrecoverable, `POST /backup/restore` **refused** with the exact fail-closed
message ("…[SECRET_KEY] could not be recovered … a PBS whole-guest restore is required first…"),
**before any compose-up** (no side effects). The demo has no dashboard password, so the API is open
(auth + CSRF are both skipped in that mode) — this was driven via the public URL. Gate + reconciliation
+ orchestration + data_key parsing are also unit-tested.
- **One e2e not run (environment limit, not a code gap):** the full "deploy with data → restore →
confirm data decrypts" — AdventureLog's images don't fit the **8 GB guest rootfs** (the deploy hit "no
space left on device"). This is exactly the Phase 3 rootfs-headroom concern, now observed live.
Key-preservation/regenerate-nothing is covered by the gate's verbatim-recovery unit test.
### v0.53.1 — Phase 2: recovery units refresh on the periodic cache cycle (idempotent) (2026-06-13)
The recovery-unit capture now also runs from `RefreshCache` (controller startup + every 5m), not only
the daily DB dump — so a unit exists shortly after startup and stays current with config changes
(redeploy / optional-config) without a 24h wait. `CaptureRecoveryUnit` builds the captured content in
memory and **skips all writes when the unit is already current** (same config checksums + dump set +
controller version), so the periodic refresh does not thrash a spinning USB drive. Added an idempotency
test (unchanged → skip; config change → rewrite).
### v0.53.0 — Phase 2: per-app self-contained recovery unit (capture side, SECRET-FREE) (2026-06-13)
Each app's on-drive backup becomes a complete, recreatable **recovery unit** — not just DB dumps +
volume tars, but the app's *definition* too, so it can be recreated. The unit is **secret-free by
design** (decided after reading the actual hub code: the hub is deliberately zero-knowledge and holds
no app secrets; app.yaml + the encryption key live on the guest rootfs → already inside the PBS
whole-guest snapshot). Secrets/data-keys are recovered at restore from the guest's own app.yaml (live,
or via PBS) — **never stored in the unit, never regenerated**.
- **Unit layout** (rooted at the existing `backups/primary/<app>/` — no risky dump-dir migration):
`compose/` (docker-compose.yml + .felhom.yml + a **secret-stripped** app.yaml) + the existing
`db-dumps/` + `volume-dumps/` + `manifest.json`. New path helpers `RecoveryUnitPath` /
`RecoveryUnitComposePath` / `RecoveryUnitManifestPath` in `internal/appbackup/paths.go`
(`AppDBDumpPath`/`AppVolumeDumpPath` refactored onto `RecoveryUnitPath` — identical resolved paths).
- **Secret-free manifest** (`internal/backup/recovery_unit.go`): app id, display name, controller
version, timestamp, drive, namespace root, pinned **image tags** (image NOT stored — re-pulled on
restore), the **NAMES** of secret env vars (values never stored), the `data_key` env-var names, the
explicit `secret_source` note ("guest app.yaml (live) or PBS — never stored in this unit"), captured
config-file list, enumerated dumps, and sha256 checksums of the captured config.
- **Capture has no secret access:** non-secret env is plaintext in app.yaml; the capture simply excludes
the secret-named keys (plus a defensive `crypto.IsEncrypted` guard), so it reads no secret value. New
`StackDataProvider.GetStackRecoveryInfo` + `RecoveryInfo` (in `appbackup`), implemented by the main.go
`stackAdapter`; `ParseComposeImages` extracts the image pins.
- **`data_key` annotation** (`DeployField.DataKey`, `Metadata.DataKeyEnvVars()`): marks a
data-encrypting key (e.g. AdventureLog's "Titkosítási kulcs", `SECRET_KEY`) — a **fail-closed** safety
annotation for restore (refuse + warn rather than regenerate-and-corrupt), NOT a per-secret
preserve/regenerate decision. Catalog: `adventurelog/.felhom.yml` `SECRET_KEY` marked `data_key: true`.
- **Wired into the dump flow:** `RunDBDumps` refreshes every deployed app's recovery unit after the DB
dumps (best-effort per app; skips disconnected/decommissioned drives). Capture test
(`recovery_unit_test.go`) proves the unit is secret-free (a secret in the source app.yaml never
appears in the unit) and the manifest structure.
- **NOT in this increment (next):** the restore-from-unit *recreate* (re-pull + compose-up + secret
recovery from guest/PBS) and its fail-closed `data_key` gate, with live AdventureLog readable-data
validation. The README backup-paths section (stale restic/secondary) is rewritten when Tier 2 lands.
### v0.52.0 — Phase 1 GATE: deploy-side double-nest fix + path-agreement lock (2026-06-13)
Completes the Model-A double-nest reconciliation deferred in v0.48.0. v0.51.0 fixed the **backup
-90
View File
@@ -13,96 +13,6 @@ Last updated: 2026-06-12 (storage UX polish)
> is tracked in `CHANGELOG.md`, `controller/README.md`, and the auto-memory `MEMORY.md`. Live version:
> **v0.45.0**.
>
> **2026-06-13 — v0.58.0 OS/Docker-data split prevention layer (Phase 2; Phase 1 = agent v0.29.0):**
> - OS rootfs + Docker data split onto separate local-lvm volumes (golden bakes 32G rootfs + 256G
> /var/lib/docker, backup=1, **overlay2** so images live on the data vol). Infra protected by
> PREVENTION not placement: `system.GetDockerVolumeHeadroom()` (statfs "/" = data vol) reserves
> max(5GB,10%); `deployStack` refuses HTTP 507 below buffer; deploy.html banner+disable; monitor
> (warn80/crit90) watches the same vol; log rotation baked into the golden daemon.json.
> - Live-validated by DESTROYING + RE-PROVISIONING 9201 from the split golden: split layout, overlay2,
> images on data vol, lean rootfs, OS isolation (data vol 100% → rootfs healthy), deploy gate 507,
> ActualBudget volume on data vol, hub config pull, external access via CF. RomM/USB re-enroll = the
> documented final restore step (RomM data safe on host USB). See memory [[os-data-split]].
>
> **2026-06-13 — v0.57.0 UI fixes (Part A of the UI-fixes/storage-spike spec):**
> - A1: fixed the RIGHT storage list — `#host-storage-bars` (the JS-filled, agent-PVE-storage list:
> `local`/`local-lvm`/`felhom-pbs`/`felhom-usb`), which reordered on every poll. Now
> `enrichHostStorageTargets` sorts `/api/host-metrics` server-side + adds friendly Hungarian
> labels/purpose. Display-only — PVE storage ids never renamed. (v0.56.0's 4C had sorted the OTHER,
> server-rendered user-data list.)
> - A2: per-app Tier-2 config panel at `GET/POST /stacks/{name}/backup`; the dead-end "Beállítás" button
> (was → deploy page) is repointed there. Pin a target drive / toggle Tier 2 off; prefs
> (`UserDisabled`/`PreferredTarget`) persist on `CrossDriveBackup` and survive the runner's status
> writes (`withTier2Prefs`). Always visible incl. single-SSD + non-HDD (PBS-context) apps.
> - Part B (storage OS/data split spike) = build-nothing; findings → `felhom-agent/REPORT-storage-split-spike.md`.
> - Live-validated on guest 9201; build/deploy = golden bootstrap (`/etc/felhom-controller-image` + restart `felhom-controller-bootstrap.service`).
>
> **2026-06-13 — v0.56.0 Phase 4: FileBrowser scoping + UI polish (SLICE COMPLETE):**
> - 4A: FileBrowser bind scoped to `<drive>/appdata` (recovery units + Tier 2 copies under `backups/`
> NOT mounted → customer can't browse/delete the restore source). 4B: deploy storage step states
> files-on-drive / DB-on-fast-SSD. 4C: `buildStorageBars` stable sort + purpose description on the
> monitoring list (user-data drives only; agent local/local-lvm/pbs live on the storage page, not here).
> - Live-validated (9201): FileBrowser mount `/mnt/felhom-usb/appdata -> /srv/felhom-usb` (backups hidden);
> deploy + monitoring text rendered. **All 5 phases (1, 2, 2b, 3, 4) shipped + live-validated, v0.52→v0.56.**
>
> **2026-06-13 — v0.55.0 Phase 3: auto off-drive Tier 2 (rootfs-headroom guard):**
> - `internal/backup/tier2.go`: rsync `-a --delete` of each HDD app's recovery unit + appdata → a
> DIFFERENT physical disk (`<target>/backups/secondary/<app>/`). Auto target: prefer another registered
> drive (off-disk via `system.SamePhysicalDevice`), else internal SSD for SMALL units only.
> - **Rootfs-headroom guard** (`tier2FitsHeadroom`, unit-tested): SSD = ~8G guest rootfs, so REFUSE
> unless the unit fits leaving reserve = max(2G, 20%) free; honest "needs 2nd HDD" status when nothing
> fits — never fills the rootfs. Status via surviving `settings.CrossDriveBackup`; "2. mentés" UI card
> now populated (`buildAppBackupRows`). Daily `tier2-backup` 03:30 + `POST /api/backup/tier2`.
> - **Live-validated (9201):** happy path (RomM → SSD, off felhom-usb, 77KB, "[SSD: DB/config only]");
> refuse path (1G userdata dummy → REFUSED with honest msg, rootfs not filled); UI card shows
> "Sikeres → belső SSD (csak DB/konfiguráció)". Demo cleaned.
> - Next: Phase 4 (FileBrowser scoping + deploy-UI DB-on-SSD note + monitoring sort).
>
> **2026-06-13 — v0.53.0/v0.53.1 Phase 2: per-app recovery unit (capture side, SECRET-FREE):**
> - Each app's `backups/primary/<app>/` becomes a self-contained recovery unit: `compose/`
> (docker-compose.yml + .felhom.yml + **secret-stripped** app.yaml) + db-dumps/ + volume-dumps/ +
> `manifest.json` (image pins, secret env-var NAMES, data_key names, checksums, secret_source note).
> - **Secret-free by design.** Decided after reading the ACTUAL hub code: hub is zero-knowledge (no app
> secrets); app.yaml + key live on the guest rootfs → in the PBS whole-guest snapshot. So the unit
> stores no secret/data-key/image; restore recovers secrets from the guest's app.yaml (live/PBS),
> regenerates nothing. `data_key` (DeployField.DataKey; AdventureLog SECRET_KEY marked) = fail-closed
> restore annotation only.
> - Capture needs no decryption (non-secret env is plaintext; excludes secret-named + encrypted keys).
> Wired into RunDBDumps AND the periodic RefreshCache (idempotent checksum-skip → no USB thrash).
> - **Deploy mechanism resolved:** controller in guest 9201 is golden/bootstrap-managed —
> `felhom-controller-bootstrap.service` docker-runs the tag from `/etc/felhom-controller-image`
> (gitea anon-pull). Deploy = build+push → anon-pull → update tag file → restart the service.
> - **Live-validated (9201):** RomM unit captured (images=3, secrets=3, data_keys=0), secret-leak grep
> = NO_LEAK.
> - **v0.54.0 Phase 2b (restore-from-unit + fail-closed gate):** `RestoreFromRecoveryUnit` recreates an
> app from its unit + secrets recovered from the GUEST's live app.yaml (`RecoverStackSecrets`,
> `stacks.RedeployFromEnv`), regenerating nothing. `reconcileRestoreSecrets` (pure, unit-tested) is the
> fail-closed gate: missing/empty data-key → REFUSE (needs PBS whole-guest restore); missing resettable
> secret → warn+proceed. Wired into `/backup/restore`. Gate + orchestration + data_key parsing
> unit/integration-tested; deployed v0.54.0 healthy.
> - **LIVE-validated (9201, AdventureLog):** unit manifest `data_key_env_vars:[SECRET_KEY]`
> (catalog→manifest live); with SECRET_KEY made unrecoverable, `POST /backup/restore` REFUSED with the
> exact fail-closed message BEFORE any compose-up. Demo has NO dashboard password → API open (auth+CSRF
> skipped), driven via public URL. NOTE: full deploy-with-data→restore e2e blocked because AdventureLog
> images don't fit the 8G guest rootfs ("no space left") — that's the Phase 3 rootfs-headroom concern
> seen live. Demo left clean (AdventureLog reverted to not-deployed).
> - Next: Phase 3 (Tier 2 auto off-drive, rootfs-headroom guard), Phase 4 (FileBrowser + UI).
>
> **2026-06-13 — v0.52.0 Phase 1 GATE: deploy-side double-nest fix (catalog) + path-agreement test:**
> - The `felhom-data` double-nest lived in the **app-catalog compose templates**
> (`${HDD_PATH}/felhom-data/appdata/<app>`), not in `deploy.go`. On a Model-A in-guest drive the mount
> already IS the `felhom-data` namespace, so it double-nested on disk while the v0.51.0 backup helpers
> resolved single-nested → divergence. Fixed all four HDD templates (romm, nextcloud, immich,
> paperless-ngx) → `${HDD_PATH}/appdata/<app>`.
> - New `internal/stacks/hddpath_agreement_test.go` locks deploy-resolver (`ParseComposeHDDMounts`) ==
> backup helper (`AppDataDir(NamespaceRoot(.,true))`). No controller runtime change → no image rebuild
> (deployed stays 0.51.0, functionally current; golden not rebaked for a no-op).
> - **Live (guest 9201):** git-sync auto-delivered the fix to all four stack files; RomM migrated
> (stop→move→verify→redeploy) from `/mnt/felhom-usb/felhom-data/appdata/romm` →
> `/mnt/felhom-usb/appdata/romm`, healthy + HTTP 200, no data loss, old namespace empty. **GATE PASSED.**
> - Next: Phase 2 (per-app recovery unit), Phase 3 (auto-enabled off-drive Tier 2 w/ rootfs-headroom
> guard), Phase 4 (FileBrowser scoping + deploy-UI DB-on-SSD note + monitoring sort).
>
> **2026-06-12 — storage UX polish (v0.45.0), pairs with felhom-agent v0.24.0:**
> - **Agent eject role-gate (Part A, felhom-agent v0.24.0):** `POST /disks/eject` now refuses to
> unmount system/backup storage *at the agent* (fail-safe to protected on ambiguity) — the UI hiding
+38 -51
View File
@@ -1,56 +1,43 @@
# REPORT — felhom-controller v0.58.0 (infra-protection prevention layer for the OS/Docker-data split)
# REPORT — felhom-controller v0.51.0
Phase 2 of the OS/Docker-data storage-split slice (Phase 1 = felhom-agent v0.29.0: golden + provision).
The controller guest's OS rootfs and Docker data are now split onto separate `local-lvm` volumes for
resilience; infra (controller/traefik/cloudflared/filebrowser) shares the one Docker data-root and is
protected by **prevention, not placement**. Built, deployed, and **live-validated on a freshly
re-provisioned guest 9201**.
Offsite-backup UI (felhom-pbs = real DR) + Model-A double-nest fix. Pairs with felhom-agent v0.28.0
(whole-guest backup re-targeted to the offsite PBS tier). Live-deployed in guest 9201 on demo-felhom.
## What shipped (v0.58.0)
- **Reserved-buffer headroom guard** (`internal/system/dockervol.go`): `GetDockerVolumeHeadroom()`
measures the Docker-data volume via `statfs("/")` the controller container's root overlay is the
upperdir on the guest's `/var/lib/docker` volume (true with the **overlay2** driver; see the agent
report), so `/` reports the data volume. Reserve floor `DockerVolumeReserveGB = max(5 GB, 10%)`.
Fail-open on a measurement error.
- **Deploy-time hard gate** (`internal/api/router.go` `deployStack`): a new deploy is **refused (HTTP
507** + Hungarian message) when free space on the Docker-data volume is at/under the reserved buffer.
- **Deploy-page surfacing** (`deploy.html`): a new deploy below the buffer shows a Hungarian warning and
**disables** the "Telepítés indítása" button; the API gate is the hard backstop.
- **Runtime monitoring** (`monitor/healthcheck.go`): confirmed `DiskPercent` watches the Docker-data
volume (statfs `/`); warn 80% / crit 90% trip ABOVE the 10%-free buffer, so the customer is warned
before the gate engages. Clarifying comment added.
- **Log rotation** baked into the golden's `daemon.json` (agent side; `max-size 10m`, `max-file 3`).
- Tests: `DockerVolumeReserveGB` floor/scale.
## Backups page — whole-guest backup shown as real DR
- `backupTargetLabel` returns **"Biztonsági szerver külön hardver (PBS)"** for a PBS-stored backup
(detected via `backupIsPBS` on the target id / archive volid), so the customer sees the backup
survives a host hardware failure.
- The app-data section's **"Távoli mentés"** card stops reading "nincs beállítva": new
`guestBackupView.Offsite` flag drives it to **"külön hardveren (PBS)"** with a ✓ when the whole-guest
backup landed on PBS.
- The restore-test "Visszaállítás ellenőrizve" trust signal is unchanged (already wired).
- Live: agent `/backup/status` reports `target_id=felhom-pbs`; `/restore-test/status` reports
`pass:true, verified:"boot+running", source_tier:"pbs"` → the page renders the PBS label, the offsite
card, and verified-restorable.
## Live validation (guest 9201, freshly re-provisioned from the split golden)
9201 was **destroyed and re-provisioned** from the new split golden (32 GB OS rootfs + 256 GB Docker-data
volume, `backup=1`), via `felhom-agent --selftest=provision` + a reboot. The controller bootstrapped
from baked images (no pull), **pulled its config from the hub** (catalog synced — 55 app defs — hub
HTTP 200, CF token configured, hub report pushed). Then:
## Model-A double-nest fix
- Under slice-10 Model A the host agent binds `<drive>/felhom-data` onto the guest mountpoint, so an
enrolled drive's in-guest mount IS the felhom-data namespace root (basename need not be `felhom-data`,
e.g. `/mnt/felhom-usb`). The backup path helpers were re-prepending `felhom-data`, producing
`.../felhom-data/felhom-data/...` on the host (confirmed live: `/mnt/felhom-usb/felhom-data/felhom-data/...`).
- `appbackup` path helpers now take a **namespace ROOT** (no internal `felhom-data` join) plus a new
`NamespaceRoot(drivePath, inGuestDrive)`. `backup.Manager.namespaceRoot`/`AppNamespaceRoot` resolve
provenance (`drivePath != systemDataPath` ⟺ a registered in-guest drive → namespace root as-is; the
SSD-only `systemDataPath` fallback appends `felhom-data`).
- All parallel constructions updated coherently so writes, deletion (`GetStackBackupData`,
`RemoveStack` backups-base + `ProtectedHDDPaths` — legacy double-nest dirs KEPT protected), the
wipe-warning secondary scan, and export all agree. `api.router` passes the namespace root across the
package boundary. Result: a drive-resident app's DB-dump lands single-nested at `<drive>/backups/...`
in-guest = `<drive>/felhom-data/backups/...` on the host.
- New `appbackup` test asserts no doubled `felhom-data` segment for an in-guest drive and exactly one
for the system fallback. Full `go build ./...` + tests green.
- **Split layout:** controller image 0.58.0, **Storage Driver overlay2**, `Docker Root Dir
/var/lib/docker`; images on the data volume (`/var/lib/docker/overlay2` 1.7 GB), `/var/lib/containerd`
idle (380 K); `df`: `/` 935 MB/32 GB (4%, lean OS rootfs), `/var/lib/docker` 256 GB.
- **Prevention gate (the headline):** with ample space the deploy page shows **no** gate banner; after
`fallocate`-filling the data volume to 99% (3.2 GB free < 25.6 GB reserve), a `POST /api/stacks/.../deploy`
returned **HTTP 507** with the Hungarian "Nincs elég szabad tárhely" message — proven on the real
256 GB data volume.
- **Regression:** `/`, `/stacks`, `/backups`, `/monitoring`, `/stacks/{n}/deploy`, `/stacks/{n}/backup`
all HTTP 200; A1 host-storage list still ordered + friendly-labelled (felhom-usb → local-lvm → local
→ felhom-pbs); A2 Tier-2 panel route serves.
- **Deploy path + DB-on-data-volume (step 3):** deployed ActualBudget (HTTP 200, container up); its named
volume landed at `/var/lib/docker/volumes/actualbudget_actualbudget_data` = the data volume.
- **External access:** via Cloudflare the controller returns HTTP 200 for vmid 9201 (tunnel + traefik
route healthy). (A local-DNS override on the dev machine points the hostname at a stale LAN IP — a
red herring; the real public path works.)
## Decommission (P3) — NO controller change
- Permanent decommission is operator-signature-gated (never customer-confirmable), so it is wired
entirely agent-side (hub jobs-queue → signed-jobs runner). The controller deliberately exposes no
decommission UI. (felhom-agent v0.28.0.)
## OS isolation (resilience — the reason for the split), proven on the provisioned guest
Filling the Docker-data volume to 100% (239 GB) left the OS rootfs at 4% and fully writable, the guest
healthy throughout — the data volume cannot starve the OS.
## Outstanding (demo restoration, not slice validation)
- **RomM (HDD app) + USB re-enroll:** RomM's data is safe on the host USB (`/mnt/felhom-usb/felhom-data`,
untouched by the re-provision). Restoring it is the slice-10 enroll flow (assign → guest-attach →
reboot to activate the bind → register storage → deploy). With the split, the USB binds to a free slot
(mp1+) since **mp0 is now the Docker-data volume** — no collision. Documented as the final restore step;
not required for slice validation (ActualBudget covered the deploy path; the USB bind was not touched).
## Live deploy
- `gitea.dooplex.hu/admin/felhom-controller:0.51.0` running + healthy in guest 9201 (bootstrap-launched
via `/etc/felhom-controller-image`; prior 0.50.0). Startup clean (catalog sync, health ok,
FileBrowser mounts synced).
+2 -82
View File
@@ -149,16 +149,6 @@ The app catalog lives in a separate Git repository. The controller:
- Hard block if `used_mb + new_request > usable_memory`
- `CommittedMemory()` (declared sum) still used for soft overcommit warning only
- Deploy page shows real memory usage bar (not declared reservations)
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
@@ -338,16 +328,8 @@ The nightly backup has two phases that run sequentially. All paths are **per-dri
└── 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`.
> **Note:** `HDD_PATH` env var in `app.yaml` is still the mount point (e.g., `/mnt/hdd_1`). The `felhom-data` segment is embedded in path helpers — not in `HDD_PATH`.
> Pre-v0.26.0 installations use `<drive>/appdata/` and `<drive>/backups/` directly (no `felhom-data/` namespace).
Path computation is centralized in `backup/paths.go` via the `FelhomDataDir = "felhom-data"` constant:
- `PrimaryResticRepoPath(drivePath)``<drive>/felhom-data/backups/primary/restic/`
@@ -359,59 +341,6 @@ Path computation is centralized in `backup/paths.go` via the `FelhomDataDir = "f
- `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) and **regenerates nothing**; for a
`data_key` app it **fails closed** (refuse + warn) if the key can't be recovered.
- 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`).
#### 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.
**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`
@@ -811,15 +740,6 @@ The de-privileged controller (slice 8C) sees only its own cgroup and cannot read
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:
-118
View File
@@ -218,12 +218,10 @@ func main() {
stackProv := &stackAdapter{
mgr: stackMgr,
getStoragePaths: func() []settings.StoragePath { return sett.GetStoragePaths() },
encKey: encKey,
}
if cfg.Backup.Enabled {
backupMgr = backup.NewManager(cfg, sett, logger)
backupMgr.SetStackProvider(stackProv)
backupMgr.SetVersion(Version)
}
// --- Initialize alert manager ---
@@ -341,26 +339,6 @@ func main() {
backupMgr.RefreshCache(nextDBDump)
return nil
})
// Tier 2: off-drive copy of each HDD app's recovery unit + userdata (auto-enabled, auto-target).
// Runs after the DB dump so it copies a fresh unit.
backupMgr.SetTier2Notifier(func(stackName, destLabel string, dur time.Duration, err error) {
if err != nil {
notifier.NotifyCrossDriveFailed(notify.CrossDriveDetails{
StackName: stackName, Method: "rsync", DestPath: destLabel,
Duration: dur.Round(time.Second).String(), Error: err.Error(),
})
} else {
notifier.NotifyCrossDriveCompleted(notify.CrossDriveDetails{
StackName: stackName, Method: "rsync", DestPath: destLabel,
Duration: dur.Round(time.Second).String(),
})
}
})
sched.Daily("tier2-backup", "03:30", func(ctx context.Context) error {
backupMgr.RunAllTier2()
return nil
})
}
// Metrics prune — daily at 04:00
@@ -789,7 +767,6 @@ func setupLogger(cfg *config.Config) (*log.Logger, *web.LogBuffer) {
type stackAdapter struct {
mgr *stacks.Manager
getStoragePaths func() []settings.StoragePath
encKey []byte // for decrypting live app.yaml secrets during restore-from-unit
}
func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
@@ -874,101 +851,6 @@ func (a *stackAdapter) GetStackHDDPath(name string) string {
return ""
}
// GetStackRecoveryInfo gathers the SECRET-FREE inputs for an app's recovery unit (Phase 2): the
// stack dir, pinned image tags, the non-secret env, and the NAMES of secret/data-key env vars.
// It deliberately does NOT decrypt or return any secret value — secret/password fields are stored
// encrypted in app.yaml, so excluding them (plus a defensive crypto.IsEncrypted guard) yields a
// plaintext, secret-free env. The actual secret values are recovered at restore time from the
// guest's own app.yaml (live, or via the PBS whole-guest snapshot), never from the unit.
func (a *stackAdapter) GetStackRecoveryInfo(name string) (backup.RecoveryInfo, bool) {
s, ok := a.mgr.GetStack(name)
if !ok {
return backup.RecoveryInfo{}, false
}
stackDir := filepath.Dir(s.ComposePath)
meta := stacks.LoadMetadata(stackDir)
// Secret set = all secret/password fields any data_key fields (in deterministic metadata order).
secretSet := make(map[string]bool)
var secretNames []string
add := func(v string) {
if !secretSet[v] {
secretSet[v] = true
secretNames = append(secretNames, v)
}
}
for _, v := range stacks.SensitiveEnvVars(&meta) {
add(v)
}
dataKeys := meta.DataKeyEnvVars()
for _, v := range dataKeys {
add(v)
}
// Non-secret env: raw app.yaml values that are neither named-secret nor (defensively) encrypted.
nonSecret := make(map[string]string)
if appCfg := stacks.LoadAppConfig(stackDir); appCfg != nil {
for k, v := range appCfg.Env {
if secretSet[k] || crypto.IsEncrypted(v) {
continue
}
nonSecret[k] = v
}
}
return backup.RecoveryInfo{
StackDir: stackDir,
DisplayName: s.Meta.DisplayName,
ImagePins: backup.ParseComposeImages(s.ComposePath),
NonSecretEnv: nonSecret,
SecretEnvVars: secretNames,
DataKeyEnvVars: dataKeys,
}, true
}
// RecoverStackSecrets returns the live decrypted values for the named secret env vars present in the
// stack's app.yaml (the guest's own — live rootfs or PBS-restored). Absent/empty names are omitted;
// the caller's fail-closed gate decides. Secrets come from the guest, never from the recovery unit.
func (a *stackAdapter) RecoverStackSecrets(name string, names []string) map[string]string {
s, ok := a.mgr.GetStack(name)
if !ok {
return nil
}
cfg := stacks.LoadAppConfigDecrypted(filepath.Dir(s.ComposePath), a.encKey)
if cfg == nil {
return nil
}
out := make(map[string]string)
for _, n := range names {
if v, ok := cfg.Env[n]; ok && v != "" {
out[n] = v
}
}
return out
}
// RecreateStackFromUnit restores the app definition from the unit's compose dir into the stack dir,
// then redeploys with the reconstructed full env (re-pulling the pinned image). Secrets in fullEnv were
// recovered from the guest, never regenerated.
func (a *stackAdapter) RecreateStackFromUnit(name, composeSrcDir string, fullEnv map[string]string) error {
s, ok := a.mgr.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(s.ComposePath)
// Recover the app definition from the unit (compose + .felhom.yml) into the stack dir.
for _, fname := range []string{"docker-compose.yml", ".felhom.yml"} {
data, err := os.ReadFile(filepath.Join(composeSrcDir, fname))
if err != nil {
continue // capture whichever existed
}
if err := os.WriteFile(filepath.Join(stackDir, fname), data, 0644); err != nil {
return fmt.Errorf("restoring %s from unit: %w", fname, err)
}
}
return a.mgr.RedeployFromEnv(name, fullEnv)
}
// RefreshAndIsRunning forces a docker ps scan before checking state.
// Called during post-restore health check (~every 5s for up to 90s).
// Full refresh is acceptable here since restores are rare operations.
-5
View File
@@ -473,11 +473,6 @@ type StorageTarget struct {
ClassHint string `json:"class_hint"`
ThinPool *ThinPoolFill `json:"thin_pool,omitempty"`
Smart SmartSummary `json:"smart"`
// Label and Purpose are controller-side display enrichment (NOT from the agent): a friendly
// Hungarian name + one-line purpose so the customer understands what each storage holds. The
// raw PVE storage id stays in Name (display-only labels — we never rename the actual storage).
Label string `json:"label,omitempty"`
Purpose string `json:"purpose,omitempty"`
}
// HostMetricsResponse mirrors the agent's GET /host/metrics payload (host-wide health + per-storage
-33
View File
@@ -221,10 +221,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/backup/run" && req.Method == http.MethodPost:
r.triggerBackup(w, req)
// POST /api/backup/tier2 — run off-drive Tier 2 copies for all HDD apps
case path == "/backup/tier2" && req.Method == http.MethodPost:
r.triggerTier2(w, req)
// GET /api/metrics/system
case path == "/metrics/system" && req.Method == http.MethodGet:
r.metricsSystem(w, req)
@@ -346,19 +342,6 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
return
}
// Prevention layer (storage-split): refuse a deploy when the Docker-data volume is at/under its
// reserved buffer, so customer apps can't fill the volume the infra containers (controller,
// traefik, cloudflared, filebrowser) depend on. Fail-OPEN on a measurement error — the buffer is
// a safety net, not a security control, so a transient statfs failure must not block all deploys.
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
hr.AvailGB, hr.ReserveGB)})
return
}
deployReq := stacks.DeployRequest{
StackName: name,
Values: body.Values,
@@ -766,22 +749,6 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
}
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return
}
if r.backupMgr.IsRunning() {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
return
}
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
go r.backupMgr.RunAllTier2()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
}
// --- Metrics handlers ---
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
-63
View File
@@ -1,7 +1,6 @@
package appbackup
import (
"bufio"
"context"
"fmt"
"log"
@@ -24,68 +23,6 @@ type StackDataProvider interface {
StopStack(name string) error
StartStack(name string) error
RefreshAndIsRunning(name string) bool
// GetStackRecoveryInfo returns the data needed to capture a SECRET-FREE recovery unit
// (Phase 2): the stack dir, pinned image tags, the non-secret env, and the NAMES of the
// secret/data-key env vars (values are NEVER returned — they are recovered at restore time
// from the guest's own app.yaml, live or via the PBS whole-guest snapshot). ok=false if the
// stack is unknown.
GetStackRecoveryInfo(name string) (RecoveryInfo, bool)
// --- Phase 2b: restore-from-recovery-unit ---
// RecoverStackSecrets returns the live decrypted values for the named secret env vars that are
// currently present (non-empty) in the stack's app.yaml (the guest's own — live rootfs, or
// PBS-restored). Names that are absent/empty are simply omitted from the map; the caller's
// fail-closed gate decides what to do. The unit is never the source of secrets.
RecoverStackSecrets(name string, names []string) map[string]string
// RecreateStackFromUnit restores an app's definition from the unit's compose dir into the stack
// dir, writes app.yaml from fullEnv (encrypting secret fields), and (re-)deploys it via
// `docker compose up -d`, which re-pulls the pinned image. Secrets are NEVER regenerated.
RecreateStackFromUnit(name, composeSrcDir string, fullEnv map[string]string) error
}
// RecoveryInfo carries everything needed to write a secret-free recovery unit for a stack.
// It deliberately holds NO secret values — only the names of secret/data-key env vars, so the
// manifest can record what must be recovered from elsewhere (guest app.yaml / PBS) without the
// unit ever storing a secret or a data-encrypting key.
type RecoveryInfo struct {
StackDir string // dir holding docker-compose.yml + .felhom.yml + app.yaml
DisplayName string // app display name
ImagePins []string // pinned image tags from compose `image:` lines (re-pulled on restore)
NonSecretEnv map[string]string // env with all secret/password/data-key values removed (plaintext only)
SecretEnvVars []string // NAMES of stripped secret/password fields (recovered from guest/PBS)
DataKeyEnvVars []string // NAMES of data-encrypting-key fields (fail-closed gate on restore)
}
// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
// docker-compose.yml, in file order, de-duplicated. The image bytes are never stored in the
// recovery unit — only these pins, so restore re-pulls from the registry.
func ParseComposeImages(composePath string) []string {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
var images []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "image:") {
continue
}
img := strings.TrimSpace(strings.TrimPrefix(line, "image:"))
img = strings.Trim(img, "\"'")
// Skip variable-only images we can't pin (e.g. image: ${SOME_IMAGE})
if img == "" || strings.HasPrefix(img, "${") {
continue
}
if !seen[img] {
seen[img] = true
images = append(images, img)
}
}
return images
}
// StackSummary holds minimal stack info needed for app data discovery.
+2 -23
View File
@@ -33,35 +33,14 @@ func PrimaryBackupPath(nsRoot string) string {
return filepath.Join(nsRoot, "backups", "primary")
}
// RecoveryUnitPath returns the per-app self-contained recovery-unit ROOT under a namespace root.
// It is the existing per-app backup dir (`backups/primary/<stack>/`) — the legacy name is kept so the
// db-dumps/ and volume-dumps/ already written there need no migration; the unit gains compose/ and
// manifest.json as siblings, making the whole dir a complete, recreatable unit (Phase 2). The unit is
// secret-free: secrets/data-keys are recovered from the guest's own app.yaml (live or via PBS), never
// stored here. See backup.recoveryUnit / restore for the capture + restore flow.
func RecoveryUnitPath(nsRoot, stackName string) string {
return filepath.Join(nsRoot, "backups", "primary", stackName)
}
// RecoveryUnitComposePath returns the compose/config capture dir within an app's recovery unit
// (docker-compose.yml + .felhom.yml + secret-stripped app.yaml).
func RecoveryUnitComposePath(nsRoot, stackName string) string {
return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "compose")
}
// RecoveryUnitManifestPath returns the manifest.json path within an app's recovery unit.
func RecoveryUnitManifestPath(nsRoot, stackName string) string {
return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "manifest.json")
}
// AppDBDumpPath returns the DB dump directory for an app under a felhom-data namespace root.
func AppDBDumpPath(nsRoot, stackName string) string {
return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "db-dumps")
return filepath.Join(nsRoot, "backups", "primary", stackName, "db-dumps")
}
// AppVolumeDumpPath returns the Docker-volume dump-tar directory for an app under a namespace root.
func AppVolumeDumpPath(nsRoot, stackName string) string {
return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "volume-dumps")
return filepath.Join(nsRoot, "backups", "primary", stackName, "volume-dumps")
}
// AppDataDir returns the app data directory under a felhom-data namespace root.
@@ -1,67 +0,0 @@
package appexport
import (
"path/filepath"
"strings"
"testing"
)
// TestImportManifestAppNameTraversal is an AUDIT evidence test for finding
// [CTRL-001] (commit eea235b). It demonstrates that an imported bundle's
// manifest.AppName — which is fully attacker-controlled JSON inside the .fab —
// is used verbatim as a path segment in executeImport:
//
// stackDir := filepath.Join(stacksDir, manifest.AppName) // restore.go:339
// os.MkdirAll(stackDir, 0755) // restore.go:365
// composePath := filepath.Join(stackDir, "docker-compose.yml") // restore.go:401
//
// UnmarshalManifest performs NO validation of AppName (manifest.go:36-42), and
// no IsValidStackName/sanitizer exists in the package. A name containing ".."
// therefore escapes the stacks base directory.
//
// This test asserts the SAFE invariant ("the resolved stack dir must stay under
// the stacks base"). It FAILS at the recorded commit, which is the evidence the
// guard is missing. Do NOT "fix" the bug by weakening this test — the fix is to
// reject traversal AppNames in UnmarshalManifest / before the join.
func TestImportManifestAppNameTraversal(t *testing.T) {
const stacksDir = "/data/stacks" // stand-in for provider.GetStacksBaseDir()
cases := []struct {
name string
appName string
}{
{"parent-escape", "../evil"},
{"deep-escape", "../../etc/cron.d/x"},
{"absolute", "/etc/cron.d/x"},
}
base := filepath.Clean(stacksDir)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Exactly mirror restore.go:339.
stackDir := filepath.Join(stacksDir, tc.appName)
// The invariant the importer SHOULD enforce: stackDir stays under base.
if stackDir != base && !strings.HasPrefix(stackDir, base+string(filepath.Separator)) {
t.Fatalf("CTRL-001: manifest.AppName %q escapes stacks base: filepath.Join(%q, AppName) = %q (outside %q). "+
"executeImport then os.MkdirAll's and writes app.yaml/docker-compose.yml there with no validation.",
tc.appName, stacksDir, stackDir, base)
}
})
}
}
// TestUnmarshalManifestDoesNotValidateAppName documents that the only manifest
// parse entrypoint accepts a hostile AppName without complaint — the missing
// chokepoint for [CTRL-001].
func TestUnmarshalManifestDoesNotValidateAppName(t *testing.T) {
raw := []byte(`{"version":1,"app_name":"../../escape","display_name":"x"}`)
m, err := UnmarshalManifest(raw)
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
if strings.Contains(m.AppName, "..") {
t.Fatalf("CTRL-001: UnmarshalManifest returned a traversal AppName %q with no rejection; "+
"a sanitizer (single safe path segment, allowlist) is missing", m.AppName)
}
}
@@ -25,7 +25,6 @@ type StackSummary = appbackup.StackSummary
type AppBackupInfo = appbackup.AppBackupInfo
type AppDataPath = appbackup.AppDataPath
type AppDockerVolume = appbackup.AppDockerVolume
type RecoveryInfo = appbackup.RecoveryInfo
// --- type aliases (dbdump) ---
@@ -81,10 +80,6 @@ func ResolveDockerVolumeNames(composePath string) []string {
return appbackup.ResolveDockerVolumeNames(composePath)
}
func ParseComposeImages(composePath string) []string {
return appbackup.ParseComposeImages(composePath)
}
// humanizeBytes forwards to appbackup.HumanizeBytes; kept unexported so the
// many in-package call sites (backup.go, crossdrive.go, restore code) need no edit.
func humanizeBytes(b int64) string {
@@ -112,18 +107,6 @@ func AppVolumeDumpPath(nsRoot, stackName string) string {
return appbackup.AppVolumeDumpPath(nsRoot, stackName)
}
func RecoveryUnitPath(nsRoot, stackName string) string {
return appbackup.RecoveryUnitPath(nsRoot, stackName)
}
func RecoveryUnitComposePath(nsRoot, stackName string) string {
return appbackup.RecoveryUnitComposePath(nsRoot, stackName)
}
func RecoveryUnitManifestPath(nsRoot, stackName string) string {
return appbackup.RecoveryUnitManifestPath(nsRoot, stackName)
}
func AppDataDir(nsRoot, stackName string) string {
return appbackup.AppDataDir(nsRoot, stackName)
}
+2 -17
View File
@@ -26,10 +26,6 @@ type Manager struct {
settings *settings.Settings
stackProvider StackDataProvider
systemDataPath string // fallback drive for SSD-only apps
version string // controller version, stamped into recovery-unit manifests
// tier2Notify, if set, is called after each Tier 2 copy (success: err==nil) for notifications.
tier2Notify func(stackName, destLabel string, dur time.Duration, err error)
mu sync.Mutex
lastDBDump *DBDumpStatus
@@ -239,16 +235,9 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
m.logger.Printf("[INFO] [backup] DB dump completed: %d databases, %s total (%s)",
len(results), humanizeBytes(totalSize), duration.Round(time.Millisecond))
} else {
// Still refresh recovery units below — a partial DB failure shouldn't leave units stale.
m.logger.Printf("[WARN] [backup] some database dumps failed; refreshing recovery units anyway")
}
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest).
m.captureAllRecoveryUnits()
if !allOK {
return fmt.Errorf("some database dumps failed")
}
return nil
}
@@ -490,10 +479,6 @@ func (m *Manager) RefreshCache(nextDBDump time.Time) {
// Discover app data — all deployed stacks, backup is mandatory
if m.stackProvider != nil {
status.AppDataInfo = DiscoverAppData(m.stackProvider, status.DiscoveredDBs)
// Phase 2: keep each app's recovery unit current with its definition. Idempotent
// (checksum-skip), so this periodic refresh only writes when the config actually changed,
// and ensures units exist shortly after startup without waiting for the daily DB dump.
m.captureAllRecoveryUnits()
}
// Fill in dynamic fields under lock.
@@ -597,7 +582,7 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
// isDebug returns true if logging level is "debug".
func (m *Manager) isDebug() bool {
return m.cfg != nil && m.cfg.Logging.Level == "debug"
return m.cfg.Logging.Level == "debug"
}
func dbNames(dbs []DiscoveredDB) string {
-290
View File
@@ -1,290 +0,0 @@
package backup
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// RecoveryManifest describes an app's self-contained, SECRET-FREE recovery unit (Phase 2).
//
// The unit on a drive is `<nsRoot>/backups/primary/<app>/` and contains:
// compose/ docker-compose.yml + .felhom.yml + a SECRET-STRIPPED app.yaml
// db-dumps/ app-consistent DB dump(s) (written by the dump flow)
// volume-dumps/ named-volume tars (written by the dump flow)
// manifest.json this file
//
// The unit holds NO secret values, NO data-encrypting keys, 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. The
// secret values are recovered at restore time from the guest's own app.yaml (live on the rootfs,
// or via the PBS whole-guest snapshot) — see Restore. "Restore from the unit alone" is therefore
// honestly "unit + the guest's app.yaml"; SecretSource records that dependency explicitly.
type RecoveryManifest struct {
SchemaVersion int `json:"schema_version"`
AppName string `json:"app_name"`
DisplayName string `json:"display_name"`
ControllerVer string `json:"controller_version"`
CreatedAt string `json:"created_at"`
Drive string `json:"drive"` // HDD_PATH (in-guest mount)
NamespaceRoot string `json:"namespace_root"` // resolved felhom-data namespace root
ImagePins []string `json:"image_pins"` // image NOT stored — re-pulled on restore
SecretEnvVars []string `json:"secret_env_vars"` // NAMES only — recovered from guest/PBS
DataKeyEnvVars []string `json:"data_key_env_vars"` // fail-closed gate on restore
SecretSource string `json:"secret_source"` // human note: where secrets come from
ConfigFiles []string `json:"config_files"` // captured into compose/
DBDumps []string `json:"db_dumps"`
VolumeDumps []string `json:"volume_dumps"`
Checksums map[string]string `json:"checksums"` // sha256 of captured compose/ files
}
// SetVersion records the controller version stamped into recovery-unit manifests.
func (m *Manager) SetVersion(v string) {
m.mu.Lock()
m.version = v
m.mu.Unlock()
}
// SetTier2Notifier wires the notification callback invoked after each Tier 2 copy.
func (m *Manager) SetTier2Notifier(fn func(stackName, destLabel string, dur time.Duration, err error)) {
m.tier2Notify = fn
}
// CaptureRecoveryUnit writes/refreshes an app's secret-free recovery unit: it captures the
// compose + metadata + a secret-stripped app.yaml into compose/, enumerates the DB/volume dumps
// already present, and writes manifest.json. It NEVER writes a secret value or the Docker image.
//
// Idempotent: it builds the captured content in memory first and SKIPS all writes when the unit is
// already current (same config checksums, same dump set, same controller version) — so it can run on
// the periodic status refresh without thrashing a spinning USB drive.
func (m *Manager) CaptureRecoveryUnit(stackName string) error {
if m.stackProvider == nil {
return fmt.Errorf("no stack provider")
}
info, ok := m.stackProvider.GetStackRecoveryInfo(stackName)
if !ok {
return fmt.Errorf("stack %q not found", stackName)
}
drivePath := m.GetAppDrivePath(stackName)
if drivePath == "" || !filepath.IsAbs(drivePath) {
return fmt.Errorf("cannot determine absolute drive path for %s", stackName)
}
nsRoot := m.namespaceRoot(drivePath)
// Build the captured config CONTENT in memory (no writes yet) so we can checksum-compare.
type capFile struct {
name string
data []byte
perm os.FileMode
}
var files []capFile
checksums := make(map[string]string)
var configFiles []string
for _, fname := range []string{"docker-compose.yml", ".felhom.yml"} {
data, err := os.ReadFile(filepath.Join(info.StackDir, fname))
if err != nil {
continue // optional — capture whichever exist
}
files = append(files, capFile{fname, data, 0644})
checksums[fname] = sha256Hex(data)
configFiles = append(configFiles, fname)
}
appYaml := buildStrippedAppYaml(info)
files = append(files, capFile{"app.yaml", appYaml, 0600})
checksums["app.yaml"] = sha256Hex(appYaml)
configFiles = append(configFiles, "app.yaml")
dbDumps := listFileNames(AppDBDumpPath(nsRoot, stackName), ".sql")
volDumps := listFileNames(AppVolumeDumpPath(nsRoot, stackName), ".tar")
version := m.versionLocked()
manifestPath := RecoveryUnitManifestPath(nsRoot, stackName)
// Skip if the unit is already current — avoids needless drive writes on the periodic refresh.
if cur := readManifest(manifestPath); cur != nil &&
cur.ControllerVer == version &&
stringMapEqual(cur.Checksums, checksums) &&
stringSliceEqual(cur.DBDumps, dbDumps) &&
stringSliceEqual(cur.VolumeDumps, volDumps) {
return nil
}
composeDir := RecoveryUnitComposePath(nsRoot, stackName)
if err := os.MkdirAll(composeDir, 0755); err != nil {
return fmt.Errorf("creating recovery-unit compose dir: %w", err)
}
for _, f := range files {
if err := atomicWrite(filepath.Join(composeDir, f.name), f.data, f.perm); err != nil {
return fmt.Errorf("capturing %s: %w", f.name, err)
}
}
manifest := &RecoveryManifest{
SchemaVersion: 1,
AppName: stackName,
DisplayName: info.DisplayName,
ControllerVer: version,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Drive: drivePath,
NamespaceRoot: nsRoot,
ImagePins: info.ImagePins,
SecretEnvVars: info.SecretEnvVars,
DataKeyEnvVars: info.DataKeyEnvVars,
SecretSource: "guest app.yaml (live rootfs) or PBS whole-guest snapshot — never stored in this unit",
ConfigFiles: configFiles,
DBDumps: dbDumps,
VolumeDumps: volDumps,
Checksums: checksums,
}
if err := writeManifest(manifestPath, manifest); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
m.logger.Printf("[INFO] [backup] Recovery unit captured for %s → %s (images=%d, secrets-referenced=%d, data_keys=%d)",
stackName, RecoveryUnitPath(nsRoot, stackName), len(info.ImagePins), len(info.SecretEnvVars), len(info.DataKeyEnvVars))
return nil
}
// captureAllRecoveryUnits refreshes the recovery unit for every deployed stack. Best-effort:
// a per-app failure is logged and does not abort the others.
func (m *Manager) captureAllRecoveryUnits() {
if m.stackProvider == nil {
return
}
for _, stack := range m.stackProvider.ListDeployedStacks() {
drivePath := m.GetAppDrivePath(stack.Name)
if m.settings != nil && (m.settings.IsDisconnected(drivePath) || m.settings.IsDecommissioned(drivePath)) {
continue // drive not writable — skip, the existing unit stays as-is
}
if err := m.CaptureRecoveryUnit(stack.Name); err != nil {
m.logger.Printf("[WARN] [backup] Recovery unit capture failed for %s: %v", stack.Name, err)
}
}
}
func (m *Manager) versionLocked() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.version
}
// strippedAppYaml is the on-disk shape of the secret-free app.yaml captured into the unit.
type strippedAppYaml struct {
Deployed bool `yaml:"deployed"`
Env map[string]string `yaml:"env"`
}
// buildStrippedAppYaml renders a secret-free app.yaml (non-secret env only) as bytes. Deterministic:
// yaml.v3 sorts map keys and the secret-name list comes in stable metadata order, so identical input
// yields identical bytes (needed for the checksum-skip guard).
func buildStrippedAppYaml(info RecoveryInfo) []byte {
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: info.NonSecretEnv})
if err != nil {
body = []byte("deployed: true\nenv: {}\n")
}
header := "# Captured by felhom-controller recovery unit — SECRET-FREE.\n" +
"# Secret/data-key values are intentionally omitted; recover them at restore from the\n" +
"# guest's own app.yaml (live rootfs, or the PBS whole-guest snapshot). Stripped names:\n"
if len(info.SecretEnvVars) > 0 {
header += "# " + strings.Join(info.SecretEnvVars, ", ") + "\n"
}
return []byte(header + string(body))
}
// writeManifest writes the manifest JSON atomically.
func writeManifest(dst string, manifest *RecoveryManifest) error {
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return atomicWrite(dst, append(data, '\n'), 0644)
}
// readManifest reads an existing recovery-unit manifest (nil if absent or unparseable).
func readManifest(path string) *RecoveryManifest {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var m RecoveryManifest
if json.Unmarshal(data, &m) != nil {
return nil
}
return &m
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func stringMapEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func stringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// listFileNames returns the names of files with the given suffix in dir (sorted, none if absent).
func listFileNames(dir, suffix string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), suffix) {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names
}
// atomicWrite writes data to path via a .tmp file + rename.
func atomicWrite(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm)
if err != nil {
return err
}
if _, err := io.Copy(f, strings.NewReader(string(data))); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
@@ -1,203 +0,0 @@
package backup
import (
"encoding/json"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
// fakeRecoveryProvider is a configurable StackDataProvider for the capture + restore tests.
type fakeRecoveryProvider struct {
info RecoveryInfo
hdd string
secrets map[string]string // returned by RecoverStackSecrets
gotEnv map[string]string // captured by RecreateStackFromUnit
running bool // returned by RefreshAndIsRunning
stopped bool
}
func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
return filepath.Join(f.info.StackDir, "docker-compose.yml"), true
}
func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil }
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
func (f *fakeRecoveryProvider) StopStack(string) error { f.stopped = true; return nil }
func (f *fakeRecoveryProvider) StartStack(string) error { return nil }
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return f.info, true
}
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string {
return f.secrets
}
func (f *fakeRecoveryProvider) RecreateStackFromUnit(_, _ string, fullEnv map[string]string) error {
f.gotEnv = fullEnv
return nil
}
// TestCaptureRecoveryUnitIsSecretFree proves the captured unit (a) contains compose+config+manifest,
// (b) enumerates the existing dumps, and (c) is SECRET-FREE: a secret value present in the SOURCE
// app.yaml does NOT appear anywhere in the unit, because the capture writes the stripped NonSecretEnv
// (not the raw app.yaml). The manifest records the secret NAMES + data_key flag for recovery-from-guest.
func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
const secretVal = "SUPERSECRETVALUE-do-not-leak"
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive := filepath.Join(tmp, "drive") // in-guest namespace root (basename need not be felhom-data)
if err := os.MkdirAll(stackDir, 0755); err != nil {
t.Fatal(err)
}
// Source stack files — the raw app.yaml DELIBERATELY holds a secret to prove it's not copied.
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
"services:\n app:\n image: example/app:1.2.3\n")
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\n")
mustWrite(t, filepath.Join(stackDir, "app.yaml"),
"deployed: true\nenv:\n DB_PASSWORD: "+secretVal+"\n SUBDOMAIN: example\n")
// Pre-existing dumps (written by the dump flow before capture).
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "example"), "example-postgres.sql"), "dump")
mustWrite(t, filepath.Join(AppVolumeDumpPath(drive, "example"), "example_data.tar"), "tar")
// RecoveryInfo as the adapter would build it: secret values already stripped from NonSecretEnv.
info := RecoveryInfo{
StackDir: stackDir,
DisplayName: "Example",
ImagePins: []string{"example/app:1.2.3"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "example", "HDD_PATH": drive},
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
DataKeyEnvVars: []string{"SECRET_KEY"},
}
m := &Manager{
logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(tmp, "system"), // != drive ⇒ drive treated as in-guest, nsRoot = drive
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive},
version: "vtest",
}
if err := m.CaptureRecoveryUnit("example"); err != nil {
t.Fatalf("capture: %v", err)
}
composeDir := RecoveryUnitComposePath(drive, "example")
for _, f := range []string{"docker-compose.yml", ".felhom.yml", "app.yaml"} {
if _, err := os.Stat(filepath.Join(composeDir, f)); err != nil {
t.Errorf("missing captured config %s: %v", f, err)
}
}
// Manifest structure.
mfData, err := os.ReadFile(RecoveryUnitManifestPath(drive, "example"))
if err != nil {
t.Fatalf("manifest: %v", err)
}
var man RecoveryManifest
if err := json.Unmarshal(mfData, &man); err != nil {
t.Fatalf("manifest parse: %v", err)
}
if man.AppName != "example" || man.ControllerVer != "vtest" {
t.Errorf("manifest meta: app=%q ver=%q", man.AppName, man.ControllerVer)
}
if len(man.ImagePins) != 1 || man.ImagePins[0] != "example/app:1.2.3" {
t.Errorf("image pins: %v", man.ImagePins)
}
if len(man.SecretEnvVars) != 2 {
t.Errorf("secret env-var names: %v (want 2)", man.SecretEnvVars)
}
if len(man.DataKeyEnvVars) != 1 || man.DataKeyEnvVars[0] != "SECRET_KEY" {
t.Errorf("data-key env-vars: %v", man.DataKeyEnvVars)
}
if len(man.DBDumps) != 1 || len(man.VolumeDumps) != 1 {
t.Errorf("dumps enumerated: db=%v vol=%v", man.DBDumps, man.VolumeDumps)
}
// app.yaml in the unit must carry the non-secret env but NOT the secret value.
appy := mustRead(t, filepath.Join(composeDir, "app.yaml"))
if !strings.Contains(appy, "SUBDOMAIN") {
t.Errorf("stripped app.yaml missing non-secret env: %s", appy)
}
// SECRET-FREE invariant: the secret value must not appear ANYWHERE in the unit.
unitRoot := RecoveryUnitPath(drive, "example")
_ = filepath.WalkDir(unitRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.Contains(mustRead(t, path), secretVal) {
t.Errorf("SECRET LEAK: %q found in %s", secretVal, path)
}
return nil
})
}
// TestCaptureRecoveryUnitIdempotent proves the checksum-skip guard: a second capture with unchanged
// config does NOT rewrite the manifest (CreatedAt stable), but a config change DOES.
func TestCaptureRecoveryUnitIdempotent(t *testing.T) {
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive := filepath.Join(tmp, "drive")
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:1\n")
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "ex"), "ex.sql"), "d")
info := RecoveryInfo{StackDir: stackDir, DisplayName: "Ex", ImagePins: []string{"ex/app:1"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "ex"}}
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(tmp, "sys"),
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive}, version: "v1"}
manifestPath := RecoveryUnitManifestPath(drive, "ex")
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
first := readManifest(manifestPath)
if first == nil {
t.Fatal("manifest not written")
}
// Second capture, unchanged → skipped (manifest byte-identical incl. CreatedAt).
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
if again := readManifest(manifestPath); again.CreatedAt != first.CreatedAt {
t.Errorf("idempotent capture rewrote manifest: %q -> %q", first.CreatedAt, again.CreatedAt)
}
// Change the compose → must rewrite (config checksum differs).
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:2\n")
m.stackProvider.(*fakeRecoveryProvider).info.ImagePins = []string{"ex/app:2"}
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
changed := readManifest(manifestPath)
if len(changed.ImagePins) != 1 || changed.ImagePins[0] != "ex/app:2" {
t.Errorf("config change not captured: %v", changed.ImagePins)
}
if changed.Checksums["docker-compose.yml"] == first.Checksums["docker-compose.yml"] {
t.Errorf("compose checksum should change after edit")
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func mustRead(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
-140
View File
@@ -1,140 +0,0 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// reconcileRestoreSecrets merges the recovery unit's non-secret env with the secrets recovered from
// the guest's own app.yaml, and applies the FAIL-CLOSED data-key gate. It is the safety-critical heart
// of Phase 2b and is deliberately a pure function (no I/O) so it can be exhaustively unit-tested.
//
// Policy (per the Phase 2 design — see REPORT/CHANGELOG):
// - Regenerate NOTHING. Every secret comes from the guest (live rootfs, or PBS whole-guest restore).
// - A missing DATA-ENCRYPTING key (`dataKeyNames`) is FATAL: regenerating it would render the
// restored data unreadable, so we refuse and tell the operator to do a PBS whole-guest restore.
// - A missing resettable secret (DB password, admin password) is NON-fatal: it's returned in
// `missing` so the caller can warn; the app may simply need a credential reset, no data is lost.
func reconcileRestoreSecrets(nonSecretEnv, recoveredSecrets map[string]string, secretNames, dataKeyNames []string) (fullEnv map[string]string, missing []string, err error) {
fullEnv = make(map[string]string, len(nonSecretEnv)+len(secretNames))
for k, v := range nonSecretEnv {
fullEnv[k] = v
}
have := func(n string) bool {
v, ok := recoveredSecrets[n]
return ok && v != ""
}
for _, n := range secretNames {
if have(n) {
fullEnv[n] = recoveredSecrets[n]
} else {
missing = append(missing, n)
}
}
// Fail-closed: any unrecoverable data-encrypting key aborts the restore.
var missingDataKeys []string
for _, dk := range dataKeyNames {
if !have(dk) {
missingDataKeys = append(missingDataKeys, dk)
}
}
if len(missingDataKeys) > 0 {
return nil, missing, fmt.Errorf(
"refusing to restore: data-encrypting key(s) %v could not be recovered from the guest's app.yaml — "+
"a PBS whole-guest restore is required first (regenerating the key would render stored data unreadable)",
missingDataKeys)
}
return fullEnv, missing, nil
}
// readStrippedEnv parses the non-secret env from a recovery unit's secret-stripped app.yaml.
func readStrippedEnv(path string) map[string]string {
data, err := os.ReadFile(path)
if err != nil {
return map[string]string{}
}
var s strippedAppYaml
if yaml.Unmarshal(data, &s) != nil || s.Env == nil {
return map[string]string{}
}
return s.Env
}
// RestoreFromRecoveryUnit recreates an app from its on-drive recovery unit + the guest's own secrets.
//
// It reads the unit manifest, recovers the secret values from the guest's live app.yaml, applies the
// fail-closed data-key gate, restores the named-volume data from the unit's tars, then restores the
// app's definition from the unit and redeploys it with the reconstructed env (re-pulling the pinned
// image). No secret is ever regenerated, and no secret is read from the unit. If no unit exists it
// falls back to the legacy volume-only RestoreApp.
func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
if m.stackProvider == nil {
return fmt.Errorf("stack provider not configured")
}
m.mu.Lock()
if m.running {
m.mu.Unlock()
return fmt.Errorf("backup or restore already in progress")
}
m.running = true
m.mu.Unlock()
defer func() {
m.mu.Lock()
m.running = false
m.mu.Unlock()
}()
drivePath := m.GetAppDrivePath(stackName)
if drivePath == "" || !filepath.IsAbs(drivePath) {
return fmt.Errorf("cannot determine drive path for %s", stackName)
}
nsRoot := m.namespaceRoot(drivePath)
manifest := readManifest(RecoveryUnitManifestPath(nsRoot, stackName))
if manifest == nil {
m.logger.Printf("[WARN] [backup] No recovery unit for %s — falling back to volume-only restore", stackName)
m.mu.Lock()
m.running = false // RestoreApp re-acquires the running flag
m.mu.Unlock()
return m.RestoreApp(stackName, "")
}
composeDir := RecoveryUnitComposePath(nsRoot, stackName)
nonSecretEnv := readStrippedEnv(filepath.Join(composeDir, "app.yaml"))
// Recover secrets from the GUEST (never the unit), then apply the fail-closed gate.
recovered := m.stackProvider.RecoverStackSecrets(stackName, manifest.SecretEnvVars)
fullEnv, missing, err := reconcileRestoreSecrets(nonSecretEnv, recovered, manifest.SecretEnvVars, manifest.DataKeyEnvVars)
if err != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
return err
}
if len(missing) > 0 {
m.logger.Printf("[WARN] [backup] Restore %s: %d resettable secret(s) unrecoverable %v — proceeding (may need a credential reset; no data-key affected)",
stackName, len(missing), missing)
}
m.logger.Printf("[INFO] [backup] Restoring %s from recovery unit: images=%d, secrets recovered=%d/%d, data_keys=%d",
stackName, len(manifest.ImagePins), len(manifest.SecretEnvVars)-len(missing), len(manifest.SecretEnvVars), len(manifest.DataKeyEnvVars))
// Stop, restore named-volume data, then recreate the definition + redeploy with the recovered env.
if err := m.stackProvider.StopStack(stackName); err != nil {
m.logger.Printf("[WARN] [backup] could not stop %s before restore: %v (continuing)", stackName, err)
}
if err := m.restoreDockerVolumes(stackName, drivePath); err != nil {
m.logger.Printf("[WARN] [backup] volume restore for %s: %v (continuing)", stackName, err)
}
if err := m.stackProvider.RecreateStackFromUnit(stackName, composeDir, fullEnv); err != nil {
return fmt.Errorf("recreating %s from unit: %w", stackName, err)
}
if err := m.waitForHealthy(stackName, 90*time.Second); err != nil {
m.logger.Printf("[WARN] [backup] %s restored but health check failed: %v", stackName, err)
}
m.logger.Printf("[INFO] [backup] Restore-from-unit completed: %s", stackName)
return nil
}
@@ -1,127 +0,0 @@
package backup
import (
"io"
"log"
"path/filepath"
"testing"
)
// TestRestoreFromRecoveryUnitOrchestration exercises the full in-process flow: read manifest →
// recover secrets → apply gate → recreate with the reconciled env. It proves (a) on success the
// recreate is called with non-secret env + recovered secrets merged, and (b) on a missing data-key the
// restore is REFUSED and recreate is never called.
func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
newUnit := func(t *testing.T) (drive string) {
tmp := t.TempDir()
drive = filepath.Join(tmp, "drive")
// stripped (secret-free) app.yaml in the unit
mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"),
"deployed: true\nenv:\n SUBDOMAIN: trips\n")
man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v",
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"}, DataKeyEnvVars: []string{"SECRET_KEY"}}
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
t.Fatal(err)
}
return drive
}
t.Run("success — recreate called with merged env", func(t *testing.T) {
drive := newUnit(t)
fake := &fakeRecoveryProvider{
hdd: drive,
running: true, // so the post-restore health wait returns promptly
secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"},
}
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
stackProvider: fake}
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
t.Fatalf("restore: %v", err)
}
if fake.gotEnv == nil {
t.Fatal("recreate was not called")
}
if fake.gotEnv["SUBDOMAIN"] != "trips" || fake.gotEnv["DB_PASSWORD"] != "pw" || fake.gotEnv["SECRET_KEY"] != "deadbeef" {
t.Errorf("recreate got wrong env: %v", fake.gotEnv)
}
if !fake.stopped {
t.Errorf("app should be stopped before restore")
}
})
t.Run("data-key unrecoverable — REFUSED, recreate not called", func(t *testing.T) {
drive := newUnit(t)
fake := &fakeRecoveryProvider{
hdd: drive,
secrets: map[string]string{"DB_PASSWORD": "pw"}, // SECRET_KEY (data_key) missing
}
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
stackProvider: fake}
err := m.RestoreFromRecoveryUnit("app")
if err == nil {
t.Fatal("expected fail-closed refusal, got nil")
}
if fake.gotEnv != nil {
t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
}
})
}
// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
func TestReconcileRestoreSecrets(t *testing.T) {
nonSecret := map[string]string{"SUBDOMAIN": "trips", "DOMAIN": "demo-felhom.eu"}
t.Run("all recovered, no data_key — full env, no error", func(t *testing.T) {
recovered := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
[]string{"DB_PASSWORD", "SECRET_KEY"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(missing) != 0 {
t.Errorf("missing: %v", missing)
}
// Non-secret + both secrets present, and recovered values used VERBATIM (regenerate nothing).
if full["SUBDOMAIN"] != "trips" || full["DB_PASSWORD"] != "pw" || full["SECRET_KEY"] != "deadbeef" {
t.Errorf("full env wrong: %v", full)
}
})
t.Run("data_key missing — FAIL CLOSED (refuse)", func(t *testing.T) {
recovered := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
full, _, err := reconcileRestoreSecrets(nonSecret, recovered,
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
if err == nil {
t.Fatal("expected fail-closed error for missing data-encrypting key, got nil")
}
if full != nil {
t.Errorf("full env should be nil on refusal, got %v", full)
}
})
t.Run("data_key empty value — FAIL CLOSED", func(t *testing.T) {
recovered := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
_, _, err := reconcileRestoreSecrets(nonSecret, recovered, []string{"SECRET_KEY"}, []string{"SECRET_KEY"})
if err == nil {
t.Fatal("empty data-key value must fail closed")
}
})
t.Run("resettable secret missing — proceed with warning", func(t *testing.T) {
recovered := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
if err != nil {
t.Fatalf("a missing resettable secret must NOT fail closed: %v", err)
}
if len(missing) != 1 || missing[0] != "DB_PASSWORD" {
t.Errorf("missing should be [DB_PASSWORD], got %v", missing)
}
if full["SECRET_KEY"] != "deadbeef" {
t.Errorf("data-key should be preserved verbatim: %v", full)
}
if _, present := full["DB_PASSWORD"]; present {
t.Errorf("missing resettable secret should be absent, not regenerated")
}
})
}
-393
View File
@@ -1,393 +0,0 @@
package backup
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// Tier 2 = an off-drive (different physical disk) copy of an HDD app's recovery unit + bulk userdata.
// It is the ONLY off-drive protection that browsable HDD userdata can get — PBS can't reach bind
// mounts. Auto-enabled for every HDD app; the target is auto-picked: prefer another registered
// user-data drive (can hold bulk), else the internal SSD for SMALL units only — and the SSD is the
// guest rootfs (~8 GB), so we REFUSE rather than fill it (a size-aware headroom guard). When no
// off-drive target fits, we record an honest "needs a 2nd HDD" status instead of silently doing
// nothing useful.
const gibibyte = 1024 * 1024 * 1024
var (
errNoOffDiskTarget = errors.New("no off-drive target (single drive, app already on the system disk)")
errSSDNoHeadroom = errors.New("the internal SSD lacks headroom for this app's data — a 2nd drive is required for off-drive backup")
)
// Tier2Target is a resolved off-drive destination for an app's Tier 2 copy.
type Tier2Target struct {
NamespaceRoot string // felhom-data namespace root on the target drive
Label string // human label (UI)
IsSystemDrive bool // target is the internal SSD/system drive (DB/config only)
Reason string // why this target (Hungarian, for UI/logs)
}
// tier2FitsHeadroom reports whether a unit of unitGB fits on a system/rootfs drive while leaving a
// reserve free. Reserve = max(2 GB, 20% of total) — this is what protects the small (~8 GB) guest
// rootfs from being filled by a Tier 2 copy. Pure function (unit-tested).
func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool {
reserve := totalGB * 0.20
if reserve < 2.0 {
reserve = 2.0
}
return (availGB - unitGB) >= reserve
}
// selectTier2Target picks the off-drive destination for an app's Tier 2 copy. A customer-pinned
// target (PreferredTarget, set from the config panel) wins when it is still valid; otherwise it
// auto-picks: another user-data drive, else the internal SSD for small units (headroom-guarded).
func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tier2Target, error) {
sourceDrive := m.GetAppDrivePath(stackName)
if sourceDrive == "" {
return nil, fmt.Errorf("no source drive for %s", stackName)
}
// 0. Honor a customer-pinned target if it is still valid (registered, schedulable, off-disk).
// An invalid pin (gone / same physical disk) silently falls through to the auto-pick.
if m.settings != nil {
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.PreferredTarget != "" {
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path != cd.PreferredTarget {
continue
}
if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) {
break // pinned target is on the same physical disk — not off-drive; fall through
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
IsSystemDrive: false,
Reason: "kézi választás",
}, nil
}
}
}
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk (can hold bulk userdata).
if m.settings != nil {
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) {
continue
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
IsSystemDrive: false,
Reason: "másik adatmeghajtó",
}, nil
}
}
// 2. Fall back to the internal SSD (system data path) — SMALL units only.
sys := m.systemDataPath
if sys == "" || system.SamePhysicalDevice(sourceDrive, sys) {
return nil, errNoOffDiskTarget // single drive / app already on the system disk
}
if !m.tier2FitsSystemDrive(sys, unitSizeBytes) {
return nil, errSSDNoHeadroom // would fill the ~8 GB rootfs — refuse, don't fill
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended
Label: "belső SSD (rendszer)",
IsSystemDrive: true,
Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
}, nil
}
// tier2FitsSystemDrive checks the size-aware rootfs-headroom guard for the SSD target.
func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
di := system.GetDiskUsage(sys)
if di == nil {
return false // can't determine free space → refuse (fail-closed for the rootfs)
}
return tier2FitsHeadroom(di.AvailGB, di.TotalGB, float64(unitSizeBytes)/gibibyte)
}
// RunTier2 makes/refreshes the off-drive copy of a single HDD app's recovery unit + userdata.
// Best-effort and idempotent (rsync mirror). Records status into settings for the UI; returns an
// error only on an actual copy failure (no valid target is a recorded status, not an error).
func (m *Manager) RunTier2(stackName string) error {
// Customer turned Tier 2 off for this app (config panel) — skip without touching status.
if m.settings != nil {
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.UserDisabled {
m.logger.Printf("[INFO] [backup] Tier 2 for %s skipped — disabled by customer", stackName)
return nil
}
}
sourceDrive := m.GetAppDrivePath(stackName)
if sourceDrive == "" {
return fmt.Errorf("no source drive for %s", stackName)
}
sourceNsRoot := m.namespaceRoot(sourceDrive)
unitDir := RecoveryUnitPath(sourceNsRoot, stackName)
appDataDir := AppDataDir(sourceNsRoot, stackName)
if _, err := os.Stat(unitDir); err != nil {
return nil // no recovery unit yet — nothing to copy
}
unitSize := dirSizeBytes(unitDir) + dirSizeBytes(appDataDir)
target, err := m.selectTier2Target(stackName, unitSize)
if err != nil {
reason := tier2NoTargetReason(err)
m.recordTier2NoTarget(stackName, reason)
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
return nil
}
// Defense-in-depth off-drive guard (selection already enforced it).
if system.SamePhysicalDevice(sourceDrive, target.NamespaceRoot) {
m.recordTier2NoTarget(stackName, "a kiválasztott cél ugyanazon a fizikai lemezen van")
return nil
}
destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", stackName)
start := time.Now()
if err := rsyncMirror(unitDir, filepath.Join(destBase, "recovery-unit")); err != nil {
m.recordTier2Failure(stackName, target, err)
if m.tier2Notify != nil {
m.tier2Notify(stackName, target.Label, time.Since(start), err)
}
return fmt.Errorf("tier2 rsync unit for %s: %w", stackName, err)
}
if _, e := os.Stat(appDataDir); e == nil {
if err := rsyncMirror(appDataDir, filepath.Join(destBase, "appdata")); err != nil {
m.recordTier2Failure(stackName, target, err)
if m.tier2Notify != nil {
m.tier2Notify(stackName, target.Label, time.Since(start), err)
}
return fmt.Errorf("tier2 rsync appdata for %s: %w", stackName, err)
}
}
dur := time.Since(start)
m.recordTier2Success(stackName, target, unitSize, dur)
if m.tier2Notify != nil {
m.tier2Notify(stackName, target.Label, dur, nil)
}
m.logger.Printf("[INFO] [backup] Tier 2 copied %s → %s (%s, %s)%s",
stackName, destBase, humanizeBytes(unitSize), dur.Round(time.Second),
map[bool]string{true: " [SSD: DB/config only]", false: ""}[target.IsSystemDrive])
return nil
}
// RunAllTier2 runs Tier 2 for every deployed HDD app (apps whose data lives on an external drive —
// non-HDD apps live on the rootfs and are already inside the PBS whole-guest snapshot).
func (m *Manager) RunAllTier2() {
if m.stackProvider == nil {
return
}
var n int
for _, stack := range m.stackProvider.ListDeployedStacks() {
if m.stackProvider.GetStackHDDPath(stack.Name) == "" {
continue // not an HDD app — its data is on the rootfs, covered by PBS
}
if m.settings != nil && (m.settings.IsDisconnected(m.GetAppDrivePath(stack.Name)) ||
m.settings.IsDecommissioned(m.GetAppDrivePath(stack.Name))) {
continue
}
if err := m.RunTier2(stack.Name); err != nil {
m.logger.Printf("[WARN] [backup] Tier 2 failed for %s: %v", stack.Name, err)
}
n++
}
m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d HDD app(s) processed", n)
}
// --- per-app config-panel view (drives the Tier-2 "Beállítás" page) ---
// Tier2Option is one selectable off-drive destination in the config panel.
type Tier2Option struct {
Path string // registered storage path (the value persisted as PreferredTarget)
Label string // human label for the dropdown
}
// Tier2Info is the per-app Tier-2 view the config panel renders. It exposes the effective target
// (pinned or auto), whether that is the size-limited internal SSD, the honest no-target reason, and
// the off-disk drives the customer may pin — so the control is meaningful even with a single target.
type Tier2Info struct {
IsHDDApp bool // false = the app lives on the rootfs (already inside the PBS whole-guest snapshot)
SourceDrive string // where the app's data currently lives
Disabled bool // customer turned Tier 2 off
Preferred string // customer-pinned target path ("" = automatic)
EffectiveLabel string // label of the target that WOULD be used right now
EffectiveIsSSD bool // the effective target is the internal SSD (DB/config only)
EffectiveDesc string // why this target (Hungarian)
NoTarget bool // no off-drive target fits at all
NoTargetReason string // honest reason when NoTarget
Alternatives []Tier2Option
}
// Tier2Info builds the config-panel view for one app. Read-only (no status writes).
func (m *Manager) Tier2Info(stackName string) Tier2Info {
var info Tier2Info
if m.stackProvider != nil {
info.IsHDDApp = m.stackProvider.GetStackHDDPath(stackName) != ""
}
source := m.GetAppDrivePath(stackName)
info.SourceDrive = source
if m.settings != nil {
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil {
info.Disabled = cd.UserDisabled
info.Preferred = cd.PreferredTarget
}
// Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk.
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == source || system.SamePhysicalDevice(source, sp.Path) {
continue
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
info.Alternatives = append(info.Alternatives, Tier2Option{Path: sp.Path, Label: label})
}
}
// Resolve what the runner WOULD pick right now (real unit size feeds the SSD headroom guard).
sourceNsRoot := m.namespaceRoot(source)
unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName)) + dirSizeBytes(AppDataDir(sourceNsRoot, stackName))
target, err := m.selectTier2Target(stackName, unitSize)
if err != nil {
info.NoTarget = true
info.NoTargetReason = tier2NoTargetReason(err)
return info
}
info.EffectiveLabel = target.Label
info.EffectiveIsSSD = target.IsSystemDrive
info.EffectiveDesc = target.Reason
return info
}
// --- status persistence (drives the "2. mentés" UI card) ---
// withTier2Prefs carries the customer-preference fields (UserDisabled/PreferredTarget) from any
// existing config into a freshly-built status struct, so a runner status write never clobbers them.
func (m *Manager) withTier2Prefs(stackName string, cfg *settings.CrossDriveBackup) *settings.CrossDriveBackup {
if m.settings != nil {
if existing := m.settings.GetCrossDriveConfig(stackName); existing != nil {
cfg.UserDisabled = existing.UserDisabled
cfg.PreferredTarget = existing.PreferredTarget
}
}
return cfg
}
func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, dur time.Duration) {
if m.settings == nil {
return
}
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
Enabled: true,
Method: "rsync",
DestinationPath: target.NamespaceRoot,
Schedule: "daily",
LastRun: time.Now().Format(time.RFC3339),
LastStatus: "ok",
LastDuration: dur.Round(time.Second).String(),
LastSizeHuman: humanizeBytes(sizeBytes),
}))
}
func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) {
if m.settings == nil {
return
}
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
Enabled: true,
Method: "rsync",
DestinationPath: target.NamespaceRoot,
Schedule: "daily",
LastRun: time.Now().Format(time.RFC3339),
LastStatus: "error",
LastError: cause.Error(),
}))
}
func (m *Manager) recordTier2NoTarget(stackName, reason string) {
if m.settings == nil {
return
}
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
Enabled: false,
Method: "rsync",
Schedule: "daily",
LastStatus: "no_target",
LastError: reason,
}))
}
func tier2NoTargetReason(err error) string {
switch {
case errors.Is(err, errSSDNoHeadroom):
return "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges"
case errors.Is(err, errNoOffDiskTarget):
return "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
default:
return err.Error()
}
}
// --- helpers ---
// rsyncMirror mirrors src→dst with rsync -a --delete (exact copy, browsable on disk, no versioning).
func rsyncMirror(src, dst string) error {
if err := os.MkdirAll(dst, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", dst, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
// Trailing slashes: copy the CONTENTS of src into dst.
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", strings.TrimRight(src, "/")+"/", strings.TrimRight(dst, "/")+"/")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// dirSizeBytes returns the total size of a directory via `du -sb` (0 if absent/error).
func dirSizeBytes(dir string) int64 {
if _, err := os.Stat(dir); err != nil {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "du", "-sb", dir).Output()
if err != nil {
return 0
}
fields := strings.Fields(string(out))
if len(fields) == 0 {
return 0
}
var size int64
if _, err := fmt.Sscanf(fields[0], "%d", &size); err != nil {
return 0
}
return size
}
-115
View File
@@ -1,115 +0,0 @@
package backup
import (
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// newTestManager builds a Manager backed by a real (temp-file) settings store and the given
// system-data path as the SSD fallback source (no stackProvider → source = systemDataPath).
func newTestManager(t *testing.T, systemDataPath string) (*Manager, *settings.Settings) {
t.Helper()
logger := log.New(os.Stderr, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), logger)
if err != nil {
t.Fatalf("settings.Load: %v", err)
}
cfg := &config.Config{}
cfg.Paths.SystemDataPath = systemDataPath
return NewManager(cfg, sett, logger), sett
}
// A customer-pinned PreferredTarget must win over the auto-pick (which would take the first
// off-disk drive), and be reported with the "kézi választás" reason.
func TestSelectTier2Target_HonorsPreferred(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
// Two eligible drives; auto-pick would take the alphabetically-first ("/mnt/a").
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/a", Label: "A", Schedulable: true}); err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/b", Label: "B", Schedulable: true}); err != nil {
t.Fatal(err)
}
if err := sett.SetTier2Preference("app", false, "/mnt/b"); err != nil {
t.Fatal(err)
}
target, err := m.selectTier2Target("app", 1024)
if err != nil {
t.Fatalf("selectTier2Target: %v", err)
}
if target.NamespaceRoot != filepath.FromSlash("/mnt/b") {
t.Errorf("NamespaceRoot = %q, want /mnt/b (pinned)", target.NamespaceRoot)
}
if target.Reason != "kézi választás" {
t.Errorf("Reason = %q, want 'kézi választás'", target.Reason)
}
}
// An invalid pin (path not registered) silently falls through to the auto-pick.
func TestSelectTier2Target_InvalidPreferredFallsBack(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/a", Label: "A", Schedulable: true}); err != nil {
t.Fatal(err)
}
if err := sett.SetTier2Preference("app", false, "/mnt/gone"); err != nil {
t.Fatal(err)
}
target, err := m.selectTier2Target("app", 1024)
if err != nil {
t.Fatalf("selectTier2Target: %v", err)
}
if target.NamespaceRoot != filepath.FromSlash("/mnt/a") || target.Reason != "másik adatmeghajtó" {
t.Errorf("got %q/%q, want /mnt/a auto-pick", target.NamespaceRoot, target.Reason)
}
}
// A runner status write must NOT clobber the customer's preference fields.
func TestRecordTier2_PreservesPreference(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
if err := sett.SetTier2Preference("app", true, "/mnt/b"); err != nil {
t.Fatal(err)
}
m.recordTier2NoTarget("app", "teszt")
cd := sett.GetCrossDriveConfig("app")
if cd == nil {
t.Fatal("config missing after status write")
}
if !cd.UserDisabled || cd.PreferredTarget != "/mnt/b" {
t.Errorf("preference clobbered: UserDisabled=%v PreferredTarget=%q", cd.UserDisabled, cd.PreferredTarget)
}
if cd.LastStatus != "no_target" {
t.Errorf("LastStatus = %q, want no_target", cd.LastStatus)
}
}
// TestTier2FitsHeadroom covers the size-aware rootfs-headroom guard that protects the ~8 GB guest
// rootfs from being filled by a Tier 2 SSD copy (reserve = max(2 GB, 20% of total)).
func TestTier2FitsHeadroom(t *testing.T) {
cases := []struct {
name string
availGB, totalGB, unitGB float64
want bool
}{
// 8 GB rootfs, ~2.4 GB free: a tiny unit fits (reserve = 2 GB), a 1 GB unit does NOT.
{"8G rootfs, tiny unit fits", 2.4, 8.0, 0.02, true},
{"8G rootfs, 1G unit refused", 2.4, 8.0, 1.0, false},
{"8G rootfs, 0.3G unit fits", 2.4, 8.0, 0.3, true},
// Reserve is the larger of 2 GB and 20%: on 8 GB, 20% = 1.6 GB < 2 GB, so 2 GB applies.
{"8G rootfs exactly at 2G reserve", 2.0, 8.0, 0.0, true},
{"8G rootfs just under reserve", 2.0, 8.0, 0.01, false},
// Large drive: 20% reserve dominates (204.8 GB on a 1 TB drive).
{"1TB drive, 50G unit fits", 500.0, 1024.0, 50.0, true},
{"1TB drive, 320G unit refused (under 20% reserve)", 500.0, 1024.0, 320.0, false},
}
for _, c := range cases {
if got := tier2FitsHeadroom(c.availGB, c.totalGB, c.unitGB); got != c.want {
t.Errorf("%s: tier2FitsHeadroom(avail=%.2f,total=%.2f,unit=%.2f)=%v want %v",
c.name, c.availGB, c.totalGB, c.unitGB, got, c.want)
}
}
}
+1 -6
View File
@@ -44,12 +44,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
sysInfo.CPUPercent, sysInfo.TemperatureCelsius, sysInfo.TemperatureSource)
}
// 1. Disk usage (SSD). NOTE (storage-split): sysInfo.DiskPercent statfs's the controller
// container's "/", whose overlay upperdir lives on the guest's /var/lib/docker volume — so this
// IS the Docker-data volume guard (post-split it's the dedicated data volume; pre-split it's the
// rootfs — either way it's wherever Docker's data-root lives). Warn at 80% / crit at 90% used
// trips ABOVE the prevention layer's 10%-free reserved buffer, so the customer is warned before
// the deploy gate even engages.
// 1. Disk usage (SSD)
if sysInfo.DiskPercent > 0 {
if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
report.Issues = append(report.Issues, fmt.Sprintf("SSD disk usage critical: %.0f%%", sysInfo.DiskPercent))
-26
View File
@@ -85,12 +85,6 @@ type CrossDriveBackup struct {
LastError string `json:"last_error,omitempty"`
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
// Customer preference (set from the per-app Tier-2 config panel; PRESERVED across the runner's
// status writes). UserDisabled turns Tier 2 off for this app; PreferredTarget pins a chosen
// destination drive (a registered storage Path) instead of the auto-pick ("" = auto).
UserDisabled bool `json:"user_disabled,omitempty"`
PreferredTarget string `json:"preferred_target,omitempty"`
}
// StoragePath represents a registered external storage location.
@@ -394,26 +388,6 @@ func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveB
return s.save()
}
// SetTier2Preference records the customer's Tier-2 choice (from the per-app config panel) WITHOUT
// disturbing the runner's status fields: it merges into the existing config if one is present, else
// seeds a minimal config carrying just the preference. The Tier-2 runner reads UserDisabled (skip)
// and PreferredTarget (pin a destination) and preserves both on every status write.
func (s *Settings) SetTier2Preference(stackName string, disabled bool, preferredTarget string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.AppBackup == nil {
s.AppBackup = make(map[string]AppBackupPrefs)
}
existing := s.AppBackup[stackName]
if existing.CrossDrive == nil {
existing.CrossDrive = &CrossDriveBackup{Method: "rsync", Schedule: "daily"}
}
existing.CrossDrive.UserDisabled = disabled
existing.CrossDrive.PreferredTarget = preferredTarget
s.AppBackup[stackName] = existing
return s.save()
}
// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not).
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
s.mu.RLock()
-42
View File
@@ -431,48 +431,6 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
return m.RefreshStatus()
}
// RedeployFromEnv writes app.yaml from the given FULL env (encrypting secret fields) and (re-)deploys
// the stack with `docker compose up -d`, which re-pulls the pinned image. Used by the restore-from-unit
// flow (Phase 2b): unlike UpdateStackConfig it sets the full env INCLUDING locked secrets — which were
// recovered from the guest's own app.yaml, never regenerated. Caller is responsible for the gate.
func (m *Manager) RedeployFromEnv(name string, env map[string]string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
cfg := &AppConfig{
Deployed: true,
DeployedAt: time.Now().UTC().Format(time.RFC3339),
Env: env,
}
for _, f := range meta.DeployFields {
if f.LockedAfterDeploy {
cfg.LockedFields = append(cfg.LockedFields, f.EnvVar)
}
}
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving app config: %w", err)
}
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.Deployed = true
s.AppConfig = cfg
}
m.mu.Unlock()
m.logger.Printf("[INFO] [stacks] Redeploying %s from recovery unit with %d env vars", name, len(env))
deployEnv := m.stackEnv(stackDir) // decrypts secrets back for compose
if _, err := m.composeExecCustomEnv(stackDir, deployEnv, "up", "-d"); err != nil {
return fmt.Errorf("compose up: %w", err)
}
m.logPostStartStatus(name, stackDir, deployEnv)
return m.RefreshStatus()
}
// composeExecWithEnv runs a compose command with custom env vars injected.
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
cmdEnv := os.Environ()
-17
View File
@@ -71,23 +71,6 @@ type DeployField struct {
Description string `yaml:"description" json:"description"`
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
Options []SelectOption `yaml:"options" json:"options,omitempty"`
// DataKey marks a field as a DATA-ENCRYPTING key (e.g. AdventureLog's "Titkosítási kulcs"):
// the app encrypts stored data with it, so regenerating it would render restored data
// unreadable. It is a fail-closed annotation only — the recovery unit never stores secrets;
// at restore the controller refuses (rather than silently restoring garbage) if a data_key
// app's key cannot be recovered from the guest's app.yaml (live or via PBS). See Phase 2.
DataKey bool `yaml:"data_key,omitempty" json:"data_key,omitempty"`
}
// DataKeyEnvVars returns the env-var names of fields marked data_key:true.
func (m *Metadata) DataKeyEnvVars() []string {
var out []string
for _, f := range m.DeployFields {
if f.DataKey {
out = append(out, f.EnvVar)
}
}
return out
}
// SelectOption is a choice for "select" type fields.
@@ -1,40 +0,0 @@
package stacks
import (
"os"
"path/filepath"
"testing"
)
// TestDataKeyParsing proves the catalog `data_key: true` annotation flows through .felhom.yml parsing
// into Metadata.DataKeyEnvVars() — the capture-side half of the Phase 2b fail-closed mechanism. The
// fail-closed gate itself is unit-tested in internal/backup (reconcileRestoreSecrets).
func TestDataKeyParsing(t *testing.T) {
dir := t.TempDir()
// Mirrors adventurelog/.felhom.yml: SECRET_KEY is a data-encrypting key, DB_PASSWORD is resettable.
yml := `display_name: AdventureLog
deploy_fields:
- env_var: SECRET_KEY
label: "Titkosítási kulcs"
type: secret
data_key: true
- env_var: DB_PASSWORD
label: "Adatbázis jelszó"
type: secret
`
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(yml), 0644); err != nil {
t.Fatal(err)
}
meta := LoadMetadata(dir)
dk := meta.DataKeyEnvVars()
if len(dk) != 1 || dk[0] != "SECRET_KEY" {
t.Fatalf("DataKeyEnvVars() = %v, want [SECRET_KEY]", dk)
}
// Both secrets are sensitive (stripped from the unit); only SECRET_KEY is a data_key (fail-closed).
sens := SensitiveEnvVars(&meta)
if len(sens) != 2 {
t.Errorf("SensitiveEnvVars() = %v, want both SECRET_KEY and DB_PASSWORD", sens)
}
}
-56
View File
@@ -1,56 +0,0 @@
package system
// Docker-data volume headroom — the infra-protection prevention layer (storage-split slice).
//
// After the OS/Docker-data split, /var/lib/docker is a dedicated volume holding ALL images +
// overlay + named volumes (controller/traefik/cloudflared/filebrowser AND customer apps). Infra is
// protected by PREVENTION, not placement: a reserved buffer the controller refuses to deploy into,
// so the volume can't be filled to the point the infra containers can't write. This file measures
// that volume and computes the reserved-buffer verdict; the deploy gate + UI consume it.
// DockerVolumePath is the path whose filesystem backs Docker's data-root as seen from INSIDE the
// controller container. The controller's own root ("/") is an overlay whose upperdir lives on the
// guest's /var/lib/docker volume, so statfs("/") reports THAT volume's capacity/free — i.e. the
// Docker-data volume the split isolates (and, pre-split, the rootfs — correct either way: it is
// always wherever Docker's data-root lives).
const DockerVolumePath = "/"
// DockerVolumeReserveGB returns the reserved-buffer floor (GiB) for the Docker-data volume:
// max(5 GB, 10% of total). Deploys are refused once free space reaches this floor so the infra
// containers keep running even when apps would otherwise fill the volume. (10% rather than the
// Tier-2 guard's 20%: on a large data volume 20% would reserve an absurd amount; infra needs only
// modest headroom for logs/overlay writes, and the runtime disk-warning at 80% used trips first.)
func DockerVolumeReserveGB(totalGB float64) float64 {
reserve := totalGB * 0.10
if reserve < 5.0 {
reserve = 5.0
}
return reserve
}
// DockerVolumeHeadroom is the Docker-data volume's capacity view for the prevention layer.
type DockerVolumeHeadroom struct {
TotalGB float64
AvailGB float64
ReserveGB float64
BelowReserve bool // free space is at/under the reserved buffer → refuse new deploys
OK bool // stats were readable (false → callers must FAIL-OPEN, not block)
}
// GetDockerVolumeHeadroom measures the Docker-data volume and computes the reserved-buffer verdict.
// OK=false when the stats can't be read; callers MUST fail-open (do not block deploys on a transient
// measurement error — the buffer is a safety net, not a security control).
func GetDockerVolumeHeadroom() DockerVolumeHeadroom {
di := GetDiskUsage(DockerVolumePath)
if di == nil || di.TotalGB <= 0 {
return DockerVolumeHeadroom{}
}
reserve := DockerVolumeReserveGB(di.TotalGB)
return DockerVolumeHeadroom{
TotalGB: di.TotalGB,
AvailGB: di.AvailGB,
ReserveGB: reserve,
BelowReserve: di.AvailGB <= reserve,
OK: true,
}
}
@@ -1,24 +0,0 @@
package system
import "testing"
// DockerVolumeReserveGB = max(5 GB, 10% of total): a flat 5 GB floor for small volumes, scaling to
// 10% on larger ones (so a 256 GB data volume reserves ~25.6 GB, not the Tier-2 guard's 20%).
func TestDockerVolumeReserveGB(t *testing.T) {
cases := []struct {
name string
totalGB float64
want float64
}{
{"tiny volume uses the 5G floor", 16, 5},
{"50G volume: 10% = 5G ties the floor", 50, 5},
{"100G volume: 10% dominates", 100, 10},
{"256G data volume", 256, 25.6},
{"zero total still floors at 5G", 0, 5},
}
for _, c := range cases {
if got := DockerVolumeReserveGB(c.totalGB); got != c.want {
t.Errorf("%s: DockerVolumeReserveGB(%.0f) = %.2f, want %.2f", c.name, c.totalGB, got, c.want)
}
}
}
@@ -233,17 +233,6 @@ func isSameBlockDevice(pathA, pathB string) bool {
return statA.Dev == statB.Dev
}
// SamePhysicalDevice reports whether two paths resolve to the same block device. Used by the Tier 2
// off-drive guard to refuse copying an app's backup onto the same physical disk as its source (the
// whole point of Tier 2 is to survive that disk failing). Returns false if either path can't be
// stat'd (fail-open to "different" would be unsafe, so callers must also verify the dest separately —
// but in practice an unstattable path fails earlier). NOTE: this is mount/device-granularity; two
// partitions on one physical disk look "different" here — the agent's durable-id is the stronger
// guarantee for that case, but for the felhom layout (external drive vs system rootfs) this suffices.
func SamePhysicalDevice(a, b string) bool {
return isSameBlockDevice(a, b)
}
// stripPartition strips the partition suffix from a device name.
// e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0".
func stripPartition(base string) string {
@@ -48,9 +48,6 @@ type DiskUsageInfo struct {
// GetDiskUsage returns nil on non-Linux.
func GetDiskUsage(_ string) *DiskUsageInfo { return nil }
// SamePhysicalDevice always returns false on non-Linux (dev/testing only — Tier 2 runs on Linux).
func SamePhysicalDevice(_, _ string) bool { return false }
// FSInfo holds filesystem type, device, and disk model info.
type FSInfo struct {
FSType string
@@ -2,9 +2,6 @@ package web
import (
"net/http"
"sort"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// Agent-backed host metrics (slice 9).
@@ -37,67 +34,5 @@ func (s *Server) ServeHostMetricsAPI(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
// The agent enumerates storages via `pvesm` in a non-deterministic order, so #host-storage-bars
// reordered on every poll (item 2). Stabilise the order Go-side and attach friendly Hungarian
// labels + a one-line purpose per entry — display-only; we NEVER rename the PVE storage ids.
enrichHostStorageTargets(resp.StorageTargets)
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
// enrichHostStorageTargets sorts the host's storage targets into a stable, customer-meaningful
// order (user-data → system+apps → backup → other; alphabetical by id within a tier) and fills in
// a friendly label + purpose per entry. Mirrors the disk-overview's sortDisksForView contract:
// a Go-side ordering beats relying on the agent's enumeration order or template JS.
func enrichHostStorageTargets(targets []agentapi.StorageTarget) {
sort.SliceStable(targets, func(i, j int) bool {
if ri, rj := storageTypeRank(targets[i].Type), storageTypeRank(targets[j].Type); ri != rj {
return ri < rj
}
return targets[i].Name < targets[j].Name
})
for i := range targets {
label, purpose := storageLabelAndPurpose(targets[i])
targets[i].Label = label
targets[i].Purpose = purpose
}
}
// storageTypeRank orders storage by what the customer cares about: where their app data lives
// first, then the system/app disk, then backup targets. Lower sorts first.
func storageTypeRank(typ string) int {
switch typ {
case "usb", "local-dir":
return 0 // external user-data drives (where browsable app data lives)
case "lvmthin", "lvm":
return 1 // the internal SSD: OS + the guest/app volumes
case "local":
return 2 // builtin dir: templates + local vzdump backups
case "pbs", "nfs", "cifs":
return 3 // backup targets (offsite / network)
default:
return 4
}
}
// storageLabelAndPurpose maps a storage target to a friendly Hungarian label + one-line purpose.
// Falls back to the raw id for unrecognised types. The raw id stays in Name (rendered muted).
func storageLabelAndPurpose(t agentapi.StorageTarget) (string, string) {
switch t.Type {
case "usb":
return "Külső adattároló (USB)", "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak."
case "local-dir":
return "Külső adattároló", "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak."
case "lvmthin", "lvm":
return "Belső SSD rendszer és alkalmazások", "Az operációs rendszer és a telepített alkalmazások tárhelye."
case "local":
return "Belső lemez sablonok és helyi mentések", "Rendszersablonok és helyi biztonsági mentések."
case "pbs":
return "Távoli biztonsági mentés", "Titkosított, telephelyen kívüli biztonsági mentések."
case "nfs":
return "Hálózati mentés (NFS)", "Hálózati tárolón őrzött biztonsági mentések."
case "cifs":
return "Hálózati mentés (SMB)", "Hálózati tárolón őrzött biztonsági mentések."
default:
return t.Name, ""
}
}
@@ -1,81 +0,0 @@
package web
import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// enrichHostStorageTargets must produce a STABLE order regardless of how the agent enumerated the
// storages (the #host-storage-bars reorder bug, item 2), and attach a friendly label + purpose per
// entry without ever mutating the raw PVE id (Name).
func TestEnrichHostStorageTargets_OrderAndLabels(t *testing.T) {
// Deliberately shuffled relative to the desired display order.
targets := []agentapi.StorageTarget{
{Name: "felhom-pbs", Type: "pbs"},
{Name: "local", Type: "local"},
{Name: "felhom-usb", Type: "usb"},
{Name: "local-lvm", Type: "lvmthin"},
}
enrichHostStorageTargets(targets)
wantOrder := []string{"felhom-usb", "local-lvm", "local", "felhom-pbs"}
for i, want := range wantOrder {
if targets[i].Name != want {
t.Fatalf("position %d = %q, want %q (full order: %v)", i, targets[i].Name, want, names(targets))
}
}
// Friendly labels attached; raw ids untouched.
for _, tgt := range targets {
if tgt.Label == "" || tgt.Purpose == "" {
t.Errorf("%s (%s): missing label/purpose (label=%q purpose=%q)", tgt.Name, tgt.Type, tgt.Label, tgt.Purpose)
}
}
if targets[0].Label != "Külső adattároló (USB)" {
t.Errorf("usb label = %q", targets[0].Label)
}
if targets[1].Label != "Belső SSD rendszer és alkalmazások" {
t.Errorf("lvmthin label = %q", targets[1].Label)
}
}
// A second run with the same input must yield the same order (determinism / idempotence).
func TestEnrichHostStorageTargets_Stable(t *testing.T) {
mk := func() []agentapi.StorageTarget {
return []agentapi.StorageTarget{
{Name: "b-usb", Type: "usb"},
{Name: "a-usb", Type: "usb"},
{Name: "local-lvm", Type: "lvmthin"},
}
}
a, b := mk(), mk()
enrichHostStorageTargets(a)
enrichHostStorageTargets(b)
for i := range a {
if a[i].Name != b[i].Name {
t.Fatalf("non-deterministic at %d: %q vs %q", i, a[i].Name, b[i].Name)
}
}
// Within the same tier, alphabetical by id.
if a[0].Name != "a-usb" || a[1].Name != "b-usb" {
t.Errorf("within-tier order = %v, want a-usb,b-usb first", names(a))
}
}
// An unrecognised type falls back to the raw id and an empty purpose.
func TestEnrichHostStorageTargets_UnknownType(t *testing.T) {
targets := []agentapi.StorageTarget{{Name: "weird-store", Type: "zfspool"}}
enrichHostStorageTargets(targets)
if targets[0].Label != "weird-store" || targets[0].Purpose != "" {
t.Errorf("unknown type: label=%q purpose=%q, want raw id + empty", targets[0].Label, targets[0].Purpose)
}
}
func names(ts []agentapi.StorageTarget) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Name
}
return out
}
+4 -79
View File
@@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
@@ -32,21 +31,13 @@ var protectedStackSubdomains = map[string]string{
type StorageBarInfo struct {
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
Path string // e.g., "/mnt/hdd_1"
Purpose string // Hungarian explanation of what this drive holds (monitoring page)
TotalGB float64
UsedGB float64
Percent float64
Disconnected bool
}
// storageBarPurpose is the Hungarian description for the registered user-data drives shown in the
// monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's
// system/PBS storage is not in the controller's storage-path registry), matching the user-data
// purpose text on the storage-management page (Phase 4C).
const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak."
// buildStorageBars returns usage bars for all registered storage paths, in a stable order
// (by path) with a purpose description.
// buildStorageBars returns usage bars for all registered storage paths.
func (s *Server) buildStorageBars() []StorageBarInfo {
var bars []StorageBarInfo
for _, sp := range s.settings.GetStoragePaths() {
@@ -58,7 +49,6 @@ func (s *Server) buildStorageBars() []StorageBarInfo {
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
Disconnected: true,
})
continue
@@ -70,14 +60,11 @@ func (s *Server) buildStorageBars() []StorageBarInfo {
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
TotalGB: di.TotalGB,
UsedGB: di.UsedGB,
Percent: di.UsedPercent,
})
}
// Deterministic order regardless of registry insertion order.
sort.Slice(bars, func(i, j int) bool { return bars[i].Path < bars[j].Path })
return bars
}
@@ -332,17 +319,6 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
data["StoragePaths"] = deployPaths
// Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the
// customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only
// meaningful for a NEW deploy (an existing app's config save doesn't consume fresh image space).
if !alreadyDeployed {
if hr := system.GetDockerVolumeHeadroom(); hr.OK {
data["DockerBelowReserve"] = hr.BelowReserve
data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
}
}
// Effective subdomain for "Megnyitás" button
if alreadyDeployed && appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
@@ -625,8 +601,6 @@ type AppBackupRow struct {
Tier2DestDisconnected bool
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
Tier2DestInactive bool
// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
Tier2UserDisabled bool
// Warnings accumulated for this app
Warnings []string
@@ -712,51 +686,11 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
row.StatusText = "Adatbázis mentés sikertelen"
}
// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
row.Tier2UserDisabled = cd.UserDisabled
if cd.UserDisabled {
// Customer turned Tier 2 off — show nothing more; the panel button still appears.
} else if cd.LastStatus == "no_target" {
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
row.Tier2Configured = false
row.Tier2StatusBadge = "Nincs 2. meghajtó"
row.Tier2LastError = cd.LastError
} else if cd.Enabled {
row.Tier2Configured = true
row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
row.Tier2Schedule = "Naponta"
row.Tier2LastRun = cd.LastRun
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastError = cd.LastError
row.Tier2SizeHuman = cd.LastSizeHuman
switch cd.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
case "error":
row.Tier2StatusBadge = "Hiba"
case "running":
row.Tier2StatusBadge = "Fut..."
default:
row.Tier2StatusBadge = "—"
}
}
}
rows = append(rows, row)
}
return rows
}
// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
func tier2DestLabel(destPath, systemDataPath string) string {
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
return "belső SSD (csak DB/konfiguráció)"
}
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
}
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
@@ -780,9 +714,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
start := time.Now()
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed on
// an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
err := s.backupMgr.RestoreApp(stackName, snapshotID)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed: %v", err)
if s.isDebug() {
@@ -1398,18 +1330,11 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
return
}
// Build volume mount lines. SCOPE to the drive's `appdata/` subtree only (Phase 4A): the customer
// browses their userdata, but the recovery units + Tier 2 copies under `backups/` are NOT mounted
// into FileBrowser at all — so the thing that restores them can't be browsed or (even read-only)
// surfaced. mkdir the appdata dir first so the bind source exists with sane ownership.
// Build volume mount lines
var storageMounts []string
for _, sp := range paths {
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"
appdataSrc := filepath.Join(sp.Path, "appdata")
if err := os.MkdirAll(appdataSrc, 0755); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure appdata dir %s: %v", appdataSrc, err)
}
line := fmt.Sprintf(" - %s:/srv/%s", appdataSrc, mountName)
line := fmt.Sprintf(" - %s:/srv/%s", sp.Path, mountName)
storageMounts = append(storageMounts, line)
}
-6
View File
@@ -263,12 +263,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/deploy")
s.deployHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
s.tier2ConfigPageHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
s.tier2ConfigSaveHandler(w, r, name)
case path == "/import":
s.importPageHandler(w, r)
case path == "/static/style.css":
@@ -276,13 +276,11 @@ func TestSortDisksForView(t *testing.T) {
}
}
// P4 (4B): a drive's cross-drive backup copies (backups/secondary/<app>) are listed so the wipe
// confirmation can warn they'd be destroyed. Shared repo / infra dirs and files are skipped.
// Layout is Model-A in-guest: the drive mount IS the felhom-data namespace root (no felhom-data
// subdir), matching NamespaceRoot(where, true) and where Tier 2 (Phase 3) writes its copies.
// P4 (4B): a drive's cross-drive backup copies (felhom-data/backups/secondary/<app>) are listed so the
// wipe confirmation can warn they'd be destroyed. Shared repo / infra dirs and files are skipped.
func TestBackupCopiesOnPath(t *testing.T) {
root := t.TempDir()
sec := filepath.Join(root, "backups", "secondary")
sec := filepath.Join(root, "felhom-data", "backups", "secondary")
for _, d := range []string{"immich", "nextcloud", "restic", "_infra"} {
if err := os.MkdirAll(filepath.Join(sec, d), 0o755); err != nil {
t.Fatal(err)
+6 -16
View File
@@ -317,12 +317,7 @@
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
<div class="backup-layer-row">
<span class="tier-label">2. mentés</span>
{{if .Tier2UserDisabled}}
<span class="layer-unconfigured">2. mentés kikapcsolva</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if and .Tier2Configured .Tier2DestDisconnected}}
{{if and .Tier2Configured .Tier2DestDisconnected}}
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="badge badge-warn" style="font-size:.7rem">Cél meghajtó leválasztva</span>
@@ -331,7 +326,7 @@
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if and .Tier2Configured .Tier2DestInactive}}
<span class="layer-method" style="opacity:.6">rsync</span>
@@ -342,7 +337,7 @@
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if .Tier2Configured}}
<span class="layer-method">rsync</span>
@@ -359,17 +354,12 @@
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else}}
<span class="layer-auto-ok">✓ 1. mentés auto</span>
<span class="layer-unconfigured">⚠ Nincs 2. (off-drive) másolat</span>
{{if .Tier2LastError}}
<span class="layer-reason" style="opacity:.85" title="A 2. mentés automatikus — külön beállítás nem kell">{{.Tier2LastError}}</span>
{{end}}
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
<span class="layer-unconfigured">⚠ Nincs 2. másolat</span>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
{{end}}
</div>
<!-- Tier 3: Remote backup (future) -->
+1 -12
View File
@@ -427,13 +427,6 @@
{{end}}
<form id="deploy-form" class="deploy-form">
{{if .DockerBelowReserve}}
<div class="alert alert-warning" style="margin-bottom:1rem">
⚠ Nincs elég szabad tárhely a telepítéshez. Jelenleg {{.DockerFreeHuman}} szabad, és a rendszer
{{.DockerReserveHuman}} tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében.
A telepítés ezért átmenetileg le van tiltva — szabadítson fel helyet, vagy bővítse a tárhelyet.
</div>
{{end}}
{{if .AutoFields}}
<div class="form-section">
<h4>Automatikusan generált értékek</h4>
@@ -545,10 +538,6 @@
<div id="storage-space-warn" class="form-hint" style="color:var(--yellow);display:none">
⚠️ A kiválasztott tárhely majdnem megtelt.
</div>
<div class="form-hint" style="margin-top:.4rem;opacity:.8">
️ A kiválasztott meghajtón az alkalmazás <strong>fájljai</strong> (média, dokumentumok) tárolódnak.
Az <strong>adatbázis a gyors belső SSD-n</strong> fut — és az alkalmazással együtt készül róla biztonsági mentés.
</div>
{{else}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
@@ -575,7 +564,7 @@
{{if not .AlreadyDeployed}}
<div class="deploy-actions">
<button type="submit" class="btn btn-primary btn-lg"{{if .DockerBelowReserve}} disabled title="Nincs elég szabad tárhely"{{else if and .MemoryInfo (index .MemoryInfo "Blocked")}} disabled title="Nincs elég memória"{{end}}>Telepítés indítása</button>
<button type="submit" class="btn btn-primary btn-lg"{{if and .MemoryInfo (index .MemoryInfo "Blocked")}} disabled title="Nincs elég memória"{{end}}>Telepítés indítása</button>
<a href="/stacks" class="btn btn-outline">Mégsem</a>
</div>
{{end}}
@@ -106,7 +106,6 @@
<div class="system-bar">
<div class="system-bar-fill {{usageColor .Percent | printf "system-bar-%s"}}" style="width:{{printf "%.1f" .Percent}}%"></div>
</div>
{{if .Purpose}}<div class="storage-purpose" style="font-size:.72rem;opacity:.65;margin-top:.2rem">{{.Purpose}}</div>{{end}}
</div>
{{end}}
{{end}}
@@ -749,17 +748,12 @@
} else {
var html = '';
targets.forEach(function(t) {
// Friendly label (server-supplied) with the raw PVE storage id shown muted for clarity.
var friendly = escapeHtml(t.label || t.name || '');
var rawId = escapeHtml(t.name || '');
var idHtml = rawId ? ' <span style="color:var(--text-muted);font-size:.72rem">(' + rawId + ')</span>' : '';
var label = friendly + idHtml;
var purposeHtml = t.purpose ? '<div class="storage-purpose" style="font-size:.72rem;opacity:.65;margin-top:.2rem">' + escapeHtml(t.purpose) + '</div>' : '';
var label = escapeHtml(t.name || '') + (t.type ? ' (' + escapeHtml(t.type) + ')' : '');
if (t.state && t.state !== 'attached') {
html += '<div class="storage-item storage-disconnected">' +
'<div class="storage-header"><span class="storage-label">' + label + '</span>' +
'<span class="storage-value badge-error" style="font-size:.75rem">Nem elérhető</span></div>' +
'<div class="system-bar"><div class="system-bar-disconnected"></div></div>' + purposeHtml + '</div>';
'<div class="system-bar"><div class="system-bar-disconnected"></div></div></div>';
return;
}
var pct = (t.used_fraction != null ? t.used_fraction * 100 : 0);
@@ -779,7 +773,7 @@
'<span class="storage-value">' + fmtBytesGB(t.used_bytes) + ' / ' + fmtBytesGB(t.total_bytes) +
' (' + Math.round(pct) + '%)</span></div>' +
'<div class="system-bar"><div class="system-bar-fill ' + usageColorClass(pct) +
'" style="width:' + Math.min(100, pct).toFixed(1) + '%"></div></div>' + purposeHtml + '</div>';
'" style="width:' + Math.min(100, pct).toFixed(1) + '%"></div></div></div>';
});
bars.innerHTML = html;
}
@@ -1,93 +0,0 @@
{{define "tier2_config"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>2. mentés beállítása — {{.DisplayName}}</h2>
<a href="/backups" class="btn btn-outline btn-sm">← Vissza a mentésekhez</a>
</div>
{{if .Flash}}<div class="monitoring-banner monitoring-banner-green">{{.Flash}}</div>{{end}}
{{if .FlashError}}<div class="monitoring-banner monitoring-banner-red">{{.FlashError}}</div>{{end}}
<div class="monitor-card">
{{with .Tier2}}
<p style="color:var(--text-muted);font-size:.9rem;margin-top:0">
A 2. mentés egy <strong>másik fizikai meghajtóra</strong> készít másolatot az alkalmazás
helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
</p>
{{if not .IsHDDApp}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek
<strong>már szerepelnek a teljes rendszermentésben (PBS)</strong>. A 2. (off-drive) másolat
kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az
alkalmazáshoz nincs külön teendő.
</div>
{{else}}
<h3 style="margin-top:1.5rem">Jelenlegi állapot</h3>
<div class="sysinfo-grid">
<div class="sysinfo-row">
<span class="sysinfo-label">2. mentés</span>
<span class="sysinfo-value">{{if .Disabled}}Kikapcsolva{{else}}Bekapcsolva{{end}}</span>
</div>
{{if .NoTarget}}
<div class="sysinfo-row">
<span class="sysinfo-label">Cél</span>
<span class="sysinfo-value text-error">Nincs elérhető off-drive cél</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Megjegyzés</span>
<span class="sysinfo-value" style="color:var(--text-muted)">{{.NoTargetReason}}</span>
</div>
{{else}}
<div class="sysinfo-row">
<span class="sysinfo-label">Cél meghajtó</span>
<span class="sysinfo-value">{{.EffectiveLabel}}{{if .EffectiveIsSSD}} — csak DB/konfiguráció{{end}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Kiválasztás módja</span>
<span class="sysinfo-value" style="color:var(--text-muted)">{{if .Preferred}}kézi választás{{else}}automatikus{{end}} — {{.EffectiveDesc}}</span>
</div>
{{end}}
</div>
{{if .EffectiveIsSSD}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
Jelenleg csak a belső SSD érhető el 2. célként, ezért csak az adatbázis és a konfiguráció
másolódik. A belső rendszerlemez kicsi, ezért a nagy fájlok off-drive mentéséhez egy
<strong>2. adatmeghajtó</strong> szükséges (hogy a rendszerlemez ne teljen meg).
</div>
{{end}}
<form method="POST" action="/stacks/{{$.StackName}}/backup" style="margin-top:1.5rem">
{{$.CSRFField}}
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="enabled" value="on" {{if not .Disabled}}checked{{end}}>
2. mentés bekapcsolva
</label>
</div>
<div class="form-group">
<label for="target">Cél meghajtó</label>
<select id="target" name="target" class="form-control">
<option value="">Automatikus{{if not .NoTarget}} (jelenleg: {{.EffectiveLabel}}){{end}}</option>
{{range .Alternatives}}
<option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{if not .Alternatives}}
<div style="font-size:.8rem;color:var(--text-muted);margin-top:.4rem">
Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2.
adatmeghajtó hozzáadásával a teljes adat is off-drive menthető.
</div>
{{end}}
</div>
<button type="submit" class="btn btn-primary">Mentés</button>
</form>
{{end}}
{{end}}
</div>
{{template "layout_end" .}}
{{end}}
@@ -1,110 +0,0 @@
package web
import (
"net/http"
"net/url"
)
// Per-app Tier-2 (off-drive copy) config panel — item 4.
//
// The "2. mentés" row on the backup page used to link its "Beállítás" button at the app's deploy
// page, which has no backup-location setting (a dead end). This is the real surface: it shows the
// current/auto off-drive target + last-run status, and lets the customer pin a different registered
// drive or turn Tier 2 off. It is ALWAYS shown — even when only the internal SSD qualifies, or the
// app's data lives on the rootfs (already in PBS) — with honest context rather than a hidden control.
//
// Routes (wired in server.go, behind RequireAuth + CsrfProtect):
// GET /stacks/{name}/backup → tier2ConfigPageHandler
// POST /stacks/{name}/backup → tier2ConfigSaveHandler
func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request, name string) {
stack, ok := s.stackMgr.GetStack(name)
if !ok {
http.NotFound(w, r)
return
}
if s.backupMgr == nil {
http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable)
return
}
info := s.backupMgr.Tier2Info(name)
data := s.baseData("backups", "2. mentés beállítása — "+stack.Meta.DisplayName)
data["StackName"] = name
data["DisplayName"] = stack.Meta.DisplayName
data["Tier2"] = info
if flash := r.URL.Query().Get("flash"); flash != "" {
data["Flash"] = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
s.executeTemplate(w, r, "tier2_config", data)
}
func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request, name string) {
if _, ok := s.stackMgr.GetStack(name); !ok {
http.NotFound(w, r)
return
}
if s.backupMgr == nil {
http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable)
return
}
_ = r.ParseForm()
// "enabled" checkbox: present → Tier 2 on; absent → off (UserDisabled = !enabled).
enabled := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
target := r.FormValue("target") // "" = automatic; otherwise a registered drive path
// Validate the chosen target against the eligible alternatives (defence-in-depth: the runner
// also re-validates off-disk at run time, but reject a bogus path here for a clean message).
if target != "" {
valid := false
for _, opt := range s.backupMgr.Tier2Info(name).Alternatives {
if opt.Path == target {
valid = true
break
}
}
if !valid {
s.redirectTier2(w, r, name, "", "A választott cél meghajtó nem érvényes.")
return
}
}
if err := s.settings.SetTier2Preference(name, !enabled, target); err != nil {
s.logger.Printf("[ERROR] [web] save Tier 2 preference for %s: %v", name, err)
s.redirectTier2(w, r, name, "", "A beállítás mentése nem sikerült.")
return
}
s.logger.Printf("[INFO] [web] Tier 2 preference saved for %s: enabled=%v target=%q", name, enabled, target)
// Apply immediately when enabled for an HDD app so the customer sees the result on return.
if enabled && s.backupMgr.Tier2Info(name).IsHDDApp {
go func() {
if err := s.backupMgr.RunTier2(name); err != nil {
s.logger.Printf("[WARN] [web] immediate Tier 2 run for %s failed: %v", name, err)
}
}()
}
s.redirectTier2(w, r, name, "A 2. mentés beállítása elmentve.", "")
}
// redirectTier2 sends the customer back to the panel with a flash message.
func (s *Server) redirectTier2(w http.ResponseWriter, r *http.Request, name, flash, flashErr string) {
dest := "/stacks/" + url.PathEscape(name) + "/backup"
q := url.Values{}
if flash != "" {
q.Set("flash", flash)
}
if flashErr != "" {
q.Set("flash_error", flashErr)
}
if e := q.Encode(); e != "" {
dest += "?" + e
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}