Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f35b382c6d | |||
| 2b46619e15 |
+34
-388
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user