Compare commits

..

4 Commits

Author SHA1 Message Date
admin 3b70a9e9ab docs(shares): R-7b v0.145.0 — CHANGELOG, CONTEXT, REUSE, README; caveats cleared
The samba_classify.go SCOPE NOTE and the README KNOWN GAP both described a gap that
R-7b closes; both now describe the sibling-shares-source execution instead.
2026-07-18 13:06:41 +02:00
admin 900c870212 feat(shares): R-7b Parts 4-6 — shares restore, samba liveness, UI truth-up
Part 4 — restore: RestoreSharesScratch + PlaceSharesRestore as SIBLINGS of the
per-app scratch/place pair. Files merged missing-only (never overwriting), each
destination PREFIX-ASSERTED against registered LIVE storage roots; definitions
merged with existing-wins; ReconcileSamba via a seam (backup must not import
stacks); credential restored best-effort into the samba named volume.
New routes POST /backup/shares/{restore,place} + a restore-page entry that renders
'Megosztasok', never the raw reserved key.
Also adds scratchJoin: reconstructing an absolute captured path under a scratch
must strip the volume name rather than rely on filepath.Join.

Part 5 — liveness: EffectiveProtected gains a settings-backed dynamic extra so the
samba CONTAINER (not the stack name — they differ) is watched exactly while sharing
is on. FINDING: the issue -> health 'fail' -> existing health_critical event ->
alert -> Hungarian degradation e-mail path needs NO further change, and introduces
no new event type, so the allowlist gotcha does not apply.

Part 6 — UI: per-tier backup status lines on the Megosztas page (amber only on
deviation). Verified the two warning-prose sites (offbox_capture/tier2_capture)
only ever receive per-app stack names, so no mapping is needed there.

RED-PROOFS RUN AND REVERTED (both fired):
  4. prefix-assert removed        -> place-guard traversal test FAILS
  5. dynamic samba extra removed  -> Scenario E enabled-case FAILS
