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) }