// Package backup implements orca's signed tarball backup/restore // subsystem (P04, v0.11 milestone). // // The model is tar.gz + HMAC-SHA256 signature: // // - Backup walks the ORCA_HOME recursively, excludes ephemeral // paths (/run/orca/*), unix sockets (*.sock), and SQLite WAL/SHM // sidecars (*.db-wal, *.db-shm), packs the rest into a tar.gz, and // computes an HMAC-SHA256 of the tarball using the cluster master // key. The tarball is written to OutputPath; the hex-encoded // signature to OutputPath + ".sig". // - VerifySignature recomputes the HMAC and compares it (constant // time) against the recorded signature. // - Restore verifies the signature first (refuses on mismatch), then // extracts the tarball to TargetDir. With Force=false it refuses to // clobber a non-empty TargetDir; with Force=true it overwrites. // // The package never logs key material. slog calls carry only metadata. package backup import ( "archive/tar" "compress/gzip" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "strings" ) // ErrSignatureMismatch is returned when the recorded HMAC-SHA256 // signature does not match the recomputed one (tampering or wrong key). var ErrSignatureMismatch = errors.New("backup: signature mismatch") // ErrTargetNotEmpty is returned when Restore is called with Force=false // against a non-empty TargetDir. var ErrTargetNotEmpty = errors.New("backup: target directory not empty (use Force to overwrite)") // BackupOptions configures Backup. type BackupOptions struct { SourceDir string // ORCA_HOME — the tree to back up OutputPath string // destination tarball path (.tar.gz) MasterKey []byte // HMAC-SHA256 key (cluster master.key) } // RestoreOptions configures Restore. type RestoreOptions struct { InputPath string // source tarball path (.tar.gz) TargetDir string // destination ORCA_HOME MasterKey []byte // HMAC-SHA256 key (for verification) Force bool // overwrite non-empty target } // excludeGlobSuffixes are the suffixes excluded from the backup. We // exclude SQLite WAL/SHM sidecars (the main db is backed up) and unix // sockets. var excludeGlobSuffixes = []string{".sock", ".db-wal", ".db-shm"} // shouldExclude reports whether a path should be excluded from the // backup. It excludes /run/orca/* (ephemeral runtime), *.sock, *.db-wal, // and *.db-shm. The /run/orca match is done on the absolute path; the // suffix matches are done on the base name. func shouldExclude(absPath string) bool { clean := filepath.Clean(absPath) if strings.HasPrefix(clean, "/run/orca/") || clean == "/run/orca" { return true } if idx := strings.LastIndex(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca"+string(filepath.Separator)); idx >= 0 { return true } if strings.HasSuffix(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca") { return true } base := filepath.Base(clean) for _, suf := range excludeGlobSuffixes { if strings.HasSuffix(base, suf) { return true } } return false } // Backup creates a signed tar.gz of SourceDir. The tarball is written // to OutputPath and the hex-encoded HMAC-SHA256 signature to // OutputPath + ".sig". The write is atomic: the tarball is streamed to // a temp file in the same directory and renamed on success; the // signature is written after the rename so a crash never leaves a // tarball with a stale or missing signature. func Backup(opts BackupOptions) error { if opts.SourceDir == "" { return fmt.Errorf("backup: SourceDir is empty") } if opts.OutputPath == "" { return fmt.Errorf("backup: OutputPath is empty") } if len(opts.MasterKey) == 0 { return fmt.Errorf("backup: MasterKey is empty") } src, err := filepath.Abs(opts.SourceDir) if err != nil { return fmt.Errorf("backup: resolve SourceDir: %w", err) } info, err := os.Stat(src) if err != nil { return fmt.Errorf("backup: stat SourceDir: %w", err) } if !info.IsDir() { return fmt.Errorf("backup: SourceDir %q is not a directory", src) } outDir := filepath.Dir(opts.OutputPath) if err := os.MkdirAll(outDir, 0o755); err != nil { return fmt.Errorf("backup: mkdir output dir: %w", err) } tmp, err := os.CreateTemp(outDir, ".orca-backup-*.tar.gz.tmp") if err != nil { return fmt.Errorf("backup: create temp tarball: %w", err) } tmpPath := tmp.Name() defer func() { tmp.Close() _ = os.Remove(tmpPath) }() gw := gzip.NewWriter(tmp) tw := tar.NewWriter(gw) var walked int walkErr := filepath.Walk(src, func(path string, fi os.FileInfo, err error) error { if err != nil { return err } if shouldExclude(path) { if fi.IsDir() { return filepath.SkipDir } return nil } rel, rerr := filepath.Rel(src, path) if rerr != nil { return fmt.Errorf("rel path %s: %w", path, rerr) } if rel == "." { return nil } hdr, herr := tar.FileInfoHeader(fi, "") if herr != nil { return fmt.Errorf("tar header for %s: %w", path, herr) } hdr.Name = filepath.ToSlash(rel) if err := tw.WriteHeader(hdr); err != nil { return fmt.Errorf("write header %s: %w", rel, err) } if !fi.Mode().IsRegular() { return nil } f, oerr := os.Open(path) if oerr != nil { return fmt.Errorf("open %s: %w", path, oerr) } defer f.Close() if _, err := io.Copy(tw, f); err != nil { return fmt.Errorf("copy %s: %w", rel, err) } walked++ return nil }) if walkErr != nil { tw.Close() gw.Close() return fmt.Errorf("backup: walk: %w", walkErr) } if err := tw.Close(); err != nil { gw.Close() return fmt.Errorf("backup: close tar writer: %w", err) } if err := gw.Close(); err != nil { return fmt.Errorf("backup: close gzip writer: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("backup: close temp tarball: %w", err) } if err := os.Rename(tmpPath, opts.OutputPath); err != nil { return fmt.Errorf("backup: rename tarball: %w", err) } sig, err := computeSignature(opts.OutputPath, opts.MasterKey) if err != nil { return fmt.Errorf("backup: compute signature: %w", err) } if err := os.WriteFile(opts.OutputPath+".sig", []byte(hex.EncodeToString(sig)), 0o644); err != nil { return fmt.Errorf("backup: write signature: %w", err) } slog.Info("backup complete", "path", opts.OutputPath, "files", walked, "sig", opts.OutputPath+".sig") return nil } // computeSignature reads the file at path and returns its HMAC-SHA256 // MAC under key. func computeSignature(path string, key []byte) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) } defer f.Close() mac := hmac.New(sha256.New, key) if _, err := io.Copy(mac, f); err != nil { return nil, fmt.Errorf("hash %s: %w", path, err) } return mac.Sum(nil), nil } // VerifySignature recomputes the HMAC-SHA256 of the tarball at // tarballPath and compares it (constant time) against the hex-encoded // signature at sigPath. Returns ErrSignatureMismatch on a mismatch. func VerifySignature(tarballPath, sigPath string, masterKey []byte) error { if len(masterKey) == 0 { return fmt.Errorf("backup: MasterKey is empty") } got, err := computeSignature(tarballPath, masterKey) if err != nil { return fmt.Errorf("compute signature: %w", err) } wantHex, err := os.ReadFile(sigPath) if err != nil { return fmt.Errorf("read signature: %w", err) } want, err := hex.DecodeString(strings.TrimSpace(string(wantHex))) if err != nil { return fmt.Errorf("decode signature: %w", err) } if !hmac.Equal(got, want) { return ErrSignatureMismatch } return nil } // Restore verifies the signature on (InputPath, InputPath+".sig") using // MasterKey, then extracts the tarball to TargetDir. With Force=false // a non-empty TargetDir is refused (ErrTargetNotEmpty); with Force=true // existing files are overwritten. func Restore(opts RestoreOptions) error { if opts.InputPath == "" { return fmt.Errorf("restore: InputPath is empty") } if opts.TargetDir == "" { return fmt.Errorf("restore: TargetDir is empty") } if len(opts.MasterKey) == 0 { return fmt.Errorf("restore: MasterKey is empty") } sigPath := opts.InputPath + ".sig" if err := VerifySignature(opts.InputPath, sigPath, opts.MasterKey); err != nil { return fmt.Errorf("restore: verify signature: %w", err) } target := filepath.Clean(opts.TargetDir) if err := os.MkdirAll(target, 0o755); err != nil { return fmt.Errorf("restore: mkdir target: %w", err) } if !opts.Force { empty, err := dirIsEmpty(target) if err != nil { return fmt.Errorf("restore: check target: %w", err) } if !empty { return ErrTargetNotEmpty } } f, err := os.Open(opts.InputPath) if err != nil { return fmt.Errorf("restore: open tarball: %w", err) } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("restore: gzip reader: %w", err) } defer gz.Close() tr := tar.NewReader(gz) var extracted int for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return fmt.Errorf("restore: read tar entry: %w", err) } name := filepath.FromSlash(hdr.Name) if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "..") { return fmt.Errorf("restore: unsafe path %q", hdr.Name) } dest := filepath.Join(target, name) switch hdr.Typeflag { case tar.TypeDir: if err := os.MkdirAll(dest, os.FileMode(hdr.Mode)); err != nil { return fmt.Errorf("restore: mkdir %s: %w", name, err) } continue case tar.TypeSymlink: if err := os.Remove(dest); err != nil && !os.IsNotExist(err) { return fmt.Errorf("restore: clear symlink %s: %w", name, err) } if err := os.Symlink(hdr.Linkname, dest); err != nil { return fmt.Errorf("restore: symlink %s: %w", name, err) } continue case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return fmt.Errorf("restore: mkdir parent %s: %w", name, err) } out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) if err != nil { return fmt.Errorf("restore: create %s: %w", name, err) } if _, err := io.Copy(out, tr); err != nil { out.Close() return fmt.Errorf("restore: write %s: %w", name, err) } out.Close() extracted++ default: slog.Warn("restore: skipping non-regular entry", "name", name, "type", hdr.Typeflag) } } slog.Info("restore complete", "path", target, "files", extracted) return nil } // dirIsEmpty reports whether dir contains no entries. func dirIsEmpty(dir string) (bool, error) { entries, err := os.ReadDir(dir) if err != nil { return false, err } return len(entries) == 0, nil }