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
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ type Manager struct {
|
|||||||
// samba named volume (`docker exec -i … tar xf -`). Nil → the real defaultSharesPassdbRestore.
|
// samba named volume (`docker exec -i … tar xf -`). Nil → the real defaultSharesPassdbRestore.
|
||||||
sharesPassdbRestore func(tar []byte) error
|
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.
|
||||||
|
|||||||
@@ -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, "", " ") }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user