f61ef2aa9e
internal/backup/backup.go: Backup (tar.gz + HMAC-SHA256 signature, excludes /run/orca + sockets + WAL/SHM), VerifySignature, Restore (signature verify + extract + Force flag). internal/cli/backup.go: orca backup --out + orca restore --in --force. Tests: round-trip, signature mismatch, exclusion, force-refuse, force-overwrite. ---ci--- project: orca phase: 04 milestone: v0.11 status: execute ---/ci---
350 lines
10 KiB
Go
350 lines
10 KiB
Go
// 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
|
|
}
|