c81df55dcb
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)
266 lines
12 KiB
Go
266 lines
12 KiB
Go
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) }
|