2026-07-18 13:02:41 +02:00
admin 85b76e0fc3 feat(shares): R-7b Part 3 — offsite shares leg (Model B') + B' isolation proof
ONE additional restic call tagged [felhom-offbox, _shares] carrying the payload
staging dir + every mandatory share folder. Hooked into runOffboxInternal AFTER the
per-app loop and BEFORE retention, so forget --group-by host,tags covers the _shares
group with no flag change. Reuses resticStep, the caller's repo-ensure and
single-flight, and the SAME enlargement-gate arithmetic.

- quota gate degrades the push to MANIFEST-ONLY, never to nothing
- EnlargedBlocked keeps the RAW _shares key (templates index by it); the display
  mapping applies only at the notification + Hungarian-prose boundaries
- OffboxTarget gains SharesLastRun/Status/Count for per-tier page truth
- zero-toggle notice suppressed when the shares leg provided coverage
- reserved-name defense: an app keyed _shares is excluded from the run loudly

RED-PROOFS RUN AND REVERTED (all fired):
  1. shares leg appends into the app's argv -> isolation test FAILS
  2. mandatory->offsite mapping inverted    -> Scenario A + B FAIL
  3. manifest-only degradation dropped      -> Scenario C FAILS
2026-07-18 12:51:46 +02:00
admin c81df55dcb feat(shares): R-7b Parts 1-2 — shares payload builder + tier-2 shares job (Model B')
Sibling shares source for the local cross-drive tier. Reuses the tier2Mirror seam,
selectTier2TargetFrom (narrow source-drive seam extracted from selectTier2Target),
tier2ReconcileRoots (pure extraction), tier2SafeRemove, the marker-LAST discipline
and the recordTier2* helpers. Per-app paths are untouched.

- shares_payload.go: deterministic _shares-manifest.json + best-effort passdb capture
- tier2_shares.go: per-source-drive legs -> cross-drive target, payload, marker LAST
- infra.SambaContainerName/SambaPassdbVolume/Mount: single source of truth for the
  container identity (renderer, stacks execs, backup execs, monitor all read it)
- RESERVED-NAME finding: ValidateSMBShareName did NOT exclude a leading underscore,
  so "_shares" was an accepted share name. Now refused; RunAllTier2 additionally
  skips a "_shares" stack loudly as defense in depth.
- fix: shareSourceDrive returned a slash-normalised path, which made the target
  selector's source-drive equality check miss (a group could target its own drive)
2026-07-18 12:45:57 +02:00
30 changed files with 2540 additions and 46 deletions
+72
View File
@@ -1,5 +1,77 @@
## Changelog ## Changelog
### v0.145.0 — R-7b: share data enters the live backup runs (Model B) + samba liveness (2026-07-18)
**The „Felhőmentés" toggle on the Megosztás page is now true.** Before this release a customer could
switch a share to „Felhőmentés: bekapcsolva" and the page would render exactly that while the files
dropped on it were in **no backup at all**`backup.RunTier2` short-circuits on `os.Stat(unitDir)`
before the classification seam, and the offsite runner enumerates `GetOffboxApps()`. A share-only
infra stack has neither a recovery unit nor an offbox toggle, so it fell through both engines. R-7b
closes that with a **sibling shares source** in each tier.
**Model B (Viktor's ruling, 2026-07-18) and its invariant.** Share data enters the runs through
NEW, ADDITIVE job/leg code that reuses the proven primitives — the tier-2 mirror seam, the restic
wrappers, the soft-quota/enlargement gate, the status recorders — while leaving **every per-app engine
path byte-identical**. Not Model A (a synthetic recovery unit breaks on multi-drive shares and wraps
1 KB of JSON in dump machinery) and not engine-loop surgery. The invariant is enforced by test, in
both tiers, with red-proofs.
- **Payload (`internal/backup/shares_payload.go`, new):** a staging dir holding
`_shares-manifest.json` (the share definitions, sorted → **byte-deterministic** for an unchanged
registry, so a no-op run gives the mirror nothing to rewrite) plus a **best-effort** `passdb.tar`
captured from the samba container. A restore therefore returns the files, the share configuration
AND the SMB password hash — not just bytes on a disk. The credential copy is SECRET-BEARING: 0600,
never logged at INFO, never in a report or a committed file. A down container degrades to
manifest-only and KEEPS any previously captured copy (a stale credential beats none for DR).
- **Tier-2 shares job (`internal/backup/tier2_shares.go`, new):** runs after the per-stack loop in the
same orchestrator run. Shares are grouped **by source drive** — a household's shares can span disks
and each group needs its own cross-drive target — into
`backups/secondary/_shares/<sourceDriveKey>/<share>` with the payload at `_payload/` and the layout
marker written **LAST**. Reuses `selectTier2TargetFrom` (a narrow source-drive seam extracted from
`selectTier2Target`; the headroom math is untouched), `tier2ReconcileRoots` (a pure extraction),
`tier2SafeRemove` and the `recordTier2*` helpers.
- **Offsite shares leg (`internal/backup/offbox_shares.go`, new):** ONE additional
`restic backup --tag felhom-offbox --tag _shares` carrying the manifest staging dir plus every
MANDATORY share folder, hooked in AFTER the per-app loop and BEFORE retention — so
`forget --group-by host,tags` covers the `_shares` group with **no flag change**. Same enlargement
arithmetic as the per-app gate. **Degradation contract:** a quota-blocked push falls back to the
MANIFEST ONLY, never to nothing — definitions protection must not regress because the files stopped
fitting.
- **Restore „Megosztások" (`internal/backup/shares_restore.go`, new):** siblings of the per-app
scratch/place pair. Files are merged **missing-only** (never overwriting) and every destination is
**prefix-asserted** against registered LIVE storage roots — a snapshot is untrusted layout input, so
a path that no longer sits under a live root is refused rather than created. Definitions merge with
**existing-wins** (a restore must never silently flip a live share's settings; skipped ones are
named in the flash). Then `ReconcileSamba` re-renders smb.conf, and the credential goes back into
the named volume best-effort. Routes `POST /backup/shares/{restore,place}`.
- **Samba liveness (the fold-in):** `monitor.EffectiveProtected` gains a settings-backed dynamic
extra, so a dead sharing service raises the same protected-container issue → alert → Hungarian
degradation e-mail as a dead traefik — but only while sharing is ON. It watches the **container**
name (`infra.SambaContainerName`), which is deliberately NOT the stack name. **Finding: the
alert/e-mail pipeline needed no further change** and no new event type is introduced, so the
`allowedEventTypes` gotcha does not apply.
- **UI truth-up:** the Megosztás page states per-tier status (2. mentés / távoli mentés, amber only on
deviation) and links to the restore page. The reserved `_shares` key is mapped to „Megosztások" at
the notification and Hungarian-prose boundaries ONLY — the persisted `EnlargedBlocked` set, the
restic tag and the dest path keep the raw key, because templates index by it.
- **RESERVED-NAME FINDING (the task's assumption was false):** `settings.nbNameRe` begins with
`[A-Za-z0-9_]`, so „_shares" **was an accepted share name** — the underscore namespace was not in
fact reserved. `ValidateSMBShareName` now refuses a leading underscore (on ADD only, so existing
shares are never retroactively invalidated), and `RunAllTier2`/`RunOffboxBackup` additionally skip a
`_shares` STACK loudly as defense in depth.
- **`infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount`** become the single source of
truth for the samba container identity — the compose renderer interpolates them, and stacks, backup
and monitor all read them instead of repeating string literals.
- **Bug found by test:** `shareSourceDrive` returned a slash-normalised path, which made the target
selector's source-drive equality check miss — a share group could have targeted its own source
drive (a same-disk copy pretending to be tier 2). Fixed; POSIX-only in effect, but real.
- **Tests:** 24 new/extended cases in `internal/backup` + 2 in `internal/monitor`. **Six red-proofs
run and reverted, all fired:** (1) shares leg appending into the app's argv → B isolation FAILS;
(2) mandatory→offsite mapping inverted → Scenarios A+B FAIL; (3) manifest-only degradation dropped →
Scenario C FAILS; (4) prefix-assert removed → place-guard traversal FAILS; (5) dynamic samba extra
removed → Scenario E enabled-case FAILS; (6) shares destBase dropping the reserved segment → tier-2
isolation FAILS.
### v0.144.0 — „Megosztás": LAN SMB file sharing (R-7 slice 1) (2026-07-18) ### v0.144.0 — „Megosztás": LAN SMB file sharing (R-7 slice 1) (2026-07-18)
The customer turns on network sharing, sets ONE household SMB password, and exports folders. The box The customer turns on network sharing, sets ONE household SMB password, and exports folders. The box
+32 -1
View File
@@ -7,7 +7,38 @@
> >
> Ask Claude Code: "Please update CONTEXT.md with what we did today" > Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-07-18 (v0.144.0 — „Megosztás": LAN SMB file sharing, R-7 slice 1) Last updated: 2026-07-18 (v0.145.0 — R-7b: share data enters the live backup runs, Model B)
> **2026-07-18 — v0.145.0: R-7b — the „Felhőmentés" toggle is now TRUE (Model B), + samba liveness.**
> Until v0.144.0 a share could be marked „Felhőmentés: bekapcsolva" while its files were in NO backup:
> both engines are recovery-unit shaped (`RunTier2` short-circuits on `os.Stat(unitDir)`; the offsite
> runner enumerates `GetOffboxApps()`) and a share-only infra stack has neither. **Viktor's ruling was
> Model B: a SIBLING shares source** — additive job/leg code reusing the proven primitives (tier-2
> mirror seam, restic wrappers, quota gate, status recorders) while every per-app engine path stays
> **byte-identical**. That invariant is enforced by test in both tiers, red-proofed.
>
> Tier 2 → `RunSharesTier2` (legs grouped by SOURCE drive → `backups/secondary/_shares/<driveKey>/…`,
> payload at `_payload/`, marker LAST). Tier 3 → `runOffboxSharesLeg`, ONE extra restic call tagged
> `_shares`, placed after the app loop and BEFORE retention so `--group-by host,tags` covers it for
> free. Restore → „Megosztások" on /backups/restore: scratch, then a missing-only merge whose every
> destination is PREFIX-ASSERTED against live storage roots; definitions merge existing-wins; then
> `ReconcileSamba`; then the credential, best-effort.
>
> The **payload** is the point: a byte-deterministic `_shares-manifest.json` + a best-effort
> secret-bearing `passdb.tar`, so DR returns files + configuration + password, not loose bytes. A
> quota-blocked offsite push degrades to the MANIFEST ONLY — never to nothing.
>
> **Findings:** (a) the reserved-name assumption was FALSE — `nbNameRe` accepted „_shares" as a share
> name; `ValidateSMBShareName` now refuses a leading underscore and both run loops skip a `_shares`
> stack loudly. (b) The samba-liveness fold-in needed NO alert/e-mail pipeline change and adds no new
> event type (so the `allowedEventTypes` gotcha does not apply) — `EffectiveProtected` just gains a
> settings-backed dynamic extra watching the CONTAINER name (`felhom-samba` ≠ stack name `samba`).
> (c) A real bug surfaced in review-by-test: `shareSourceDrive` returned a slash-normalised path,
> making the target selector's equality check miss so a share group could target its own source drive.
>
> **OPEN:** Viktor raises the managed-update floor to **v0.145.0** (supersedes the 0.144 note) so the
> N100 rehearsal's day-0 box converges onto the honest version. Pre-existing, untouched:
> `docker_run_volume_path_gate` fails on `internal/appexport/estimate.go` (predates this work).
> **2026-07-18 — v0.144.0: „Megosztás" LAN SMB sharing (R-7 slice 1), LIVE on demo.** SMB ships as an > **2026-07-18 — v0.144.0: „Megosztás" LAN SMB sharing (R-7 slice 1), LIVE on demo.** SMB ships as an
> **embedded controller feature** — the FOURTH protected infra stack (traefik/cloudflared/filebrowser/ > **embedded controller feature** — the FOURTH protected infra stack (traefik/cloudflared/filebrowser/
+6
View File
@@ -56,6 +56,12 @@
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand | | `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops | | `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup | | `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
| `backup.SharesPseudoStack` / `DisplayStackName` | controller/internal/backup/shares_payload.go | `"_shares"` / `(key) string` | THE reserved key for the shares source (restic tag, `backups/secondary/_shares`, CrossDriveBackup record) + its display mapping | NEVER let the raw key reach a Hungarian surface — map at the notification/prose boundary ONLY; the persisted `EnlargedBlocked` set and the templates index by the RAW key |
| `Manager.buildSharesPayload` / `classifiedShares` | controller/internal/backup/shares_payload.go | `() (dir, passdbOK, error)` / `() []classifiedShare` | the definitions+credential payload and the availability-filtered share set both tiers read | payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. `classifiedShares` is the single place a dead mount is dropped, so both jobs agree |
| `Manager.selectTier2TargetFrom` | controller/internal/backup/tier2.go | `(stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error)` | tier-2 target choice with the source drive supplied EXPLICITLY | the seam the shares job reuses — NEVER fork the headroom math; `selectTier2Target` is now a thin wrapper over it |
| `Manager.tier2ReconcileRoots` | controller/internal/backup/tier2.go | `(destBase, roots, legRels)` | staleness pruning with explicit dest roots | pure extraction from `tier2Reconcile` (which now calls it with `hdd`/`userdata`); reuse it rather than writing a second pruner |
| `Manager.liveShareRootOK` / `scratchJoin` | controller/internal/backup/shares_restore.go | `(dst) bool` / `(scratch, abs) string` | THE place guard for shares restore + scratch path reconstruction | a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse `..` and the drive root itself. `scratchJoin` strips the volume name — plain `filepath.Join` splices a drive letter mid-path |
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 | | `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot | | `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
+26 -4
View File
@@ -2374,10 +2374,32 @@ main mux behind `RequireAuth`+`CsrfProtect` (the `/api/` subtree is routed there
no `.felhom.yml` and its binds are absolute share paths). Per-share `Felhőmentés` ON → `mandatory` no `.felhom.yml` and its binds are absolute share paths). Per-share `Felhőmentés` ON → `mandatory`
(offsite + tier-2); OFF → `optional` (tier-2 only). smb.conf/passdb are never classified. (offsite + tier-2); OFF → `optional` (tier-2 only). smb.conf/passdb are never classified.
> **KNOWN GAP (design fork, deliberately not improvised).** The seam is correct, but share data is not #### Share backup EXECUTION — the sibling shares source (R-7b, v0.145.0)
> yet in a live tier-2/offsite RUN: `backup.RunTier2` short-circuits on a missing recovery unit before
> it reaches `GetStackClassifiedBinds`, and the offsite runner enumerates `settings.GetOffboxApps()`. The earlier KNOWN GAP is **closed**: share data is in both live tiers. It did not get there through
> Both engines are recovery-unit shaped, which a share-only infra stack has not. See root `REPORT.md`. `GetStackClassifiedBinds`the engines are recovery-unit shaped and Model B deliberately left every
per-app path byte-identical. Instead `internal/backup` runs a **sibling shares source** off the same
registry, applying the same per-share class rule:
| Tier | Entry point | Shape |
|---|---|---|
| 2 (cross-drive) | `RunSharesTier2` — after the per-stack loop in `RunAllTier2` | legs grouped by SOURCE DRIVE → `backups/secondary/_shares/<driveKey>/<share>` + `_payload/`, layout marker LAST |
| 3 (offsite) | `runOffboxSharesLeg` — after the per-app loop, before retention | ONE `restic backup --tag felhom-offbox --tag _shares` = manifest staging dir + every MANDATORY share |
| restore | `RestoreSharesScratch``PlaceSharesRestore` | scratch first, then a missing-only merge, each destination prefix-asserted against LIVE storage roots |
The **payload** (`shares_payload.go`) is what makes a restore give back a working feature rather than
loose files: a byte-deterministic `_shares-manifest.json` of the definitions plus a best-effort,
secret-bearing `passdb.tar`. Definitions protection is the floor — a quota-blocked offsite push
degrades to the manifest alone, never to nothing.
`_shares` is a **reserved key** (restic tag, dest root, status record). `ValidateSMBShareName` refuses
a leading underscore, and both run loops skip a `_shares` stack loudly. It never reaches a customer
surface: `backup.DisplayStackName` maps it to „Megosztások" at the notification and prose boundaries,
while the persisted set, the tag and the paths keep the raw key.
**Liveness:** `monitor.EffectiveProtected` adds `infra.SambaContainerName` exactly while sharing is
on, so a dead sharing service raises the standard protected-container issue → alert → degradation
e-mail. Note the container name is NOT the stack name (`samba` vs `felhom-samba`).
--- ---
+10 -6
View File
@@ -261,6 +261,10 @@ func main() {
// O4: restore-from-unit generates a replacement for an unrecoverable RESETTABLE secret // O4: restore-from-unit generates a replacement for an unrecoverable RESETTABLE secret
// (data-keys stay fail-closed) so the app redeploys with a fresh credential, not a blank one. // (data-keys stay fail-closed) so the app redeploys with a fresh credential, not a blank one.
backupMgr.SetSecretGenerator(stackMgr.GenerateSecretForField) backupMgr.SetSecretGenerator(stackMgr.GenerateSecretForField)
// R-7b: after a shares restore re-adds definitions to the registry, smb.conf must be
// re-rendered or the restored shares exist on paper but are not exported. A seam rather than a
// direct call — the backup package must not depend on the stacks package.
backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba)
} }
// SLICE 2: the offsite apply-bridge — on startup (async, non-blocking) reconcile the hub-served offsite // SLICE 2: the offsite apply-bridge — on startup (async, non-blocking) reconcile the hub-served offsite
@@ -385,7 +389,7 @@ func main() {
healthInterval = 5 * time.Minute healthInterval = 5 * time.Minute
} }
sched.Every("system-health", healthInterval, func(ctx context.Context) error { sched.Every("system-health", healthInterval, func(ctx context.Context) error {
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger) healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), sett.GetSMBSettings(), logger)
// Self-heal the base stack: call unconditionally every tick. EnsureBaseStack is single-flight // Self-heal the base stack: call unconditionally every tick. EnsureBaseStack is single-flight
// + idempotent (skips running stacks ⇒ a cheap 3× docker-inspect no-op when healthy), so there // + idempotent (skips running stacks ⇒ a cheap 3× docker-inspect no-op when healthy), so there
// is no need to couple to the health-report issue strings. Runs in a goroutine — never blocks // is no need to couple to the health-report issue strings. Runs in a goroutine — never blocks
@@ -629,7 +633,7 @@ func main() {
pushInterval = 15 * time.Minute pushInterval = 15 * time.Minute
} }
sched.Every("hub-report", pushInterval, func(ctx context.Context) error { sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
if err := hubPusher.Push(r); err != nil { if err := hubPusher.Push(r); err != nil {
return err return err
@@ -735,7 +739,7 @@ func main() {
// Hub report // Hub report
if hubPusher != nil { if hubPusher != nil {
if cfg.Hub.Enabled { if cfg.Hub.Enabled {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
var pushErr error var pushErr error
for attempt := 1; attempt <= 3; attempt++ { for attempt := 1; attempt <= 3; attempt++ {
@@ -803,7 +807,7 @@ func main() {
// Initial alert refresh (so alerts appear immediately, not after first 5min health check) // Initial alert refresh (so alerts appear immediately, not after first 5min health check)
go func() { go func() {
report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger) report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), sett.GetSMBSettings(), logger)
alertMgr.Refresh(report, cfg, backupMgr, false, "") alertMgr.Refresh(report, cfg, backupMgr, false, "")
}() }()
@@ -817,7 +821,7 @@ func main() {
var reportTrigger *report.Trigger var reportTrigger *report.Trigger
if hubPusher != nil && cfg.Hub.Enabled { if hubPusher != nil && cfg.Hub.Enabled {
fireReport := func() error { fireReport := func() error {
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
return hubPusher.Push(rep) return hubPusher.Push(rep)
} }
@@ -960,7 +964,7 @@ func main() {
dc := &web.DebugCallbacks{} dc := &web.DebugCallbacks{}
if hubPusher != nil { if hubPusher != nil {
dc.TriggerHubReportPush = func() error { dc.TriggerHubReportPush = func() error {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
return hubPusher.Push(r) return hubPusher.Push(r)
} }
+15
View File
@@ -92,6 +92,21 @@ type Manager struct {
// (`-a --delete`, contents-of-src semantics). // (`-a --delete`, contents-of-src semantics).
tier2Mirror func(src, dst string) error tier2Mirror func(src, dst string) error
// sharesPassdbCapture (R-7b) — the samba passdb capture seam (a `docker exec … tar cf -`),
// overridable so the shares payload builder is unit-testable without docker. Nil → the real
// defaultSharesPassdbCapture. Best-effort by contract: an error yields a manifest-only payload.
sharesPassdbCapture func() ([]byte, error)
// sharesPassdbRestore (R-7b) — the mirror seam for putting a captured passdb archive BACK into the
// samba named volume (`docker exec -i … tar xf -`). Nil → the real defaultSharesPassdbRestore.
sharesPassdbRestore func(tar []byte) error
// sharesReconcile (R-7b), if set, re-renders and applies the samba stack after a shares restore
// re-adds definitions to the registry (wired in main.go to stacks.Manager.ReconcileSamba). It is a
// SEAM rather than a direct call because the backup package must not depend on the stacks package.
// Nil → the registry is updated and a WARN says smb.conf will catch up on the next health tick.
sharesReconcile func() error
// tier2SSDFits (3b) — the SSD-headroom predicate seam, overridable in tests (system.GetDiskUsage is // tier2SSDFits (3b) — the SSD-headroom predicate seam, overridable in tests (system.GetDiskUsage is
// Linux-only → nil on the Windows test host, which would always refuse the SSD branch). Nil → the // Linux-only → nil on the Windows test host, which would always refuse the SSD branch). Nil → the
// real tier2FitsSystemDrive. // real tier2FitsSystemDrive.
+54 -4
View File
@@ -598,6 +598,16 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
defer m.releaseRunning() defer m.releaseRunning()
apps := m.settings.GetOffboxApps() apps := m.settings.GetOffboxApps()
// Reserved-name defense in depth (R-7b): an app keyed `_shares` would collide with the shares
// leg's restic tag and blocked-set entry. Catalog names cannot realistically produce this, but a
// silent collision would corrupt both sources, so it is refused loudly instead.
for i, a := range apps {
if a == SharesPseudoStack {
m.logger.Printf("[ERROR] [offbox] app %q uses the RESERVED shares key — excluded from the run to protect the shares leg", a)
apps = append(apps[:i:i], apps[i+1:]...)
break
}
}
t := m.settings.GetOffboxTarget() t := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t) base, env := m.offboxBaseArgs(t)
// Edge-trigger for the enlarge-blocked notification: capture the PRIOR blocked set so we notify only // Edge-trigger for the enlarge-blocked notification: capture the PRIOR blocked set so we notify only
@@ -673,7 +683,9 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
// Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports // Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports
// its emptiness instead of a bare success — the customer thinks offsite runs, but nothing // its emptiness instead of a bare success — the customer thinks offsite runs, but nothing
// is covered until at least one app is toggled. // is covered until at least one app is toggled.
if len(apps) == 0 { // R-7b: the shares leg counts as coverage — a box whose only cloud content is its shares
// must not be told "nothing is selected".
if len(apps) == 0 && !runResult.sharesBackedUp {
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás") warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
} }
if len(missing) > 0 { if len(missing) > 0 {
@@ -683,9 +695,25 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed). // 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
warns = append(warns, runResult.warns...) warns = append(warns, runResult.warns...)
// 3a: the pre-push enlargement gate blocked some apps' userdata — config+DB still saved. // 3a: the pre-push enlargement gate blocked some apps' userdata — config+DB still saved.
if len(blockedNames) > 0 { // R-7b: the shares source is not an "app" and its degraded floor is the DEFINITIONS, not a
// recovery unit — so it gets its own sentence and is excluded from the app count. The
// persisted EnlargedBlocked set keeps the RAW `_shares` key (it is a lookup key the
// templates index by); only this prose maps it through the display vocabulary.
var blockedApps []string
sharesBlocked := false
for _, n := range blockedNames {
if n == SharesPseudoStack {
sharesBlocked = true
continue
}
blockedApps = append(blockedApps, n)
}
if len(blockedApps) > 0 {
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.", warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
len(blockedNames), strings.Join(blockedNames, ", "))) len(blockedApps), strings.Join(blockedApps, ", ")))
}
if sharesBlocked {
warns = append(warns, sharesBlockedWarning())
} }
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run. // SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
if qw := offboxQuotaWarning(o); qw != "" { if qw := offboxQuotaWarning(o); qw != "" {
@@ -708,7 +736,10 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
usedGB := int(t.RepoSizeBytes / offboxGiB) usedGB := int(t.RepoSizeBytes / offboxGiB)
for _, b := range runResult.blocked { for _, b := range runResult.blocked {
if !priorBlocked[b.stack] { if !priorBlocked[b.stack] {
m.offboxEnlargeBlockedNotify(b.stack, b.estBytes, usedGB, t.QuotaGB) // DISPLAY BOUNDARY (R-7b): the notification is a customer-facing surface (it becomes a
// Hungarian e-mail), so the reserved `_shares` key is mapped here — and ONLY here plus
// the warning prose above. The persisted set and the restic tag stay raw.
m.offboxEnlargeBlockedNotify(DisplayStackName(b.stack), b.estBytes, usedGB, t.QuotaGB)
} }
} }
} }
@@ -813,6 +844,10 @@ type offboxRunResult struct {
missing []string missing []string
blocked []offboxBlocked blocked []offboxBlocked
warns []string warns []string
// sharesBackedUp (R-7b) records that the sibling shares leg produced a snapshot this run. It keeps
// the zero-toggle honesty notice honest: a box with no app toggled but shares in the cloud is NOT
// "nothing is covered".
sharesBackedUp bool
} }
// runOffboxInternal does the repo-ensure + per-app DISCOVER → capture-set → gate → multi-path backup + // runOffboxInternal does the repo-ensure + per-app DISCOVER → capture-set → gate → multi-path backup +
@@ -867,6 +902,21 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
res.backedUp++ res.backedUp++
m.logger.Printf("[INFO] [offbox] backed up %s (%s, %d mandatory path(s))", stack, src, len(extra)) m.logger.Printf("[INFO] [offbox] backed up %s (%s, %d mandatory path(s))", stack, src, len(extra))
} }
// R-7b: the SHARES leg runs AFTER the per-app loop and BEFORE retention, so `forget --group-by
// host,tags` covers the `_shares` group for free. It is placed BEFORE the firstErr return on
// purpose: share protection must not be dropped because some unrelated app failed to push.
sharesRes, sharesErr := m.runOffboxSharesLeg(ctx, base, env, t)
m.recordSharesOffsiteStatus(sharesRes)
res.warns = append(res.warns, sharesRes.warns...)
if sharesRes.blocked {
res.blocked = append(res.blocked, offboxBlocked{stack: SharesPseudoStack, estBytes: sharesRes.estBytes})
}
if sharesRes.ran {
res.sharesBackedUp = true
}
if sharesErr != nil && firstErr == nil {
firstErr = sharesErr
}
if firstErr != nil { if firstErr != nil {
return res, firstErr return res, firstErr
} }
+161
View File
@@ -0,0 +1,161 @@
package backup
import (
"context"
"fmt"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Offsite shares leg — R-7b Part 3, the remote leg of Model B.
//
// It is a SIBLING of the per-app loop in runOffboxInternal, not a modification of it. The B
// invariant — every per-app restic invocation stays byte-identical — is the headline guarantee here
// and is enforced by TestOffboxSharesLegLeavesAppCallsByteIdentical.
//
// Shape: ONE additional `restic backup` call tagged [felhom-offbox, _shares], whose paths are the
// payload staging dir plus every MANDATORY (Felhőmentés-on) share folder. It reuses resticStep (so
// it inherits the C2 crash-lock self-heal), the caller's already-ensured repo, the caller's
// already-taken single-flight, and the SAME enlargement-gate arithmetic the per-app path uses. It
// runs BEFORE retention, so `forget --group-by host,tags` covers the `_shares` group for free with
// no flag change.
//
// Degradation contract: when the quota gate trips, the push degrades to the MANIFEST ONLY — never to
// nothing. Definitions protection must not regress just because the files no longer fit; a customer
// who is over quota should still get their „Megosztás" page back from a DR restore.
// sharesLegResult carries the outcome of the offsite shares leg back to the run.
type sharesLegResult struct {
ran bool // the leg produced a restic call
count int // share folders included (0 = definitions-only push)
blocked bool // the quota gate degraded this push to manifest-only
estBytes int64 // the estimate the gate weighed (for the blocked notification)
warns []string
status string // persisted SharesLastStatus
}
// runOffboxSharesLeg pushes the shares source. Caller holds the running flag and has already ensured
// the repo. Returns the leg result plus a hard error only when the restic call itself failed.
func (m *Manager) runOffboxSharesLeg(ctx context.Context, base, env []string, t *settings.OffboxTarget) (sharesLegResult, error) {
var res sharesLegResult
if !m.sharesEnabled() {
// Sharing off / no shares registered: a clean no-op. NO `_shares` restic group is created —
// an empty group would age through retention forever and imply a protection that isn't there.
return res, nil
}
shares := m.classifiedShares()
var mandatory []classifiedShare
for _, sh := range shares {
if sh.mandatory {
mandatory = append(mandatory, sh)
}
}
if len(shares) > 0 && len(mandatory) == 0 {
// Every share is tier-2-only. The FILES correctly stay off-site-excluded (Scenario B), but the
// definitions still ride offsite: they are ~1 KB and they are what makes a DR restore give the
// customer their share configuration back rather than an empty page.
m.logger.Printf("[INFO] [shares] offsite: no share is marked for the cloud — pushing share definitions only")
}
payloadDir, passdbOK, perr := m.buildSharesPayload()
if perr != nil {
// Without a payload there is nothing to anchor a restore on; push the files anyway rather than
// skipping protection, but say so loudly.
m.logger.Printf("[ERROR] [shares] offsite: payload staging failed — pushing share files without the definition manifest: %v", perr)
res.warns = append(res.warns, "A megosztás-beállítások távoli mentése nem sikerült — a fájlok mentése megtörtént.")
payloadDir = ""
}
if !passdbOK {
res.warns = append(res.warns, "A megosztás jelszava nem került a mentésbe (a megosztás szolgáltatás nem futott) — visszaállítás után újra meg kell adni.")
}
paths := make([]string, 0, len(mandatory)+1)
if payloadDir != "" {
paths = append(paths, payloadDir)
}
sharePaths := make([]string, 0, len(mandatory))
for _, sh := range mandatory {
sharePaths = append(sharePaths, sh.Path)
}
// Pre-push enlargement gate — the SAME arithmetic as the per-app path (offbox.go): last-known repo
// raw-data bytes + this push's estimate crossing the soft quota degrades the push instead of
// failing it. Here the degradation floor is the manifest rather than a recovery unit.
if len(sharePaths) > 0 && t != nil && t.QuotaGB > 0 {
var est int64
for _, p := range sharePaths {
est += m.offboxSize()(p)
}
if t.RepoSizeBytes+est >= int64(t.QuotaGB)*offboxGiB {
m.logger.Printf("[INFO] [shares] offsite: enlargement blocked by quota (est %s + repo %s ≥ %d GB) — definitions-only push continues",
humanizeBytes(est), humanizeBytes(t.RepoSizeBytes), t.QuotaGB)
res.blocked = true
res.estBytes = est
sharePaths = nil
}
}
paths = append(paths, sharePaths...)
if len(paths) == 0 {
m.logger.Printf("[WARN] [shares] offsite: nothing to push (no payload, no eligible share) — skipped")
res.status = "skipped"
return res, nil
}
args := append([]string{"backup", "--tag", "felhom-offbox", "--tag", SharesPseudoStack}, paths...)
bctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
out, berr := m.resticStep(bctx, env, base, "backup:"+SharesPseudoStack, args...)
cancel()
if berr != nil {
m.logger.Printf("[ERROR] [shares] offsite push failed: %v: %s", berr, truncate(out))
res.status = "error"
return res, fmt.Errorf("offbox backup %s: %w", SharesDisplayName, berr)
}
res.ran = true
res.count = len(sharePaths)
res.status = "ok"
if res.blocked {
res.status = "blocked"
}
m.logger.Printf("[INFO] [shares] offsite push OK: %d share folder(s) + definitions", res.count)
return res, nil
}
// recordSharesOffsiteStatus persists the per-tier status the „Megosztás" page renders. Kept separate
// from the app-wide offsite status so a page can state SHARES truth without inferring it.
func (m *Manager) recordSharesOffsiteStatus(res sharesLegResult) {
if m.settings == nil || res.status == "" {
return
}
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.SharesLastRun = time.Now().UTC().Format(time.RFC3339)
o.SharesLastStatus = res.status
o.SharesLastCount = res.count
}); err != nil {
m.logger.Printf("[WARN] [shares] offsite status persist failed: %v", err)
}
}
// SharesOffsiteStatus returns the last shares-leg outcome for the „Megosztás" page: the RFC3339 run
// stamp, the status label and how many share folders the push covered. ok=false when no offsite
// target is configured or the leg has never run.
func (m *Manager) SharesOffsiteStatus() (lastRun, status string, count int, ok bool) {
if m.settings == nil {
return "", "", 0, false
}
t := m.settings.GetOffboxTarget()
if t == nil || t.SharesLastStatus == "" {
return "", "", 0, false
}
return t.SharesLastRun, t.SharesLastStatus, t.SharesLastCount, true
}
// sharesBlockedWarning renders the customer-facing note for a quota-degraded shares push. It goes
// through DisplayStackName's vocabulary deliberately: the reserved `_shares` key must never appear
// in Hungarian prose.
func sharesBlockedWarning() string {
return fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt a(z) %s tartalma nem került a távoli mentésbe — csak a megosztás-beállítások.",
strings.ToLower(SharesDisplayName))
}
@@ -0,0 +1,259 @@
package backup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-7b offsite shares leg. The headline test in this file is the B ISOLATION PROOF: adding the
// shares source must leave every per-app restic invocation BYTE-IDENTICAL. That is the whole premise
// of Model B — if it does not hold, the design has silently become engine-loop surgery.
// sharesOffboxEnv wires an offbox manager with one app (unit on `drive`) and the shares feature on,
// so a run can be taken with and without shares against the SAME paths.
type sharesOffboxEnv struct {
m *Manager
sett *settings.Settings
drive string
unit string
}
func newSharesOffboxEnv(t *testing.T, app string) *sharesOffboxEnv {
t.Helper()
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
unit := mkUnit(t, drive, app)
prov.hdd[app] = drive
if err := sett.SetAppOffbox(app, true); err != nil {
t.Fatal(err)
}
if err := sett.SetSMBEnabled(true); err != nil {
t.Fatal(err)
}
m.SetSharesPassdbCapturer(func() ([]byte, error) { return []byte("FAKE-PASSDB"), nil })
return &sharesOffboxEnv{m: m, sett: sett, drive: drive, unit: unit}
}
// addOffsiteShare registers an available share on the env's drive.
func (e *sharesOffboxEnv) addOffsiteShare(t *testing.T, name string, offsite bool) string {
t.Helper()
p := filepath.Join(e.drive, name)
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
t.Fatal(err)
}
return p
}
// run takes one offsite run and returns the capture.
func (e *sharesOffboxEnv) run(t *testing.T) *backupCapture {
t.Helper()
cap := &backupCapture{}
e.m.SetOffboxRunner(cap.runner())
if err := e.m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
return cap
}
// THE B ISOLATION PROOF. One app, then the same app plus one mandatory share: the app's restic argv
// must be byte-identical across both runs, and the shares source must appear as exactly ONE
// additional call. Red-proof: make the shares leg append its paths into the app's argv instead of
// issuing its own call — this test fails.
func TestOffboxSharesLegLeavesAppCallsByteIdentical(t *testing.T) {
env := newSharesOffboxEnv(t, "immich")
baseline := env.run(t)
baseArgs := baseline.byStack["immich"]
if len(baseArgs) == 0 {
t.Fatal("precondition: the baseline run produced no app backup call")
}
if baseline.backups != 1 {
t.Fatalf("precondition: baseline should be exactly 1 backup call, got %d", baseline.backups)
}
env.addOffsiteShare(t, "dokumentumok", true)
withShares := env.run(t)
gotArgs := withShares.byStack["immich"]
if strings.Join(gotArgs, "\x00") != strings.Join(baseArgs, "\x00") {
t.Errorf("B INVARIANT VIOLATED — the app's restic argv changed when shares were added:\n baseline: %v\n with shares: %v", baseArgs, gotArgs)
}
if withShares.backups != 2 {
t.Errorf("expected exactly ONE additional restic call for the shares source, got %d total", withShares.backups)
}
if _, ok := withShares.byStack[SharesPseudoStack]; !ok {
t.Fatalf("no restic call tagged %q was issued: %v", SharesPseudoStack, withShares.byStack)
}
}
// Scenario A: a mandatory share reaches offsite — correct tags, the manifest staging dir, and the
// share folder. Red-proof: flip the mandatory→offsite mapping (push only non-mandatory shares) and
// this fails.
func TestOffboxSharesLegPushesMandatoryShare(t *testing.T) {
env := newSharesOffboxEnv(t, "immich")
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
cap := env.run(t)
args := cap.byStack[SharesPseudoStack]
if len(args) == 0 {
t.Fatal("no shares call issued")
}
if !contains(args, "felhom-offbox") || !contains(args, SharesPseudoStack) {
t.Errorf("shares call must carry BOTH tags [felhom-offbox, %s]: %v", SharesPseudoStack, args)
}
if !contains(args, sharePath) {
t.Errorf("shares call missing the mandatory share path %q: %v", sharePath, args)
}
if !contains(args, env.m.SharesPayloadDir()) {
t.Errorf("shares call missing the manifest staging dir %q: %v", env.m.SharesPayloadDir(), args)
}
// The manifest on disk must be the registry.
blob, err := os.ReadFile(filepath.Join(env.m.SharesPayloadDir(), sharesManifestName))
if err != nil {
t.Fatalf("manifest not staged: %v", err)
}
if !strings.Contains(string(blob), "dokumentumok") {
t.Errorf("manifest does not describe the share: %s", blob)
}
// Per-tier status must be recorded for the „Megosztás" page.
_, status, count, ok := env.m.SharesOffsiteStatus()
if !ok || status != "ok" || count != 1 {
t.Errorf("SharesOffsiteStatus = (%q, %d, %v), want (ok, 1, true)", status, count, ok)
}
}
// Scenario B: an OPTIONAL share is tier-2-only — its path must appear in NO restic argument.
func TestOffboxSharesLegExcludesOptionalShare(t *testing.T) {
env := newSharesOffboxEnv(t, "immich")
mandatoryPath := env.addOffsiteShare(t, "dokumentumok", true)
optionalPath := env.addOffsiteShare(t, "filmek", false)
cap := env.run(t)
for tag, args := range cap.byStack {
if contains(args, optionalPath) {
t.Errorf("OPTIONAL share path leaked into the %q restic call: %v", tag, args)
}
}
if !contains(cap.byStack[SharesPseudoStack], mandatoryPath) {
t.Error("the mandatory share should still be pushed")
}
}
// Scenario C: the quota gate degrades the push to the MANIFEST ONLY — definitions protection never
// regresses — the blocked set gains the reserved key, and the notification is edge-triggered so a
// second identical run does NOT re-notify. Red-proof: drop the manifest-only degradation (skip the
// whole leg when blocked) and the "manifest still pushed" assertion fails.
func TestOffboxSharesLegQuotaDegradesToManifestOnly(t *testing.T) {
env := newSharesOffboxEnv(t, "immich")
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
// A 1 GB quota with a 2 GB share estimate: the gate must trip.
if err := env.sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 1 }); err != nil {
t.Fatal(err)
}
env.m.SetOffboxSizer(func(string) int64 { return 2 * offboxGiB })
var notified []string
env.m.SetOffboxEnlargeBlockedNotifier(func(stack string, _ int64, _, _ int) {
notified = append(notified, stack)
})
cap := env.run(t)
args := cap.byStack[SharesPseudoStack]
if len(args) == 0 {
t.Fatal("the blocked run must still push the definitions, not skip the leg entirely")
}
if contains(args, sharePath) {
t.Errorf("a quota-blocked push must NOT carry the share folder: %v", args)
}
if !contains(args, env.m.SharesPayloadDir()) {
t.Errorf("a quota-blocked push MUST still carry the manifest (definitions protection never regresses): %v", args)
}
// The persisted blocked set keeps the RAW key (templates index by it)…
tgt := env.sett.GetOffboxTarget()
if !containsStr(tgt.EnlargedBlocked, SharesPseudoStack) {
t.Errorf("EnlargedBlocked should contain the raw %q key, got %v", SharesPseudoStack, tgt.EnlargedBlocked)
}
// …while the NOTIFICATION boundary renders the Hungarian display name.
if len(notified) != 1 || notified[0] != SharesDisplayName {
t.Errorf("notification should fire once as %q, got %v", SharesDisplayName, notified)
}
// The customer-facing warning must not leak the reserved key either.
if strings.Contains(tgt.LastWarning, SharesPseudoStack) {
t.Errorf("the reserved key leaked into Hungarian prose: %q", tgt.LastWarning)
}
// Edge-trigger: an identical second run must NOT re-notify.
notified = nil
env.run(t)
if len(notified) != 0 {
t.Errorf("a persistently-blocked shares source must not re-notify nightly, got %v", notified)
}
}
// Sharing disabled / no shares: no `_shares` restic group is created at all.
func TestOffboxSharesLegNoOpWhenSharingOff(t *testing.T) {
env := newSharesOffboxEnv(t, "immich")
if err := env.sett.SetSMBEnabled(false); err != nil {
t.Fatal(err)
}
cap := env.run(t)
if _, ok := cap.byStack[SharesPseudoStack]; ok {
t.Error("a disabled sharing feature must create no _shares snapshot group")
}
if cap.backups != 1 {
t.Errorf("expected only the app's call, got %d", cap.backups)
}
}
// Scenario F, offsite side: a share on an unavailable drive reaches NO restic argument, and the run
// still covers the healthy shares.
func TestOffboxSharesLegSkipsDeadMount(t *testing.T) {
env := newSharesOffboxEnv(t, "immich")
live := env.addOffsiteShare(t, "elo", true)
dead := filepath.Join(env.drive, "halott")
if err := env.sett.AddSMBShare(settings.SMBShare{Name: "halott", Path: dead, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
t.Fatal(err)
} // folder deliberately never created → unavailable
cap := env.run(t)
args := cap.byStack[SharesPseudoStack]
if contains(args, dead) {
t.Errorf("an unavailable share path reached the restic argv: %v", args)
}
if !contains(args, live) {
t.Errorf("the healthy share must still be pushed: %v", args)
}
}
// A run whose ONLY cloud content is shares must not be told "nothing is selected".
func TestOffboxSharesLegSuppressesZeroToggleNotice(t *testing.T) {
env := newSharesOffboxEnv(t, "immich")
if err := env.sett.SetAppOffbox("immich", false); err != nil {
t.Fatal(err)
}
env.addOffsiteShare(t, "dokumentumok", true)
env.run(t)
if w := env.sett.GetOffboxTarget().LastWarning; strings.Contains(w, "nincs mentésre jelölt alkalmazás") {
t.Errorf("a box whose cloud content is its shares is covered — misleading warning: %q", w)
}
}
// containsStr is a small slice helper (the package's `contains` takes the restic argv shape).
func containsStr(hay []string, needle string) bool {
for _, h := range hay {
if h == needle {
return true
}
}
return false
}
@@ -0,0 +1,265 @@
package backup
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// SMB share backup — R-7b, Model B (Viktor's ruling 2026-07-18). Share data enters the live backup
// runs through a SIBLING shares source: additive job/leg code that reuses the proven primitives
// (tier-2 mirror seam, restic wrappers, soft-quota gate, status recording) while leaving every
// per-app engine path BYTE-IDENTICAL. This file holds the piece both tiers share: the PAYLOAD — the
// share DEFINITIONS plus the SMB password hash — so a DR rebuild restores the files, the share
// configuration, and the household credential, not just the bytes on disk.
//
// Why a payload at all: the share folders are plain customer directories; copying them protects the
// data but loses everything that made them shares. Without the manifest a restore leaves the customer
// with their files back and an empty „Megosztás" page.
const (
// SharesPseudoStack is the RESERVED key the shares source occupies wherever the per-app engines
// key by stack name: the restic tag, the tier-2 dest root (backups/secondary/_shares/…), and the
// CrossDriveBackup status record. It is NEVER shown to the customer — every UI/notification
// boundary maps it through SharesDisplayName (see DisplayStackName).
//
// RESERVED-NAME VERIFICATION (R-7b): the claim that slice-1 validation already excludes a leading
// underscore is FALSE for shares — settings.nbNameRe starts with [A-Za-z0-9_], so „_shares" was an
// accepted share name. settings.ValidateSMBShareName now rejects the underscore-prefixed namespace
// outright. Stack names come from the git-synced catalog (not customer input) and so cannot
// realistically produce „_shares", but RunAllTier2 and RunOffboxBackup skip such a stack with a
// loud WARN as defense in depth rather than let it clobber the shares tree.
SharesPseudoStack = "_shares"
// SharesDisplayName is the Hungarian customer-facing name for the shares source.
SharesDisplayName = "Megosztások"
// sharesManifestName is the share-definition document inside the payload.
sharesManifestName = "_shares-manifest.json"
// sharesPassdbName is the SECRET-BEARING samba passdb archive inside the payload. It rides the
// restic repo (encrypted at rest) and the tier-2 staging on the customer's own drives; its bytes
// and its name never reach a log line at INFO, a report, or a committed file.
sharesPassdbName = "passdb.tar"
// sharesPassdbMember is the subtree inside infra.SambaPassdbMount that holds the credential.
sharesPassdbMember = "private"
// sharesManifestVersion pins the payload document shape.
sharesManifestVersion = 1
)
// DisplayStackName maps an engine-internal stack key to the name a customer may see. Today the only
// mapping is the reserved shares pseudo-stack; every other key is its own display name. THIS is the
// single boundary that keeps „_shares" out of the Hungarian UI, alerts and e-mails.
func DisplayStackName(key string) string {
if key == SharesPseudoStack {
return SharesDisplayName
}
return key
}
// SharesManifest is the restorable share-definition document. It is deliberately timestamp-free at
// the document level so the rendered JSON is BYTE-DETERMINISTIC for an unchanged registry — the
// tier-2 mirror then has nothing to rewrite on a no-op run. Per-share CreatedAt is preserved.
type SharesManifest struct {
Version int `json:"version"`
ServerName string `json:"server_name"`
Shares []settings.SMBShare `json:"shares"`
}
// SetSharesPassdbCapturer overrides the samba passdb capture (tests inject a fake so no docker runs).
func (m *Manager) SetSharesPassdbCapturer(fn func() ([]byte, error)) { m.sharesPassdbCapture = fn }
// sharesPassdbCapturer returns the passdb capture seam (nil → the real docker exec).
func (m *Manager) sharesPassdbCapturer() func() ([]byte, error) {
if m.sharesPassdbCapture != nil {
return m.sharesPassdbCapture
}
return defaultSharesPassdbCapture
}
// defaultSharesPassdbCapture tars the samba private/ subtree out of the running container on stdout.
// Best-effort by contract: a stopped container simply yields an error and the payload ships
// manifest-only (re-setting the SMB password is a cheap UX step; the definitions are the load-bearing
// part). Uses the same `docker exec` shape as stacks.extractInitialCreds.
func defaultSharesPassdbCapture() ([]byte, error) {
cmd := exec.Command("docker", "exec", infra.SambaContainerName,
"tar", "cf", "-", "-C", infra.SambaPassdbMount, sharesPassdbMember)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("passdb capture: %w", err)
}
return out, nil
}
// sharesPayloadDir is the staging directory both tiers read. It lives in the controller DATA DIR
// (not on a customer drive) for two reasons: the payload is kilobytes — manifest JSON plus a small
// tdb archive — so the F-A1 rootfs-filler concern does not apply, and a DETERMINISTIC absolute path
// makes the restic snapshot path stable, which is what the restore mapper anchors on. 0700 because
// the passdb archive is secret-bearing.
func (m *Manager) sharesPayloadDir() string {
return filepath.Join(m.cfg.Paths.DataDir, "shares-payload")
}
// SharesPayloadDir exposes the staging path for the restore mapper and tests.
func (m *Manager) SharesPayloadDir() string { return m.sharesPayloadDir() }
// buildSharesManifest renders the deterministic manifest document from the live registry. Shares are
// sorted case-insensitively by name so an unchanged registry always marshals to identical bytes
// regardless of the order the customer happened to add them in.
func (m *Manager) buildSharesManifest() SharesManifest {
mf := SharesManifest{Version: sharesManifestVersion, Shares: []settings.SMBShare{}}
if m.settings == nil {
return mf
}
smb := m.settings.GetSMBSettings()
mf.ServerName = smb.EffectiveServerName()
mf.Shares = append(mf.Shares, m.settings.GetSMBShares()...)
sort.Slice(mf.Shares, func(i, j int) bool {
a, b := strings.ToLower(mf.Shares[i].Name), strings.ToLower(mf.Shares[j].Name)
if a != b {
return a < b
}
return mf.Shares[i].Name < mf.Shares[j].Name
})
return mf
}
// buildSharesPayload stages the payload and returns its directory. The manifest is ALWAYS written
// (it is the definitions-protection floor that must never regress); the passdb archive is
// best-effort and its absence is a WARN, not an error. Returns the dir plus whether the passdb made
// it in, so callers can report honestly.
func (m *Manager) buildSharesPayload() (dir string, passdbOK bool, err error) {
dir = m.sharesPayloadDir()
if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil {
return "", false, fmt.Errorf("shares payload dir: %w", mkErr)
}
// Tighten an inherited-loose mode from an older layout (the passdb archive lives here).
if chErr := os.Chmod(dir, 0o700); chErr != nil {
m.logger.Printf("[WARN] [shares] payload dir chmod failed: %v", chErr)
}
blob, mErr := json.MarshalIndent(m.buildSharesManifest(), "", " ")
if mErr != nil {
return "", false, fmt.Errorf("shares manifest marshal: %w", mErr)
}
blob = append(blob, '\n')
if wErr := writeFileAtomic(filepath.Join(dir, sharesManifestName), blob, 0o600); wErr != nil {
return "", false, fmt.Errorf("shares manifest write: %w", wErr)
}
passdbPath := filepath.Join(dir, sharesPassdbName)
tar, pErr := m.sharesPassdbCapturer()()
switch {
case pErr != nil || len(tar) == 0:
// Container down / never deployed. Keep any PREVIOUSLY captured archive rather than deleting
// it — a stale credential copy is strictly better for DR than none, and the payload stays
// self-consistent because the manifest carries no password of its own.
if _, sErr := os.Stat(passdbPath); sErr == nil {
m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — keeping the previously captured copy: %v", pErr)
passdbOK = true
} else {
m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — payload is manifest-only; the SMB password must be re-set after a restore: %v", pErr)
}
default:
if wErr := writeFileAtomic(passdbPath, tar, 0o600); wErr != nil {
// Never fatal: definitions protection must not hinge on the credential copy.
m.logger.Printf("[WARN] [shares] passdb stage failed — payload is manifest-only: %v", wErr)
} else {
passdbOK = true
m.logger.Printf("[DEBUG] [shares] passdb archive staged (%d bytes)", len(tar))
}
}
m.logger.Printf("[INFO] [shares] payload staged: %d share definition(s), credential copy=%v", len(m.buildSharesManifest().Shares), passdbOK)
return dir, passdbOK, nil
}
// writeFileAtomic writes via tmp + rename at the requested mode (mirrors stacks.sambaWriteAtomic's
// discipline: a crash mid-write can never leave a half-manifest a restore would read).
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return err
}
if err := os.Chmod(tmp, mode); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// classifiedShare is one share the backup sources act on, with its class resolved from the registry
// exactly as stacks.sambaClassifiedBinds does it: Offsite=true ⇒ MANDATORY (offsite + tier-2),
// Offsite=false ⇒ OPTIONAL (tier-2 only). Availability is resolved here too, so BOTH jobs skip a
// dead mount identically (Scenario F).
type classifiedShare struct {
settings.SMBShare
mandatory bool
}
// classifiedShares returns the shares both jobs act on, dropping unavailable ones with a loud WARN.
// A share whose owning drive is disconnected/decommissioned, or whose folder simply is not there, is
// NEVER handed to a mirror or a restic argv: mirroring a missing mountpoint would copy an empty dir
// over a good backup, and that is the silently-wrong-restore class this codebase refuses by rule.
func (m *Manager) classifiedShares() []classifiedShare {
if m.settings == nil {
return nil
}
var out []classifiedShare
for _, sh := range m.settings.GetSMBShares() {
if !m.shareBackupAvailable(sh.Path) {
m.logger.Printf("[WARN] [shares] share skipped — folder unavailable (drive away or path missing): name=%s", sh.Name)
continue
}
out = append(out, classifiedShare{SMBShare: sh, mandatory: sh.Offsite})
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
return out
}
// shareBackupAvailable mirrors stacks.shareAvailable (the export-time gate) so the backup sources and
// the smb.conf renderer agree on what "available" means. Kept local rather than imported: the backup
// package must not depend on the stacks package.
func (m *Manager) shareBackupAvailable(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
if m.settings != nil {
p := filepath.ToSlash(path)
for _, sp := range m.settings.GetStoragePaths() {
root := filepath.ToSlash(sp.Path)
if p == root || strings.HasPrefix(p, root+"/") {
if sp.Disconnected || sp.Decommissioned {
return false
}
break
}
}
}
st, err := os.Stat(path)
return err == nil && st.IsDir()
}
// sharesEnabled reports whether the shares source has anything to do at all: the feature is on AND at
// least one share is registered. A disabled feature or an empty registry is a clean no-op in both
// jobs — no `_shares` restic group, no empty dest dirs (the zero-shares edge case).
func (m *Manager) sharesEnabled() bool {
if m.settings == nil {
return false
}
return m.settings.GetSMBSettings().Enabled && len(m.settings.GetSMBShares()) > 0
}
// sharesNow is the timestamp helper for the shares status records (kept in one place so tests that
// assert on recorded status have a single seam to reason about).
func sharesNow() string { return time.Now().Format(time.RFC3339) }
@@ -0,0 +1,321 @@
package backup
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// Shares restore — R-7b Part 4. A SIBLING of the per-app scratch/place flow in offbox_restore.go,
// mirroring its shape (restore to an on-data-drive scratch first, then a separate, deliberate
// place-to-live merge) without touching it.
//
// Three things come back, in this order of importance:
// 1. the FILES — placed missing-only into each share's live folder, never overwriting;
// 2. the DEFINITIONS — merged into the share registry so the „Megosztás" page is whole again;
// 3. the CREDENTIAL — best-effort, into the samba named volume, so the household need not re-set it.
//
// The load-bearing guard is the PREFIX ASSERT: a destination is only written when it resolves
// strictly inside a REGISTERED, LIVE storage root. A snapshot is untrusted input for this purpose —
// it was written by an older version of this box, possibly with a different drive layout — so a path
// that no longer sits under a live root is refused rather than created.
// SetSharesReconciler wires the post-restore samba re-render (main.go → stacks.ReconcileSamba).
func (m *Manager) SetSharesReconciler(fn func() error) { m.sharesReconcile = fn }
// SetSharesPassdbRestorer overrides the passdb restore exec (tests).
func (m *Manager) SetSharesPassdbRestorer(fn func(tar []byte) error) { m.sharesPassdbRestore = fn }
func (m *Manager) sharesPassdbRestorer() func([]byte) error {
if m.sharesPassdbRestore != nil {
return m.sharesPassdbRestore
}
return defaultSharesPassdbRestore
}
// defaultSharesPassdbRestore untars a captured passdb archive back into the samba named volume. It
// writes ONLY into infra.SambaPassdbMount inside the samba container — never onto the host — so a
// malformed archive cannot reach anything outside the volume it came from.
func defaultSharesPassdbRestore(tar []byte) error {
cmd := exec.Command("docker", "exec", "-i", infra.SambaContainerName,
"tar", "xf", "-", "-C", infra.SambaPassdbMount)
cmd.Stdin = bytes.NewReader(tar)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("passdb restore: %s: %w", truncate(out), err)
}
return nil
}
// sharesRestoreScratchDir returns the on-DATA-DRIVE scratch for the shares restore. Never the
// controller data dir (the F-A1 rootfs-filler lesson) and never network storage when a local drive
// exists (the F-6C-1 ownership-fidelity lesson).
func (m *Manager) sharesRestoreScratchDir() (scratch, nsRoot string, err error) {
if m.settings == nil {
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
}
pick := func(networkOK bool) (string, string, bool) {
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if strings.TrimSpace(sp.Path) == "" || (!networkOK && sp.IsNetwork()) {
continue
}
nr := m.namespaceRoot(sp.Path)
return filepath.Join(nr, "backups", "offsite-restore", SharesPseudoStack), nr, true
}
return "", "", false
}
if s, nr, ok := pick(false); ok {
return s, nr, nil
}
if s, nr, ok := pick(true); ok {
m.logger.Printf("[WARN] [shares] restore scratch on network storage — ownership fidelity not guaranteed under squash")
return s, nr, nil
}
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
}
// RestoreSharesScratch restores the latest `_shares` snapshot into the scratch dir. Non-destructive:
// it never touches a live share folder, the registry, or the credential — PlaceSharesRestore is the
// deliberate second action that does.
func (m *Manager) RestoreSharesScratch(ctx context.Context) error {
if !m.OffboxConfigured() {
return fmt.Errorf("a távoli mentés nincs beállítva")
}
scratch, nsRoot, err := m.sharesRestoreScratchDir()
if err != nil {
return err
}
if free := m.offboxFree()(nsRoot); free > 0 && free < offboxUnitOnlyFreeFloor {
return fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).",
humanizeBytes(offboxUnitOnlyFreeFloor), humanizeBytes(free))
}
id, _, err := m.offboxLatestSnapshot(ctx, SharesPseudoStack)
if err != nil {
return fmt.Errorf("nincs visszaállítható megosztás-mentés: %w", err)
}
if err := os.MkdirAll(scratch, 0o755); err != nil {
return fmt.Errorf("restore dir: %w", err)
}
t := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t)
rctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
defer cancel()
m.unlockStale(rctx, base, env)
out, rerr := m.resticStep(rctx, env, base, "restore:"+SharesPseudoStack, "restore", id, "--target", scratch)
if rerr != nil {
return fmt.Errorf("a megosztások visszaállítása sikertelen: %w: %s", rerr, truncate(out))
}
m.logger.Printf("[INFO] [shares] restored snapshot %s → %s", id, scratch)
return nil
}
// SharesScratchReady reports whether a completed shares scratch exists (gates the place action).
func (m *Manager) SharesScratchReady() bool {
scratch, _, err := m.sharesRestoreScratchDir()
if err != nil {
return false
}
entries, rErr := os.ReadDir(scratch)
return rErr == nil && len(entries) > 0
}
// SharesRestoreResult is what the flash message reports back to the customer.
type SharesRestoreResult struct {
FilesRestored int // files merged into live share folders
SharesPlaced []string // share folders whose files were merged
DefinitionsAdded []string // share definitions re-added to the registry
DefinitionsKept []string // definitions skipped because a live share already owns the name
Refused []string // definitions refused: destination is not under a live storage root
PasswordRestored bool // the household SMB credential was put back
}
// liveShareRootOK prefix-asserts a destination against the REGISTERED, LIVE storage roots. It
// requires a STRICT descendant: equal-to-the-root is refused too, because placing a share's contents
// at a drive root would scatter restored files across the whole drive. A `..` segment is refused
// outright rather than relying on Clean, so a traversal attempt is visible in the logs.
func (m *Manager) liveShareRootOK(dst string) bool {
if m.settings == nil || strings.TrimSpace(dst) == "" {
return false
}
clean := filepath.Clean(dst)
for _, seg := range strings.Split(filepath.ToSlash(dst), "/") {
if seg == ".." {
return false
}
}
p := filepath.ToSlash(clean)
for _, sp := range m.settings.GetStoragePaths() {
if sp.Decommissioned || sp.Disconnected {
continue
}
root := filepath.ToSlash(filepath.Clean(sp.Path))
if root == "" || root == "/" {
continue
}
if strings.HasPrefix(p, root+"/") {
return true
}
}
return false
}
// scratchJoin reconstructs an absolute captured path UNDER a restore scratch — restic restores with
// the absolute source structure preserved, so /mnt/hdd_1/dokumentumok lands at
// <scratch>/mnt/hdd_1/dokumentumok. The volume name and leading separator are stripped explicitly
// rather than relying on filepath.Join, which on a non-POSIX host would splice a drive letter into
// the middle of the path and produce an unopenable name.
func scratchJoin(scratch, abs string) string {
rel := abs
if vol := filepath.VolumeName(rel); vol != "" {
rel = rel[len(vol):]
}
rel = strings.TrimLeft(filepath.ToSlash(rel), "/")
return filepath.Join(scratch, filepath.FromSlash(rel))
}
// readSharesManifestFrom reads the manifest out of a restored scratch tree.
func (m *Manager) readSharesManifestFrom(scratch string) (SharesManifest, error) {
var mf SharesManifest
p := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesManifestName)
blob, err := os.ReadFile(p)
if err != nil {
return mf, fmt.Errorf("a mentésben nincs megosztás-leíró: %w", err)
}
if err := json.Unmarshal(blob, &mf); err != nil {
return mf, fmt.Errorf("a megosztás-leíró olvashatatlan: %w", err)
}
return mf, nil
}
// PlaceSharesRestore places a completed shares scratch into live locations: files first (missing-only
// merge, never overwriting), then the definitions (existing live definitions WIN on a name conflict —
// a restore must not silently flip a live share's read-only or cloud setting), then the samba
// re-render, then the credential. Single-flight.
func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult, error) {
var res SharesRestoreResult
if err := m.acquireRunning(); err != nil {
return res, fmt.Errorf("egy másik mentési/visszaállítási művelet már fut")
}
defer m.releaseRunning()
scratch, _, err := m.sharesRestoreScratchDir()
if err != nil {
return res, err
}
if _, sErr := os.Stat(scratch); sErr != nil {
return res, fmt.Errorf("nincs előkészített visszaállítás — futtass előbb egy megosztás-visszaállítást")
}
mf, err := m.readSharesManifestFrom(scratch)
if err != nil {
return res, err
}
// Live registry, indexed case-insensitively (AddSMBShare's own collision rule).
live := map[string]bool{}
if m.settings != nil {
for _, sh := range m.settings.GetSMBShares() {
live[strings.ToLower(sh.Name)] = true
}
}
copier := m.placeCopier()
for _, sh := range mf.Shares {
// THE PREFIX ASSERT. The snapshot's path is untrusted layout input; a destination that is not
// strictly inside a live registered root is refused, never created.
if !m.liveShareRootOK(sh.Path) {
m.logger.Printf("[WARN] [shares] restore refused for %s — destination is not under a live storage root", sh.Name)
res.Refused = append(res.Refused, sh.Name)
continue
}
src := scratchJoin(scratch, sh.Path)
if _, sErr := os.Stat(src); sErr == nil {
n, cErr := copier(src, sh.Path)
if cErr != nil {
return res, fmt.Errorf("a(z) „%s” megosztás fájljainak visszaállítása sikertelen: %w", sh.Name, cErr)
}
res.FilesRestored += n
res.SharesPlaced = append(res.SharesPlaced, sh.Name)
} else {
// A definitions-only snapshot (the quota-degraded shape) legitimately has no file tree.
m.logger.Printf("[DEBUG] [shares] no restored file tree for %s — definitions-only snapshot", sh.Name)
}
// Definitions: existing live share WINS. Restoring must never silently change a share the
// household is using right now.
if live[strings.ToLower(sh.Name)] {
res.DefinitionsKept = append(res.DefinitionsKept, sh.Name)
continue
}
if m.settings != nil {
if aErr := m.settings.AddSMBShare(sh); aErr != nil {
m.logger.Printf("[WARN] [shares] could not re-add definition %s: %v", sh.Name, aErr)
continue
}
res.DefinitionsAdded = append(res.DefinitionsAdded, sh.Name)
}
}
// Re-render smb.conf so the restored definitions are actually exported.
if len(res.DefinitionsAdded) > 0 {
if m.sharesReconcile == nil {
m.logger.Printf("[WARN] [shares] no samba reconciler wired — smb.conf will catch up on the next health tick")
} else if rErr := m.sharesReconcile(); rErr != nil {
m.logger.Printf("[WARN] [shares] samba re-render after restore failed: %v", rErr)
}
}
// Credential, best-effort and last: the files and definitions are the load-bearing parts, and
// re-setting an SMB password is a cheap, well-signposted UX step.
passdb := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesPassdbName)
if blob, rErr := os.ReadFile(passdb); rErr == nil && len(blob) > 0 {
if pErr := m.sharesPassdbRestorer()(blob); pErr != nil {
m.logger.Printf("[WARN] [shares] credential restore failed — the SMB password must be re-set: %v", pErr)
} else {
res.PasswordRestored = true
if m.settings != nil {
if sErr := m.settings.SetSMBUserSet(true); sErr != nil {
m.logger.Printf("[WARN] [shares] persist user-set flag after credential restore failed: %v", sErr)
}
}
m.logger.Printf("[INFO] [shares] household credential restored into the sharing service")
}
}
if rmErr := os.RemoveAll(scratch); rmErr != nil {
m.logger.Printf("[WARN] [shares] scratch cleanup failed (harmless): %v", rmErr)
}
m.logger.Printf("[INFO] [shares] restore placed: %d file(s), %d definition(s) re-added, %d kept, %d refused, credential=%v",
res.FilesRestored, len(res.DefinitionsAdded), len(res.DefinitionsKept), len(res.Refused), res.PasswordRestored)
return res, nil
}
// FlashMessage renders the Hungarian summary the „Megosztások visszaállítása" action flashes back.
func (r SharesRestoreResult) FlashMessage() string {
var parts []string
parts = append(parts, fmt.Sprintf("%s visszaállítva: %d fájl, %d megosztás-beállítás.",
SharesDisplayName, r.FilesRestored, len(r.DefinitionsAdded)))
for _, n := range r.DefinitionsKept {
parts = append(parts, fmt.Sprintf("A(z) %s megosztás beállítása már létezik — a meglévő maradt.", n))
}
if len(r.Refused) > 0 {
parts = append(parts, fmt.Sprintf("Nem állítható vissza (a mappa nincs élő adatmeghajtón): %s.",
strings.Join(r.Refused, ", ")))
}
if !r.PasswordRestored {
parts = append(parts, "A megosztás jelszavát újra meg kell adni.")
}
return strings.Join(parts, " ")
}
// SharesRegistryCount is a small helper for the page (how many shares the registry holds).
func (m *Manager) SharesRegistryCount() int {
if m.settings == nil {
return 0
}
return len(m.settings.GetSMBShares())
}
@@ -0,0 +1,268 @@
package backup
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-7b Part 4 — shares restore. Scenario D's contract: files come back byte-identical into the LIVE
// share folder, a deleted definition reappears in the registry, a definition that still exists is
// left alone, and the place step never writes outside a registered live root.
// seedSharesScratch lays down a completed restore scratch: the payload (manifest + credential) plus
// a restored file tree for each named share, mirroring what restic's absolute-path restore produces.
func seedSharesScratch(t *testing.T, env *sharesEnv, mf SharesManifest, files map[string]string) string {
t.Helper()
scratch, _, err := env.m.sharesRestoreScratchDir()
if err != nil {
t.Fatal(err)
}
payload := scratchJoin(scratch, env.m.sharesPayloadDir())
if err := os.MkdirAll(payload, 0o755); err != nil {
t.Fatal(err)
}
blob := mustJSON(t, mf)
if err := os.WriteFile(filepath.Join(payload, sharesManifestName), blob, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(payload, sharesPassdbName), []byte("FAKE-PASSDB-TAR"), 0o600); err != nil {
t.Fatal(err)
}
for livePath, content := range files {
p := scratchJoin(scratch, livePath)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return scratch
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := jsonMarshalIndent(v)
if err != nil {
t.Fatal(err)
}
return b
}
// wirePlaceSeams installs a real (missing-only) copier and a fake passdb restorer.
func wirePlaceSeams(env *sharesEnv, passdbCalls *int) {
env.m.offboxPlaceCopier = func(src, dst string) (int, error) {
n := 0
err := filepath.Walk(src, func(p string, fi os.FileInfo, err error) error {
if err != nil || fi.IsDir() {
return err
}
rel, rErr := filepath.Rel(src, p)
if rErr != nil {
return rErr
}
target := filepath.Join(dst, rel)
if _, sErr := os.Stat(target); sErr == nil {
return nil // missing-only: never overwrite
}
b, rErr := os.ReadFile(p)
if rErr != nil {
return rErr
}
if mErr := os.MkdirAll(filepath.Dir(target), 0o755); mErr != nil {
return mErr
}
n++
return os.WriteFile(target, b, 0o644)
})
return n, err
}
env.m.sharesPassdbRestore = func([]byte) error { *passdbCalls++; return nil }
}
// Scenario D: the round trip. A deleted file returns byte-identical; a deleted definition reappears
// and triggers the samba re-render; a definition that still exists is KEPT (a restore must never
// silently flip a live share's settings).
func TestSharesRestoreRoundTrip(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
keptPath := env.addShare(t, "hdd_1", "marad", true)
deletedPath := filepath.Join(env.drives["hdd_1"], "torolt")
mf := SharesManifest{
Version: sharesManifestVersion, ServerName: "FELHOM",
Shares: []settings.SMBShare{
// Still live — its definition must be kept, not overwritten (note the flipped ReadOnly:
// if the merge preferred the snapshot, this would silently change a live share).
{Name: "marad", Path: keptPath, ReadOnly: true, Offsite: false, CreatedAt: "2020-01-01T00:00:00Z"},
// Deleted from the registry — must come back.
{Name: "torolt", Path: deletedPath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
},
}
seedSharesScratch(t, env, mf, map[string]string{
filepath.Join(deletedPath, "fontos.txt"): "EREDETI-TARTALOM",
filepath.Join(keptPath, "marad.txt"): "SNAPSHOT-VERZIO",
})
var reconciled, passdbCalls int
env.m.SetSharesReconciler(func() error { reconciled++; return nil })
wirePlaceSeams(env, &passdbCalls)
res, err := env.m.PlaceSharesRestore(t.Context())
if err != nil {
t.Fatalf("PlaceSharesRestore: %v", err)
}
// 1. File back, byte-identical, in the LIVE folder.
b, rErr := os.ReadFile(filepath.Join(deletedPath, "fontos.txt"))
if rErr != nil || string(b) != "EREDETI-TARTALOM" {
t.Errorf("restored file wrong/missing: %q, %v", b, rErr)
}
// 2. Missing-only: the live file must NOT be clobbered by the snapshot version.
b, _ = os.ReadFile(filepath.Join(keptPath, "marad.txt"))
if string(b) != "content-of-marad" {
t.Errorf("a live file was overwritten by the restore: %q", b)
}
// 3. Deleted definition reappears; live definition kept unchanged.
if !containsStr(res.DefinitionsAdded, "torolt") {
t.Errorf("deleted definition did not reappear: %+v", res)
}
if !containsStr(res.DefinitionsKept, "marad") {
t.Errorf("live definition should be reported as kept: %+v", res)
}
for _, sh := range env.sett.GetSMBShares() {
if sh.Name == "marad" && sh.ReadOnly {
t.Error("a restore silently flipped a LIVE share's ReadOnly setting")
}
}
// 4. smb.conf re-rendered so the restored share is actually exported.
if reconciled != 1 {
t.Errorf("ReconcileSamba should run exactly once after adding definitions, ran %d", reconciled)
}
// 5. Credential restored best-effort.
if passdbCalls != 1 || !res.PasswordRestored {
t.Errorf("credential restore did not run: calls=%d result=%v", passdbCalls, res.PasswordRestored)
}
}
// THE PLACE-GUARD RED-PROOF TARGET. A manifest whose share path escapes every registered live root
// must be REFUSED with zero writes. Red-proof: make liveShareRootOK return true unconditionally and
// this test fails (the traversal destination gets created).
func TestSharesRestoreRefusesDestinationOutsideLiveRoots(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
outside := filepath.Join(env.tmp, "kivul", "gonosz")
traversal := filepath.Join(env.drives["hdd_1"], "..", "eszkeipel")
mf := SharesManifest{
Version: sharesManifestVersion, ServerName: "FELHOM",
Shares: []settings.SMBShare{
{Name: "kivul", Path: outside, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
{Name: "traverz", Path: traversal, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
},
}
seedSharesScratch(t, env, mf, map[string]string{
filepath.Join(outside, "x.txt"): "SHOULD-NEVER-LAND",
filepath.Join(traversal, "y.txt"): "SHOULD-NEVER-LAND",
})
var passdbCalls int
wirePlaceSeams(env, &passdbCalls)
res, err := env.m.PlaceSharesRestore(t.Context())
if err != nil {
t.Fatalf("a refused destination must be reported, not error out: %v", err)
}
if len(res.Refused) != 2 {
t.Errorf("both out-of-root destinations must be refused, got %+v", res)
}
if res.FilesRestored != 0 {
t.Errorf("zero files must be written when every destination is refused, got %d", res.FilesRestored)
}
if _, sErr := os.Stat(filepath.Join(outside, "x.txt")); !os.IsNotExist(sErr) {
t.Error("PLACE GUARD BREACHED — a file landed outside every registered live root")
}
if _, sErr := os.Stat(filepath.Join(env.tmp, "eszkeipel", "y.txt")); !os.IsNotExist(sErr) {
t.Error("PLACE GUARD BREACHED — a traversal destination was written")
}
// The definitions must not be registered either — a share pointing outside is not restorable.
if len(env.sett.GetSMBShares()) != 0 {
t.Errorf("refused shares must not enter the registry: %+v", env.sett.GetSMBShares())
}
}
// The prefix assert's boundary cases, stated directly.
func TestLiveShareRootOKBoundaries(t *testing.T) {
env := newSharesEnv(t, "hdd_1")
root := env.drives["hdd_1"]
if !env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) {
t.Error("a strict descendant of a live root must be allowed")
}
if env.m.liveShareRootOK(root) {
t.Error("the drive root ITSELF must be refused (a share is never the whole drive)")
}
if env.m.liveShareRootOK(filepath.Join(root, "..", "elsewhere")) {
t.Error("a `..` segment must be refused")
}
if env.m.liveShareRootOK("") {
t.Error("an empty destination must be refused")
}
// A drive that is away is not a LIVE root.
if err := env.sett.SetDisconnected(root, true, nil); err != nil {
t.Fatal(err)
}
if env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) {
t.Error("a disconnected drive must not count as a live root")
}
}
// A definitions-only snapshot (the quota-degraded shape) restores the CONFIGURATION without error
// even though no file tree exists — that is the whole point of the manifest-only floor.
func TestSharesRestoreDefinitionsOnlySnapshot(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
sharePath := filepath.Join(env.drives["hdd_1"], "dokumentumok")
mf := SharesManifest{
Version: sharesManifestVersion, ServerName: "FELHOM",
Shares: []settings.SMBShare{{Name: "dokumentumok", Path: sharePath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}},
}
seedSharesScratch(t, env, mf, nil) // no file tree at all
var passdbCalls int
env.m.SetSharesReconciler(func() error { return nil })
wirePlaceSeams(env, &passdbCalls)
res, err := env.m.PlaceSharesRestore(t.Context())
if err != nil {
t.Fatalf("a definitions-only snapshot must restore cleanly: %v", err)
}
if !containsStr(res.DefinitionsAdded, "dokumentumok") {
t.Errorf("the definition should be restored: %+v", res)
}
if res.FilesRestored != 0 {
t.Errorf("no files exist in a definitions-only snapshot, got %d", res.FilesRestored)
}
}
// The flash message must speak Hungarian and never leak the reserved key.
func TestSharesRestoreFlashMessage(t *testing.T) {
res := SharesRestoreResult{FilesRestored: 3, DefinitionsAdded: []string{"a"}, DefinitionsKept: []string{"marad"}}
msg := res.FlashMessage()
if strings.Contains(msg, SharesPseudoStack) {
t.Errorf("the reserved key leaked into the flash message: %q", msg)
}
if !strings.Contains(msg, SharesDisplayName) {
t.Errorf("the flash message should name %q: %q", SharesDisplayName, msg)
}
if !strings.Contains(msg, "A(z) marad megosztás beállítása már létezik — a meglévő maradt.") {
t.Errorf("the kept-definition sentence is missing: %q", msg)
}
}
// jsonMarshalIndent is a tiny indirection so the test file needs no direct encoding/json import
// beyond this one helper.
func jsonMarshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }
+455
View File
@@ -0,0 +1,455 @@
package backup
import (
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// --- shared harness -------------------------------------------------------------------------------
// sharesEnv is a Manager wired for the R-7b shares source: a real settings store, real temp drives,
// and FAKE copy/exec seams so nothing shells out to rsync, du, docker or restic.
type sharesEnv struct {
m *Manager
// mirrored records every tier2Mirror call as "src=>dst" in call order.
mirrored []string
// drives maps a label to the temp dir standing in for that registered drive.
drives map[string]string
sett *settings.Settings
tmp string
}
// newSharesEnv registers the named drives, turns sharing on, and installs the fake seams. Shares are
// added afterwards with addShare so each test states exactly the registry it needs.
func newSharesEnv(t *testing.T, driveNames ...string) *sharesEnv {
t.Helper()
tmp := t.TempDir()
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
env := &sharesEnv{drives: map[string]string{}, sett: sett, tmp: tmp}
for _, name := range driveNames {
p := filepath.Join(tmp, name)
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: name, Schedulable: true}); err != nil {
t.Fatal(err)
}
env.drives[name] = p
}
if err := sett.SetSMBEnabled(true); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = filepath.Join(tmp, "data")
sysDrive := filepath.Join(tmp, "sysdrive")
if err := os.MkdirAll(sysDrive, 0o755); err != nil {
t.Fatal(err)
}
cfg.Paths.SystemDataPath = sysDrive
env.m = &Manager{
cfg: cfg, settings: sett, logger: log.New(io.Discard, "", 0), systemDataPath: sysDrive,
// Fake mirror: records the call AND actually copies, so restore tests have real bytes.
tier2Mirror: func(src, dst string) error {
env.mirrored = append(env.mirrored, src+"=>"+dst)
return copyTreeForTest(src, dst)
},
sharesPassdbCapture: func() ([]byte, error) { return []byte("FAKE-PASSDB-TAR"), nil },
}
return env
}
// addShare registers a share, creating its folder with one file so it is "available".
func (e *sharesEnv) addShare(t *testing.T, drive, name string, offsite bool) string {
t.Helper()
p := filepath.Join(e.drives[drive], name)
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(p, name+".txt"), []byte("content-of-"+name), 0o644); err != nil {
t.Fatal(err)
}
if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
t.Fatal(err)
}
return p
}
// copyTreeForTest is a minimal recursive copy standing in for rsync in the mirror seam.
func copyTreeForTest(src, dst string) error {
fi, err := os.Stat(src)
if err != nil {
return err
}
if !fi.IsDir() {
b, rErr := os.ReadFile(src)
if rErr != nil {
return rErr
}
if mErr := os.MkdirAll(filepath.Dir(dst), 0o755); mErr != nil {
return mErr
}
return os.WriteFile(dst, b, 0o644)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
if err := copyTreeForTest(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
return err
}
}
return nil
}
// mirroredDsts returns just the destination side of every recorded mirror call.
func (e *sharesEnv) mirroredDsts() []string {
var out []string
for _, s := range e.mirrored {
out = append(out, s[strings.Index(s, "=>")+2:])
}
return out
}
// --- Part 1: payload ------------------------------------------------------------------------------
// The manifest must carry the share DEFINITIONS verbatim — that is the whole point of the payload:
// after a DR rebuild the customer gets their files AND their „Megosztás" page back, not an empty one.
func TestSharesPayloadManifestMatchesRegistry(t *testing.T) {
env := newSharesEnv(t, "hdd_1")
env.addShare(t, "hdd_1", "dokumentumok", true)
env.addShare(t, "hdd_1", "filmek", false)
dir, passdbOK, err := env.m.buildSharesPayload()
if err != nil {
t.Fatalf("buildSharesPayload: %v", err)
}
if !passdbOK {
t.Fatal("expected the (faked) passdb capture to succeed")
}
blob, err := os.ReadFile(filepath.Join(dir, sharesManifestName))
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var mf SharesManifest
if err := json.Unmarshal(blob, &mf); err != nil {
t.Fatalf("manifest is not valid JSON: %v", err)
}
if mf.Version != sharesManifestVersion {
t.Errorf("manifest version = %d, want %d", mf.Version, sharesManifestVersion)
}
if mf.ServerName != settings.DefaultSMBServerName {
t.Errorf("server name = %q, want %q", mf.ServerName, settings.DefaultSMBServerName)
}
want := env.sett.GetSMBShares()
sort.Slice(want, func(i, j int) bool { return want[i].Name < want[j].Name })
if len(mf.Shares) != len(want) {
t.Fatalf("manifest has %d shares, registry has %d", len(mf.Shares), len(want))
}
for i := range want {
if mf.Shares[i] != want[i] {
t.Errorf("share %d: manifest %+v != registry %+v", i, mf.Shares[i], want[i])
}
}
// The credential copy must be present and 0600 — it is secret-bearing.
st, err := os.Stat(filepath.Join(dir, sharesPassdbName))
if err != nil {
t.Fatalf("passdb archive missing: %v", err)
}
if runtimeIsPOSIX() && st.Mode().Perm() != 0o600 {
t.Errorf("passdb archive mode = %o, want 600 (secret-bearing)", st.Mode().Perm())
}
}
// Determinism: an unchanged registry must marshal to IDENTICAL bytes regardless of the order shares
// were added in, so a no-op nightly run gives the tier-2 mirror nothing to rewrite.
func TestSharesPayloadManifestIsDeterministic(t *testing.T) {
// Built from the registry only (no filesystem), so the two runs differ ONLY in insertion order —
// otherwise the temp-dir paths baked into each share would make any comparison meaningless.
read := func(order []string) []byte {
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
for _, n := range order {
if err := sett.AddSMBShare(settings.SMBShare{
Name: n, Path: "/mnt/hdd_1/" + n, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
}
m := &Manager{settings: sett, logger: log.New(io.Discard, "", 0)}
b, err := json.MarshalIndent(m.buildSharesManifest(), "", " ")
if err != nil {
t.Fatal(err)
}
return b
}
a := read([]string{"alpha", "zulu", "mike"})
b := read([]string{"zulu", "mike", "alpha"})
if string(a) != string(b) {
t.Errorf("manifest is order-dependent:\n%s\n---\n%s", a, b)
}
}
// Scenario: samba container down. The payload must still be produced (manifest-only) and must NOT
// error — re-setting the SMB password is cheap; losing the definitions is not.
func TestSharesPayloadPassdbAbsentIsManifestOnly(t *testing.T) {
env := newSharesEnv(t, "hdd_1")
env.addShare(t, "hdd_1", "dokumentumok", true)
env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist }
dir, passdbOK, err := env.m.buildSharesPayload()
if err != nil {
t.Fatalf("a down container must not fail the payload: %v", err)
}
if passdbOK {
t.Error("passdbOK must be false when the capture failed and no prior copy exists")
}
if _, err := os.Stat(filepath.Join(dir, sharesManifestName)); err != nil {
t.Errorf("manifest must still be written: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, sharesPassdbName)); !os.IsNotExist(err) {
t.Error("no passdb archive should exist when the capture failed and none was staged before")
}
}
// A previously captured credential copy must SURVIVE a later failed capture — a stale credential is
// strictly better for DR than none, and the manifest carries no password of its own to contradict it.
func TestSharesPayloadKeepsPriorPassdbOnCaptureFailure(t *testing.T) {
env := newSharesEnv(t, "hdd_1")
env.addShare(t, "hdd_1", "dokumentumok", true)
dir, _, err := env.m.buildSharesPayload()
if err != nil {
t.Fatal(err)
}
env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist }
_, passdbOK, err := env.m.buildSharesPayload()
if err != nil {
t.Fatal(err)
}
if !passdbOK {
t.Error("a previously staged passdb copy must keep passdbOK true")
}
b, err := os.ReadFile(filepath.Join(dir, sharesPassdbName))
if err != nil || string(b) != "FAKE-PASSDB-TAR" {
t.Errorf("prior passdb copy was lost: %q, %v", b, err)
}
}
// The reserved key must never reach a Hungarian surface raw.
func TestDisplayStackNameMapsReservedKey(t *testing.T) {
if got := DisplayStackName(SharesPseudoStack); got != SharesDisplayName {
t.Errorf("DisplayStackName(%q) = %q, want %q", SharesPseudoStack, got, SharesDisplayName)
}
if got := DisplayStackName("immich"); got != "immich" {
t.Errorf("DisplayStackName must be identity for ordinary stacks, got %q", got)
}
}
// --- Part 2: tier-2 shares job --------------------------------------------------------------------
// Scenario B: an OPTIONAL (Felhőmentés-off) share is tier-2'd like any other — the tier-2 tier is the
// local copy every share gets; only the offsite tier discriminates.
func TestSharesTier2MirrorsBothClasses(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
env.addShare(t, "hdd_1", "dokumentumok", true)
env.addShare(t, "hdd_1", "filmek", false)
if err := env.m.RunSharesTier2(); err != nil {
t.Fatalf("RunSharesTier2: %v", err)
}
key := sharesDriveKey(env.drives["hdd_1"])
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
for _, share := range []string{"dokumentumok", "filmek"} {
want := filepath.Join(destBase, key, share)
if _, err := os.Stat(filepath.Join(want, share+".txt")); err != nil {
t.Errorf("share %s was not mirrored to %s: %v", share, want, err)
}
}
// The payload rides the target so it is independently restorable.
if _, err := os.Stat(filepath.Join(destBase, sharesPayloadDestRel, sharesManifestName)); err != nil {
t.Errorf("payload manifest missing from the tier-2 target: %v", err)
}
// Marker LAST: its presence means every leg + reconcile completed.
b, err := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker))
if err != nil || string(b) != tier2LayoutVersion {
t.Errorf("layout marker missing/wrong: %q, %v", b, err)
}
// Status is recorded under the reserved key, and renders as „Megosztások".
cd := env.m.SharesTier2Status()
if cd == nil || cd.LastStatus != "ok" {
t.Fatalf("shares tier-2 status = %+v, want LastStatus=ok", cd)
}
}
// The target may NEVER be the leg's own source drive — that would be a same-disk copy pretending to
// be a second backup. With shares on two drives each group must land on the other one.
func TestSharesTier2NeverTargetsItsOwnSourceDrive(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
env.addShare(t, "hdd_1", "egy", true)
env.addShare(t, "hdd_2", "ketto", true)
if err := env.m.RunSharesTier2(); err != nil {
t.Fatalf("RunSharesTier2: %v", err)
}
for _, call := range env.mirrored {
src, dst, _ := strings.Cut(call, "=>")
if strings.HasPrefix(src, env.drives["hdd_1"]) && strings.HasPrefix(dst, env.drives["hdd_1"]) {
t.Errorf("leg mirrored onto its own source drive: %s", call)
}
if strings.HasPrefix(src, env.drives["hdd_2"]) && strings.HasPrefix(dst, env.drives["hdd_2"]) {
t.Errorf("leg mirrored onto its own source drive: %s", call)
}
}
}
// Scenario F: a share whose drive is marked away is skipped LOUDLY and its path reaches NO mirror
// argument — mirroring a dead mountpoint would copy an empty dir over a good backup.
func TestSharesTier2SkipsDeadMountAndContinues(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
env.addShare(t, "hdd_1", "elo", true)
deadPath := env.addShare(t, "hdd_3", "halott", true)
if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatalf("the run must continue for healthy shares: %v", err)
}
for _, call := range env.mirrored {
if strings.Contains(call, deadPath) {
t.Errorf("dead-mount share reached a mirror call: %s", call)
}
}
var sawLive bool
for _, dst := range env.mirroredDsts() {
if strings.HasSuffix(dst, string(filepath.Separator)+"elo") {
sawLive = true
}
}
if !sawLive {
t.Error("the healthy share must still be mirrored")
}
}
// Zero shares / sharing disabled: a clean no-op — no dest tree, no status record, no empty dirs.
func TestSharesTier2ZeroSharesIsCleanNoOp(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
if err := env.m.RunSharesTier2(); err != nil {
t.Fatalf("RunSharesTier2: %v", err)
}
if len(env.mirrored) != 0 {
t.Errorf("expected no mirror calls, got %v", env.mirrored)
}
if cd := env.m.SharesTier2Status(); cd != nil {
t.Errorf("expected no status record for a feature nobody uses, got %+v", cd)
}
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
if _, err := os.Stat(destBase); !os.IsNotExist(err) {
t.Errorf("expected no _shares dest tree, stat err = %v", err)
}
}
// A share removed from the registry must stop occupying the secondary drive within ONE run — the
// reconcile pass prunes it, reusing the same destBase-bounded removal guard the app path uses.
func TestSharesTier2ReconcilePrunesRemovedShare(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
env.addShare(t, "hdd_1", "marad", true)
env.addShare(t, "hdd_1", "torolt", true)
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
key := sharesDriveKey(env.drives["hdd_1"])
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
stale := filepath.Join(destBase, key, "torolt")
if _, err := os.Stat(stale); err != nil {
t.Fatalf("precondition: %s should exist after the first run: %v", stale, err)
}
if err := env.sett.RemoveSMBShare("torolt"); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("removed share's dest dir was not pruned: stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(destBase, key, "marad")); err != nil {
t.Errorf("surviving share was pruned by mistake: %v", err)
}
}
// The tier-2 half of the B isolation proof: the shares job must write ONLY under
// backups/secondary/_shares. A per-app dest tree standing beside it must come out byte-for-byte
// untouched — same contents, same bytes — and no mirror call may target it. Red-proof: point the
// shares destBase at backups/secondary/<share> (dropping the _shares segment) and this fails.
func TestSharesTier2LeavesPerAppTreeUntouched(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
env.addShare(t, "hdd_1", "dokumentumok", true)
// A pre-existing per-app tier-2 dest with a sentinel payload.
appDest := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", "immich")
if err := os.MkdirAll(filepath.Join(appDest, "recovery-unit"), 0o755); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(appDest, "recovery-unit", "manifest.json")
if err := os.WriteFile(sentinel, []byte(`{"app":"immich"}`), 0o644); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(sentinel)
if err != nil || string(b) != `{"app":"immich"}` {
t.Errorf("B INVARIANT VIOLATED — the per-app tier-2 tree was modified: %q, %v", b, err)
}
for _, call := range env.mirrored {
if strings.Contains(call, appDest) {
t.Errorf("B INVARIANT VIOLATED — a shares mirror targeted the per-app dest: %s", call)
}
}
// And everything it DID write lives under the reserved subtree.
sharesRoot := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
for _, dst := range env.mirroredDsts() {
if !strings.HasPrefix(dst, sharesRoot+string(filepath.Separator)) {
t.Errorf("shares job wrote outside its reserved subtree: %s", dst)
}
}
}
// Drive keys must be collision-free across drives that share a basename.
func TestSharesDriveKeyIsCollisionFree(t *testing.T) {
if sharesDriveKey("/mnt/a/data") == sharesDriveKey("/mnt/b/data") {
t.Error("drives with the same basename must not map to the same dest key")
}
for _, bad := range []string{"/", "\x00", " "} {
if k := sharesDriveKey(bad); strings.ContainsAny(k, `/\`) {
t.Errorf("sharesDriveKey(%q) = %q — must be a single safe path segment", bad, k)
}
}
}
// runtimeIsPOSIX reports whether file-mode assertions are meaningful on this host (Windows reports
// synthesised permissions, so mode checks there are noise rather than signal).
func runtimeIsPOSIX() bool { return os.PathSeparator == '/' }
+33 -1
View File
@@ -86,6 +86,17 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool {
// silently-wrong-owner restore). fullSize sizes the real-drive path; stateOnlySize sizes the SSD path. // silently-wrong-owner restore). fullSize sizes the real-drive path; stateOnlySize sizes the SSD path.
func (m *Manager) selectTier2Target(stackName string, fullSize, stateOnlySize int64) (*Tier2Target, error) { func (m *Manager) selectTier2Target(stackName string, fullSize, stateOnlySize int64) (*Tier2Target, error) {
sourceDrive := m.GetAppDrivePath(stackName) sourceDrive := m.GetAppDrivePath(stackName)
if sourceDrive == "" {
return nil, fmt.Errorf("no source drive for %s", stackName)
}
return m.selectTier2TargetFrom(stackName, sourceDrive, fullSize, stateOnlySize)
}
// selectTier2TargetFrom is selectTier2Target with the source drive supplied EXPLICITLY. It exists so
// the R-7b shares source — whose "source drive" is the drive a group of shares lives on, not an app's
// GetAppDrivePath — can reuse this selection and its headroom math verbatim instead of forking it.
// The app path above is a thin wrapper; nothing about its behaviour changed.
func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, stateOnlySize int64) (*Tier2Target, error) {
if sourceDrive == "" { if sourceDrive == "" {
return nil, fmt.Errorf("no source drive for %s", stackName) return nil, fmt.Errorf("no source drive for %s", stackName)
} }
@@ -232,6 +243,13 @@ func classifyTier2Rel(dirRel string, legRels []string) tier2RelClass {
// descendant of any current leg relpath (§7-D — the deferred-pruning answer: a bind removed/re-classed // descendant of any current leg relpath (§7-D — the deferred-pruning answer: a bind removed/re-classed
// stops occupying the secondary drive within one run). Runs strictly inside destBase. // stops occupying the secondary drive within one run). Runs strictly inside destBase.
func (m *Manager) tier2Reconcile(destBase string, legRels []string) { func (m *Manager) tier2Reconcile(destBase string, legRels []string) {
m.tier2ReconcileRoots(destBase, []string{"hdd", "userdata"}, legRels)
}
// tier2ReconcileRoots is tier2Reconcile with the top-level dest roots supplied explicitly — a pure
// extraction so the R-7b shares dest (whose roots are per-source-drive keys, not hdd/userdata) can
// reuse the SAME staleness classification and the SAME destBase-bounded removal guard.
func (m *Manager) tier2ReconcileRoots(destBase string, roots, legRels []string) {
var walk func(dirAbs, dirRel string) var walk func(dirAbs, dirRel string)
walk = func(dirAbs, dirRel string) { walk = func(dirAbs, dirRel string) {
entries, err := os.ReadDir(dirAbs) entries, err := os.ReadDir(dirAbs)
@@ -258,7 +276,7 @@ func (m *Manager) tier2Reconcile(destBase string, legRels []string) {
} }
} }
} }
for _, root := range []string{"hdd", "userdata"} { for _, root := range roots {
walk(filepath.Join(destBase, root), root) walk(filepath.Join(destBase, root), root)
} }
} }
@@ -397,6 +415,14 @@ func (m *Manager) RunAllTier2() {
} }
var n int var n int
for _, stack := range m.stackProvider.ListDeployedStacks() { for _, stack := range m.stackProvider.ListDeployedStacks() {
// Reserved-name defense in depth (R-7b): the shares source owns backups/secondary/_shares and
// the _shares status record. Stack names come from the git-synced catalog, not customer input,
// so this cannot realistically fire — but if it ever did, the app would silently overwrite the
// shares tree, so it is refused loudly instead.
if stack.Name == SharesPseudoStack {
m.logger.Printf("[ERROR] [backup] Tier 2: stack %q uses the RESERVED shares key — skipped to protect the shares backup tree", stack.Name)
continue
}
// F6 (CAMPAIGN-3): volume-only apps (no HDD_PATH, backups on sys_drive) previously got NO // F6 (CAMPAIGN-3): volume-only apps (no HDD_PATH, backups on sys_drive) previously got NO
// tier-2 copy — a single controller-level copy on one device. They now flow through too: their // tier-2 copy — a single controller-level copy on one device. They now flow through too: their
// recovery unit (which holds the db/volume dumps) gets a cross-drive second copy like any HDD // recovery unit (which holds the db/volume dumps) gets a cross-drive second copy like any HDD
@@ -416,6 +442,12 @@ func (m *Manager) RunAllTier2() {
n++ n++
} }
m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d app(s) processed (incl. volume-only — F6)", n) m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d app(s) processed (incl. volume-only — F6)", n)
// R-7b: the SHARES source runs after the per-app loop, in the SAME orchestrator run. It is a
// sibling job — nothing above it changed — and its failure never fails the app tier.
if err := m.RunSharesTier2(); err != nil {
m.logger.Printf("[WARN] [backup] Tier 2 shares job failed: %v", err)
}
} }
// --- per-app config-panel view (drives the Tier-2 "Beállítás" page) --- // --- per-app config-panel view (drives the Tier-2 "Beállítás" page) ---
+291
View File
@@ -0,0 +1,291 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// Tier-2 shares job — R-7b Part 2, the local cross-drive leg of Model B.
//
// It runs AFTER the per-stack tier-2 loop, inside the SAME orchestrator run, and it is a SIBLING of
// that loop, never a modification of it: RunTier2 and its per-app dest tree are untouched (the B
// invariant, enforced by TestSharesTier2LeavesPerAppTreeUntouched). What it reuses instead is every
// primitive the per-app path proved: the tier2Mirror seam, selectTier2TargetFrom's target choice and
// headroom math, tier2ReconcileRoots' staleness pruning, tier2SafeRemove's destBase-bounded removal,
// the marker-written-LAST discipline, and the recordTier2* status helpers.
//
// Layout — backups/secondary/_shares/ on the target drive:
//
// .felhom-tier2-layout marker, content "2", written LAST (after every leg + reconcile)
// _payload/ the share definitions + credential copy (shares_payload.go)
// <sourceDriveKey>/<share>/ one mirrored leg per share, grouped by the drive it came from
//
// Shares are grouped BY SOURCE DRIVE because a household's shares can span several drives and each
// group needs its own cross-drive target: a leg's target may never be that leg's own source drive.
// sharesPayloadDestRel is the payload's relpath inside the shares dest (a reserved root name; the
// leading underscore cannot collide with a drive key because drive keys are derived from paths).
const sharesPayloadDestRel = "_payload"
// sharesDriveKey turns an absolute source-drive path into ONE safe dest path segment. The full path
// is encoded (not just its basename) so two drives whose mountpoints share a basename — /mnt/a/data
// and /mnt/b/data — can never map onto the same dest subtree and silently overwrite each other.
// Deterministic and stable across runs, which is what lets the reconcile pass recognise its own dirs.
func sharesDriveKey(drive string) string {
s := strings.Trim(filepath.ToSlash(drive), "/")
if s == "" {
return "root"
}
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
// shareSourceDrive resolves the registered storage root a share's folder lives under. Returns "" when
// the share sits under no registered root — such a share has no meaningful "other drive" and is
// skipped with a warning rather than guessed at.
func (m *Manager) shareSourceDrive(path string) string {
if m.settings == nil {
return ""
}
p := filepath.ToSlash(path)
best := ""
for _, sp := range m.settings.GetStoragePaths() {
root := filepath.ToSlash(sp.Path)
if root == "" {
continue
}
// Longest match wins, so a nested registered root claims its own shares. Containment is
// compared in slash form, but the returned value is the REGISTRY'S OWN string: the target
// selector compares the source drive against sp.Path by equality, and handing it a normalised
// variant would make that comparison miss and let a group target its own source drive.
if (p == root || strings.HasPrefix(p, root+"/")) && len(root) > len(best) {
best = sp.Path
}
}
return best
}
// sharesTier2Group is one source drive's worth of shares plus the target chosen for it.
type sharesTier2Group struct {
sourceDrive string
key string
shares []classifiedShare
}
// RunSharesTier2 mirrors every classified, available share to a cross-drive target and stages the
// payload beside it. Best-effort and idempotent, exactly like RunTier2: an absent target is an
// honest recorded status, not an error. Returns the first hard copy error.
func (m *Manager) RunSharesTier2() error {
if !m.sharesEnabled() {
// Feature off or no shares registered: a clean no-op. Deliberately NOT a recorded status —
// writing one would make the „Megosztás" page claim a backup tier for a feature in use by
// nobody, and would create the dest tree for zero shares.
return nil
}
if m.settings != nil {
if cd := m.settings.GetCrossDriveConfig(SharesPseudoStack); cd != nil && cd.UserDisabled {
m.logger.Printf("[INFO] [shares] tier-2 skipped — disabled by customer")
return nil
}
}
shares := m.classifiedShares()
if len(shares) == 0 {
// Every registered share is on a drive that is away / a folder that vanished. That is a real
// operational state the customer must see, not silence.
m.recordTier2NoTarget(SharesPseudoStack, "egyetlen megosztás mappája sem érhető el — ellenőrizd a meghajtókat")
m.logger.Printf("[WARN] [shares] tier-2: no available share folders — nothing mirrored")
return nil
}
// Group by source drive (deterministic order so logs and dest trees are reproducible).
byDrive := map[string]*sharesTier2Group{}
var warns []string
for _, sh := range shares {
drive := m.shareSourceDrive(sh.Path)
if drive == "" {
m.logger.Printf("[WARN] [shares] tier-2: share %s is not under a registered storage root — skipped", sh.Name)
warns = append(warns, fmt.Sprintf("A(z) „%s” megosztás nem regisztrált adatmeghajtón van — kimaradt a 2. mentésből.", sh.Name))
continue
}
g := byDrive[drive]
if g == nil {
g = &sharesTier2Group{sourceDrive: drive, key: sharesDriveKey(drive)}
byDrive[drive] = g
}
g.shares = append(g.shares, sh)
}
var groups []*sharesTier2Group
for _, g := range byDrive {
groups = append(groups, g)
}
sort.Slice(groups, func(i, j int) bool { return groups[i].sourceDrive < groups[j].sourceDrive })
if len(groups) == 0 {
m.recordTier2NoTarget(SharesPseudoStack, "a megosztások egyike sincs regisztrált adatmeghajtón")
return nil
}
// Payload once per run — both tiers read the same staged directory.
payloadDir, _, perr := m.buildSharesPayload()
if perr != nil {
m.logger.Printf("[WARN] [shares] tier-2: payload staging failed — mirroring files without the definition manifest: %v", perr)
warns = append(warns, "A megosztás-beállítások mentése nem sikerült — a fájlok mentése megtörtént.")
payloadDir = ""
}
mirror := m.tier2Mirror
if mirror == nil {
mirror = rsyncMirror
}
start := time.Now()
var (
totalSize int64
lastTarget *Tier2Target
noTargetWhy []string
mirrored int
)
for _, g := range groups {
// Two sizes, mirroring RunTier2's contract: full = every share in the group; state-only = the
// MANDATORY (Felhőmentés-on) subset, which is what the SSD headroom guard must fit.
var fullSize, stateOnlySize int64
for _, sh := range g.shares {
sz := dirSizeBytes(sh.Path)
fullSize += sz
if sh.mandatory {
stateOnlySize += sz
}
}
target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize)
if err != nil {
why := tier2NoTargetReason(err)
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why))
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why)
continue
}
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
if system.SamePhysicalDevice(g.sourceDrive, target.NamespaceRoot) {
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
continue
}
legs := g.shares
if target.StateOnly {
kept := legs[:0]
dropped := false
for _, sh := range legs {
if sh.mandatory {
kept = append(kept, sh)
} else {
dropped = true
}
}
legs = kept
if dropped {
warns = append(warns, "A belső SSD-re csak a felhőmentésre jelölt megosztások férnek el — a többi nem került másolásra.")
}
}
destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", SharesPseudoStack)
legRels := make([]string, 0, len(legs)+1)
for _, sh := range legs {
rel := g.key + "/" + sh.Name
if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil {
m.recordTier2Failure(SharesPseudoStack, target, err)
if m.tier2Notify != nil {
m.tier2Notify(SharesPseudoStack, target.Label, time.Since(start), err)
}
return fmt.Errorf("tier2 shares mirror %s: %w", sh.Name, err)
}
legRels = append(legRels, rel)
totalSize += dirSizeBytes(sh.Path)
mirrored++
}
// The payload rides every target so each one is independently restorable.
if payloadDir != "" {
if err := mirror(payloadDir, filepath.Join(destBase, sharesPayloadDestRel)); err != nil {
m.logger.Printf("[WARN] [shares] tier-2: payload mirror to %s failed (files are copied): %v", destBase, err)
} else {
legRels = append(legRels, sharesPayloadDestRel)
}
}
// Prune dest dirs no share covers any more (a share deleted or renamed since the last run
// stops occupying the secondary drive within one run), then the marker LAST — a half-written
// dest therefore has no marker and the restore path refuses it until the next good run.
m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels)
if err := m.writeTier2Marker(destBase); err != nil {
m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err)
}
lastTarget = target
m.logger.Printf("[INFO] [shares] tier-2 copied %d share(s) from %s → %s (%s)",
len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize))
}
dur := time.Since(start)
if lastTarget == nil {
reason := strings.Join(noTargetWhy, "; ")
if reason == "" {
reason = "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
}
m.recordTier2NoTarget(SharesPseudoStack, reason)
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for any share group — %s", reason)
return nil
}
if len(noTargetWhy) > 0 {
warns = append(warns, "Néhány meghajtón lévő megosztásnak nincs másodlagos célja: "+strings.Join(noTargetWhy, "; "))
}
m.recordTier2Success(SharesPseudoStack, lastTarget, totalSize, strings.Join(warns, " "), dur)
if m.tier2Notify != nil {
m.tier2Notify(SharesPseudoStack, lastTarget.Label, dur, nil)
}
m.logger.Printf("[INFO] [shares] tier-2 run complete: %d share leg(s), %s, %s",
mirrored, humanizeBytes(totalSize), dur.Round(time.Second))
return nil
}
// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may
// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that
// is no longer registered still gets visited (and pruned) instead of lingering forever.
func sharesTier2DestRoots(destBase string) []string {
entries, err := os.ReadDir(destBase)
if err != nil {
return nil
}
var roots []string
for _, e := range entries {
if e.IsDir() {
roots = append(roots, e.Name())
}
}
sort.Strings(roots)
return roots
}
// writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its
// presence means "every leg and the reconcile completed".
func (m *Manager) writeTier2Marker(destBase string) error {
return os.WriteFile(filepath.Join(destBase, tier2LayoutMarker), []byte(tier2LayoutVersion), 0o644)
}
// SharesTier2Status returns the recorded shares tier-2 status for the „Megosztás" page (nil when the
// job has never run). It is the SAME CrossDriveBackup record the per-app rows use, keyed by the
// reserved pseudo-stack.
func (m *Manager) SharesTier2Status() *settings.CrossDriveBackup {
if m.settings == nil {
return nil
}
return m.settings.GetCrossDriveConfig(SharesPseudoStack)
}
+25 -10
View File
@@ -30,6 +30,21 @@ type SambaData struct {
// SambaHouseholdUser is the single household SMB account name (matches the entrypoint's unix user). // SambaHouseholdUser is the single household SMB account name (matches the entrypoint's unix user).
const SambaHouseholdUser = "felhom" const SambaHouseholdUser = "felhom"
// SambaContainerName is the fixed container name the compose render pins. It is THE single source of
// truth for it: the renderer below interpolates this constant, stacks.sambaContainer aliases it for
// the exec paths, monitor's effective-protected set watches it (R-7b liveness), and the backup
// package execs it for the passdb capture/restore. Anything that needs "which container is samba"
// reads this — never a second string literal.
const SambaContainerName = "felhom-samba"
// SambaPassdbVolume / SambaPassdbMount name the docker volume that holds the household SMB credential
// (the passdb tdb set) and where the container mounts it. R-7b's payload capture/restore targets this
// mount point, so it is pinned here beside the render that creates it rather than duplicated there.
const (
SambaPassdbVolume = "samba-passdb"
SambaPassdbMount = "/var/lib/samba"
)
// RenderSambaConfig renders smb.conf: the hardened global block (bind interfaces only = lo eth0, // RenderSambaConfig renders smb.conf: the hardened global block (bind interfaces only = lo eth0,
// SMB2+ floor, NetBIOS on for flat-name resolution) plus one [section] per share. Deterministic: // SMB2+ floor, NetBIOS on for flat-name resolution) plus one [section] per share. Deterministic:
// shares are emitted in the given order (the caller preserves registry order). force user/group pin // shares are emitted in the given order (the caller preserves registry order). force user/group pin
@@ -95,21 +110,21 @@ func RenderSambaCompose(d SambaData) string {
# receive the LAN multicast that WSD/mDNS discovery needs. # receive the LAN multicast that WSD/mDNS discovery needs.
services: services:
felhom-samba: %[1]s:
image: %s image: %[2]s
container_name: felhom-samba container_name: %[1]s
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
environment: environment:
- FELHOM_SERVER_NAME=%s - FELHOM_SERVER_NAME=%[3]s
- FELHOM_IFACE=eth0 - FELHOM_IFACE=eth0
- FELHOM_UID=%d - FELHOM_UID=%[4]d
- FELHOM_GID=%d - FELHOM_GID=%[4]d
volumes: volumes:
- ./smb.conf:/etc/samba/smb.conf:ro - ./smb.conf:/etc/samba/smb.conf:ro
- samba-passdb:/var/lib/samba - %[5]s:%[6]s
%s %[7]s
volumes: volumes:
samba-passdb: %[5]s:
`, SambaImage, d.ServerName, d.UID, d.UID, binds.String()) `, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String())
} }
@@ -4,6 +4,8 @@ import (
"testing" "testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
) )
func contains(ss []string, want string) bool { func contains(ss []string, want string) bool {
@@ -21,7 +23,7 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) {
base := config.StacksConfig{Protected: []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"}} base := config.StacksConfig{Protected: []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"}}
cfgNoTok := &config.Config{Stacks: base} cfgNoTok := &config.Config{Stacks: base}
got := EffectiveProtected(cfgNoTok) got := EffectiveProtected(cfgNoTok, settings.SMBSettings{})
if contains(got, "cloudflared") { if contains(got, "cloudflared") {
t.Errorf("cloudflared must be dropped when no tunnel token: %v", got) t.Errorf("cloudflared must be dropped when no tunnel token: %v", got)
} }
@@ -33,7 +35,33 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) {
cfgTok := &config.Config{Stacks: base} cfgTok := &config.Config{Stacks: base}
cfgTok.Infrastructure.CFTunnelToken = "tok" cfgTok.Infrastructure.CFTunnelToken = "tok"
if !contains(EffectiveProtected(cfgTok), "cloudflared") { if !contains(EffectiveProtected(cfgTok, settings.SMBSettings{}), "cloudflared") {
t.Error("cloudflared must remain protected when a tunnel token is configured") t.Error("cloudflared must remain protected when a tunnel token is configured")
} }
} }
// R-7b Scenario E, BOTH directions. Sharing is a customer-toggled feature, so the samba container can
// never be in the golden controller.yaml — the effective set must add it dynamically when sharing is
// ON (so a dead sharing service raises the same protected-container issue as a dead traefik) and must
// leave it out when sharing is OFF (so a box that never enabled it never reports a missing container).
// Red-proof: delete the `if smb.Enabled` append and the enabled case fails.
func TestEffectiveProtectedTracksSharingToggle(t *testing.T) {
cfg := &config.Config{Stacks: config.StacksConfig{Protected: []string{"traefik", "felhom-controller"}}}
on := EffectiveProtected(cfg, settings.SMBSettings{Enabled: true})
if !contains(on, infra.SambaContainerName) {
t.Errorf("sharing ON: %q must be watched, got %v", infra.SambaContainerName, on)
}
off := EffectiveProtected(cfg, settings.SMBSettings{Enabled: false})
if contains(off, infra.SambaContainerName) {
t.Errorf("sharing OFF: %q must NOT be watched, got %v", infra.SambaContainerName, off)
}
// The base set is untouched in both directions.
for _, set := range [][]string{on, off} {
for _, must := range []string{"traefik", "felhom-controller"} {
if !contains(set, must) {
t.Errorf("%s must remain protected: %v", must, set)
}
}
}
}
+26 -8
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "gitea.dooplex.hu/admin/felhom-controller/internal/system"
) )
@@ -23,7 +24,7 @@ type HealthReport struct {
} }
// RunHealthCheck runs system checks and returns a diagnostic report. // RunHealthCheck runs system checks and returns a diagnostic report.
func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, logger *log.Logger) *HealthReport { func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, smb settings.SMBSettings, logger *log.Logger) *HealthReport {
report := &HealthReport{ report := &HealthReport{
Status: "ok", Status: "ok",
Timestamp: time.Now(), Timestamp: time.Now(),
@@ -159,7 +160,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
// 6. Protected containers (effective set: cloudflared only counts when a tunnel token is // 6. Protected containers (effective set: cloudflared only counts when a tunnel token is
// configured, so a LAN-only node doesn't report FAIL forever for a stack it intentionally skips). // configured, so a LAN-only node doesn't report FAIL forever for a stack it intentionally skips).
protected := EffectiveProtected(cfg) protected := EffectiveProtected(cfg, smb)
if debug { if debug {
logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected) logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected)
} }
@@ -245,18 +246,35 @@ func checkDocker() error {
} }
// EffectiveProtected returns the protected-container set that actually applies to this node. It is // EffectiveProtected returns the protected-container set that actually applies to this node. It is
// the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here: // the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here, plus
// cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs // the DYNAMIC extras whose deployment depends on customer state rather than config:
// without it, so it must not be reported as a missing protected container forever). The bring-up //
// (stacks.EnsureBaseStack) applies the same cloudflared condition, so detection and deployment agree. // - cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs
func EffectiveProtected(cfg *config.Config) []string { // without it, so it must not be reported as a missing protected container forever);
out := make([]string, 0, len(cfg.Stacks.Protected)) // - the samba container is ADDED when network sharing is switched on (R-7b). Sharing is a
// customer-toggled feature, so it can never appear in the golden controller.yaml — but once it
// IS on, a dead sharing service is exactly as customer-visible as a dead traefik and must raise
// the same protected-container issue → alert → Hungarian degradation e-mail. When sharing is
// off the container is absent from the set, so a box that never enabled it stays quiet.
//
// The bring-up applies the same conditions (stacks.EnsureBaseStack for cloudflared, ensureSamba's
// `if !smb.Enabled { return }` for samba), so detection and deployment agree in both directions.
//
// NOTE: the entries are CONTAINER names (checkProtectedContainers docker-inspects them). For the
// base stacks the container name happens to equal the stack name; for samba it does NOT — the stack
// is „samba" but the container is infra.SambaContainerName — which is why the constant is read here
// rather than the stack name assumed.
func EffectiveProtected(cfg *config.Config, smb settings.SMBSettings) []string {
out := make([]string, 0, len(cfg.Stacks.Protected)+1)
for _, name := range cfg.Stacks.Protected { for _, name := range cfg.Stacks.Protected {
if name == "cloudflared" && cfg.Infrastructure.CFTunnelToken == "" { if name == "cloudflared" && cfg.Infrastructure.CFTunnelToken == "" {
continue continue
} }
out = append(out, name) out = append(out, name)
} }
if smb.Enabled {
out = append(out, infra.SambaContainerName)
}
return out return out
} }
+2 -1
View File
@@ -29,6 +29,7 @@ func BuildReport(
version string, version string,
storagePaths []settings.StoragePath, storagePaths []settings.StoragePath,
geoRestriction *settings.GeoRestriction, geoRestriction *settings.GeoRestriction,
smb settings.SMBSettings,
logger *log.Logger, logger *log.Logger,
) *Report { ) *Report {
debug := cfg.Logging.Level == "debug" debug := cfg.Logging.Level == "debug"
@@ -136,7 +137,7 @@ func BuildReport(
r.Backup = buildBackupReport(cfg, backupMgr) r.Backup = buildBackupReport(cfg, backupMgr)
// Health // Health
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths, logger) healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths, smb, logger)
r.Health = HealthReport{ r.Health = HealthReport{
Status: healthReport.Status, Status: healthReport.Status,
Issues: healthReport.Issues, Issues: healthReport.Issues,
+8
View File
@@ -165,6 +165,14 @@ type OffboxTarget struct {
// OK run (sorted; empty clears). Drives the per-app "config+DB only" note on /backups/remote and // OK run (sorted; empty clears). Drives the per-app "config+DB only" note on /backups/remote and
// the edge-triggered enlarge-blocked notification. Not a secret (app-name list). // the edge-triggered enlarge-blocked notification. Not a secret (app-name list).
EnlargedBlocked []string `json:"enlarged_blocked,omitempty"` EnlargedBlocked []string `json:"enlarged_blocked,omitempty"`
// Shares offsite leg status (R-7b). The offsite run gained a SIBLING shares source that pushes the
// „Felhőmentés"-marked SMB shares plus the share-definition manifest under the reserved `_shares`
// tag. These three fields let the „Megosztás" page state per-tier truth instead of inferring it
// from the app-wide LastStatus. SharesLastCount is the number of share folders in the push (0 = a
// definitions-only push, e.g. quota-degraded or no share is marked for the cloud). Not secrets.
SharesLastRun string `json:"shares_last_run,omitempty"` // RFC3339
SharesLastStatus string `json:"shares_last_status,omitempty"` // "ok" | "error" | "blocked" | "skipped"
SharesLastCount int `json:"shares_last_count,omitempty"`
// EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending" // EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending"
// |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run // |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run
// proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable // proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable
+9
View File
@@ -66,6 +66,15 @@ func ValidateSMBShareName(name string) error {
if strings.ContainsAny(name, `/\.` ) { if strings.ContainsAny(name, `/\.` ) {
return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot") return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot")
} }
// RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares"
// — a restic tag, a tier-2 dest root and a status record. nbNameRe below starts with [A-Za-z0-9_],
// so before this guard „_shares" was an ACCEPTED share name and the underscore namespace was not in
// fact reserved (the R-7b task's assumption to the contrary was verified false here). Reserving the
// whole leading-underscore space keeps future system keys collision-free too. Validation runs on
// ADD only, so an already-registered share is never invalidated retroactively.
if strings.HasPrefix(name, "_") {
return fmt.Errorf("a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva")
}
if !nbNameRe.MatchString(name) { if !nbNameRe.MatchString(name) {
return fmt.Errorf("a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat") return fmt.Errorf("a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat")
} }
+3 -2
View File
@@ -22,8 +22,9 @@ import (
const ( const (
// SambaStackName is the stack directory name under StacksDir (and the protected-stack key). // SambaStackName is the stack directory name under StacksDir (and the protected-stack key).
SambaStackName = "samba" SambaStackName = "samba"
// sambaContainer is the fixed container name (set in the generated compose). // sambaContainer is the fixed container name. Aliased from the RENDERER's constant so the compose
sambaContainer = "felhom-samba" // this package writes and the execs it runs can never name different containers.
sambaContainer = infra.SambaContainerName
// sambaUID is the household uid/gid every SMB write is forced to, so apps (group 1000) and both // sambaUID is the household uid/gid every SMB write is forced to, so apps (group 1000) and both
// backup tiers see consistent ownership. // backup tiers see consistent ownership.
sambaUID = 1000 sambaUID = 1000
+10 -6
View File
@@ -18,12 +18,16 @@ import (
// //
// The smb.conf / passdb mounts are deliberately NOT emitted — they are config, not customer data. // The smb.conf / passdb mounts are deliberately NOT emitted — they are config, not customer data.
// //
// SCOPE NOTE (the reported design fork, see REPORT.md): making this seam correct does NOT by itself // EXECUTION (R-7b, v0.145.0 — the gap this note used to describe is CLOSED). Share data now reaches
// put share data into a live tier-2/offsite run. Both engines are structured around a per-app // BOTH live tiers, but not through this seam: the engines remain structured around a per-app recovery
// RECOVERY UNIT — backup.RunTier2 short-circuits on `os.Stat(unitDir)` before it ever calls // unit (backup.RunTier2 still short-circuits on `os.Stat(unitDir)`; the offsite runner still
// GetStackClassifiedBinds, and the offsite runner enumerates settings.GetOffboxApps(). A share-only // enumerates settings.GetOffboxApps()), and Model B deliberately left those paths byte-identical.
// infra stack has neither, and teaching them about one is more than an enumeration tweak, so per the // Instead the backup package runs a SIBLING shares source off the SAME registry this file reads —
// task's STOP clause it was reported rather than improvised inside the engines. // backup.RunSharesTier2 and backup.runOffboxSharesLeg — applying the identical rule below.
//
// So this function and the shares sources agree by construction on WHAT each share's class is, while
// the sources own WHERE it goes. Keep the rule here in sync with internal/backup/shares_payload.go's
// classifiedShares if it ever changes.
func (m *Manager) sambaClassifiedBinds() ([]appbackup.ClassifiedBind, bool) { func (m *Manager) sambaClassifiedBinds() ([]appbackup.ClassifiedBind, bool) {
if m.settings == nil { if m.settings == nil {
return nil, false return nil, false
+1 -1
View File
@@ -256,7 +256,7 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) {
} }
// Health // Health
healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.logger) healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.settings.GetSMBSettings(), s.logger)
dump["health"] = map[string]interface{}{ dump["health"] = map[string]interface{}{
"status": healthReport.Status, "status": healthReport.Status,
"issues": healthReport.Issues, "issues": healthReport.Issues,
+8
View File
@@ -815,6 +815,14 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
data["OffboxScratchReady"] = ready data["OffboxScratchReady"] = ready
// R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it
// gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it
// appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong).
if s.backupMgr != nil {
data["SharesRestoreOffered"] = s.settings != nil && len(s.settings.GetSMBShares()) > 0
data["SharesScratchReady"] = s.backupMgr.SharesScratchReady()
data["SharesDisplayName"] = backup.SharesDisplayName
}
s.executeTemplate(w, r, "backups_restore", data) s.executeTemplate(w, r, "backups_restore", data)
} }
@@ -355,3 +355,64 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
}() }()
offboxRedirectTo(w, r, "/backups/restore", "A helyreállítás elindult — az állapot itt frissül.", false) offboxRedirectTo(w, r, "/backups/restore", "A helyreállítás elindult — az állapot itt frissül.", false)
} }
// --- R-7b: „Megosztások" restore ------------------------------------------------------------------
//
// A SIBLING of the per-app restore pair above, not a special case of it: the shares source has no
// recovery unit and no per-app toggle, so it gets its own two-step flow (restore to scratch, then a
// deliberate place-to-live). The display name is always „Megosztások" — the reserved `_shares` key
// never reaches a customer-facing surface.
// sharesRestoreHandler restores the latest shares snapshot into an on-data-drive scratch dir
// (POST /backup/shares/restore). Non-destructive: nothing live is touched until the place action.
func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
return
}
s.backupMgr.BeginRestoreOp("shares-restore", backup.SharesDisplayName)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := s.backupMgr.RestoreSharesScratch(ctx); err != nil {
s.logger.Printf("[ERROR] [web] shares restore (async): %v", err)
s.backupMgr.EndRestoreOp(false, "A megosztások visszaállítása sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] shares restore completed (async)")
s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.")
}()
offboxRedirectTo(w, r, "/backups/restore", "A megosztások visszaállítása elindult — az állapot itt frissül.", false)
}
// sharesPlaceHandler merges a completed shares scratch into the live share folders, re-adds the
// missing definitions and restores the household credential (POST /backup/shares/place).
func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
return
}
s.backupMgr.BeginRestoreOp("shares-place", backup.SharesDisplayName)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
res, err := s.backupMgr.PlaceSharesRestore(ctx)
if err != nil {
s.logger.Printf("[ERROR] [web] shares place (async): %v", err)
s.backupMgr.EndRestoreOp(false, "A megosztások helyreállítása sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)",
res.FilesRestored, len(res.DefinitionsAdded))
s.backupMgr.EndRestoreOp(true, res.FlashMessage())
}()
offboxRedirectTo(w, r, "/backups/restore", "A megosztások helyreállítása elindult — az állapot itt frissül.", false)
}
+5
View File
@@ -418,6 +418,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.offboxRestoreHandler(w, r) s.offboxRestoreHandler(w, r)
case path == "/backup/offbox/place" && r.Method == http.MethodPost: case path == "/backup/offbox/place" && r.Method == http.MethodPost:
s.offboxPlaceHandler(w, r) s.offboxPlaceHandler(w, r)
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
s.sharesRestoreHandler(w, r)
case path == "/backup/shares/place" && r.Method == http.MethodPost:
s.sharesPlaceHandler(w, r)
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow. // Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
case path == "/backup/escrow" && r.Method == http.MethodGet: case path == "/backup/escrow" && r.Method == http.MethodGet:
s.escrowWizardPageHandler(w, r) s.escrowWizardPageHandler(w, r)
@@ -154,6 +154,26 @@ func (s *Server) sharingPageData() map[string]interface{} {
roots = append(roots, map[string]string{"Path": sp.Path, "Label": label}) roots = append(roots, map[string]string{"Path": sp.Path, "Label": label})
} }
data["StorageRoots"] = roots data["StorageRoots"] = roots
// R-7b: per-tier backup truth. Until R-7b the „Felhőmentés" toggle promised a protection the
// engines did not deliver; these two lines are what makes the promise checkable by the customer
// rather than taken on faith. Amber ONLY on deviation — a green tier says nothing at all beyond
// its timestamp, so the page stays quiet when everything is fine.
if s.backupMgr != nil {
if cd := s.backupMgr.SharesTier2Status(); cd != nil {
data["SharesTier2Status"] = cd.LastStatus
data["SharesTier2LastRun"] = cd.LastRun
data["SharesTier2Warning"] = cd.LastWarning
data["SharesTier2Error"] = cd.LastError
data["SharesTier2Dest"] = cd.DestinationPath
}
if lastRun, status, count, ok := s.backupMgr.SharesOffsiteStatus(); ok {
data["SharesOffsiteStatus"] = status
data["SharesOffsiteLastRun"] = lastRun
data["SharesOffsiteCount"] = count
}
data["SharesRestoreReady"] = s.backupMgr.SharesScratchReady()
}
return data return data
} }
@@ -102,6 +102,25 @@
{{else}} {{else}}
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — a kijelölés a <a href="/backups/remote">Távoli mentés</a> oldalon történik.</p> <p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — a kijelölés a <a href="/backups/remote">Távoli mentés</a> oldalon történik.</p>
{{end}} {{end}}
<!-- R-7b: the shares source. Not an app row — it has no per-app toggle and no recovery unit —
so it is its own entry. The reserved `_shares` key never appears here; the label always
comes from the display mapping. -->
{{if .SharesRestoreOffered}}
<div class="app-row-list" style="margin-top:1rem">
{{template "app_list_row" dict "Slug" "" "Name" .SharesDisplayName}}
<form method="POST" action="/backup/shares/restore" style="display:inline">{{$.CSRFField}}
<button type="submit" class="btn btn-xs btn-outline">Megosztások visszaállítása</button>
</form>
{{if .SharesScratchReady}}
<form method="POST" action="/backup/shares/place" style="display:inline">{{$.CSRFField}}
<button type="submit" class="btn btn-xs btn-outline">Helyreállítás az élő adatok közé (csak a hiányzó fájlok)</button>
</form>
<span class="form-hint" style="display:block;margin-top:.25rem">A meglévő fájlokat nem írja felül. A már létező megosztás-beállítások változatlanok maradnak.</span>
{{end}}
{{template "app_list_row_end"}}
</div>
{{end}}
</div> </div>
{{end}} {{end}}
@@ -127,6 +127,51 @@
A megosztás törlésekor <strong>a mappa és a fájlok megmaradnak</strong> — csak a hálózati A megosztás törlésekor <strong>a mappa és a fájlok megmaradnak</strong> — csak a hálózati
elérés szűnik meg. elérés szűnik meg.
</div> </div>
<div class="backup-section-card" style="margin-top:1rem">
<h3>A megosztások mentése</h3>
<p class="form-hint" style="margin:-0.25rem 0 0.75rem">
A megosztott mappák a többi adattal együtt mentésre kerülnek. A „Felhőmentés” bekapcsolva
azt jelenti, hogy a mappa a távoli tárhelyre is felkerül; kikapcsolva csak a második
meghajtóra készül másolat.
</p>
<ul class="plain-list">
<li>
<strong>2. mentés (másik meghajtó):</strong>
{{if .SharesTier2Status}}
{{if eq .SharesTier2Status "ok"}}
rendben{{if .SharesTier2LastRun}} — {{.SharesTier2LastRun}}{{end}}
{{if .SharesTier2Warning}}<span class="badge badge-warn">{{.SharesTier2Warning}}</span>{{end}}
{{else if eq .SharesTier2Status "no_target"}}
<span class="badge badge-warn">nincs cél — {{.SharesTier2Error}}</span>
{{else}}
<span class="badge badge-warn">hiba — {{.SharesTier2Error}}</span>
{{end}}
{{else}}
még nem futott
{{end}}
</li>
<li>
<strong>Távoli mentés (felhő):</strong>
{{if .SharesOffsiteStatus}}
{{if eq .SharesOffsiteStatus "ok"}}
rendben — {{.SharesOffsiteCount}} megosztás{{if .SharesOffsiteLastRun}}, {{.SharesOffsiteLastRun}}{{end}}
{{else if eq .SharesOffsiteStatus "blocked"}}
<span class="badge badge-warn">a tárhelykeret miatt csak a beállítások kerültek fel</span>
{{else if eq .SharesOffsiteStatus "skipped"}}
<span class="badge badge-warn">kimaradt</span>
{{else}}
<span class="badge badge-warn">hiba</span>
{{end}}
{{else}}
még nem futott
{{end}}
</li>
</ul>
<div class="form-hint">
Visszaállítani a <a href="/backups/restore">Visszaállítás</a> oldalon lehet.
</div>
</div>
{{else}} {{else}}
<div class="backup-table-empty">Még nincs megosztott mappa.</div> <div class="backup-table-empty">Még nincs megosztott mappa.</div>
{{end}} {{end}}