516 lines
12 KiB
Go
516 lines
12 KiB
Go
// Package backup handles configuration archives. Callers must stop containers,
|
|
// exclude concurrent filesystem writers, and manage rollback across files.
|
|
package backup
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
MaxTotalBytes = 32 << 20
|
|
MaxFiles = 4096
|
|
maxArchiveBytes = 4*((MaxTotalBytes+2)/3) + 4096
|
|
magic = "DSHBACKUP\x01"
|
|
magicV2 = "DSHBACKUP\x02"
|
|
SchemaVersion = 2
|
|
maxManifestBytes = 4096
|
|
maxTarBytes = MaxTotalBytes + MaxFiles*4096 + maxManifestBytes
|
|
)
|
|
|
|
var allowed = [...]string{".credentials.yaml", "cordis.patch.yml", "profiles/web/package.json", "profiles/web/cordis.patch.yml", "profiles/web/pnpm-lock.yaml", "dsh-ssh.json"}
|
|
var resources = [...]string{"skins", "pets", "agent-presets", ".agent-presets"}
|
|
|
|
type Metadata struct {
|
|
DSHVersion string `json:"dsh_version"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type manifest struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Metadata
|
|
}
|
|
|
|
func allowedPath(name string, recursive bool) bool {
|
|
if name == "." || len(name) > 1024 || path.Clean(name) != name || strings.ContainsAny(name, "\\\x00:") || path.IsAbs(name) {
|
|
return false
|
|
}
|
|
for _, valid := range allowed {
|
|
if name == valid {
|
|
return true
|
|
}
|
|
}
|
|
if recursive {
|
|
for _, dir := range resources {
|
|
if strings.HasPrefix(name, dir+"/") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type payload struct {
|
|
Version int `json:"version"`
|
|
Files map[string][]byte `json:"files"`
|
|
}
|
|
|
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
|
b, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cipher.NewGCM(b)
|
|
}
|
|
|
|
func openHome(home string) (*os.Root, error) {
|
|
info, err := os.Lstat(home)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return nil, errors.New("home must not be a symlink")
|
|
}
|
|
return os.OpenRoot(home)
|
|
}
|
|
|
|
// os.Root permits in-root symlinks, so reject them explicitly in each component.
|
|
func checkPath(root *os.Root, name string) error {
|
|
parts := strings.Split(name, "/")
|
|
for i := range parts {
|
|
info, err := root.Lstat(strings.Join(parts[:i+1], "/"))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("symlink rejected: %s", name)
|
|
}
|
|
if i < len(parts)-1 && !info.IsDir() {
|
|
return fmt.Errorf("not a directory: %s", name)
|
|
}
|
|
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
|
return fmt.Errorf("not a regular file: %s", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readBounded(f *os.File, limit int64) ([]byte, error) {
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, errors.New("not a regular file")
|
|
}
|
|
if info.Size() > limit {
|
|
return nil, errors.New("backup size limit exceeded")
|
|
}
|
|
b, err := io.ReadAll(io.LimitReader(f, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(b)) > limit {
|
|
return nil, errors.New("backup size limit exceeded")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// Create writes a 0600 archive using a raw 16, 24, or 32 byte AES key.
|
|
// Missing allowlisted files are omitted; destination's parent must exist.
|
|
func Create(home, destination string, key []byte) error {
|
|
return CreateWithMetadata(home, destination, key, Metadata{})
|
|
}
|
|
|
|
// CreateWithMetadata writes an AES-GCM encrypted tar.gz with an authenticated
|
|
// manifest. Empty resource directories are omitted; callers must exclude writers.
|
|
func CreateWithMetadata(home, destination string, key []byte, metadata Metadata) error {
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root, err := openHome(home)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer root.Close()
|
|
p := payload{Version: SchemaVersion, Files: make(map[string][]byte)}
|
|
remaining := int64(MaxTotalBytes)
|
|
addFile := func(name string) error {
|
|
if !allowedPath(name, true) {
|
|
return fmt.Errorf("path not allowed: %q", name)
|
|
}
|
|
if err := checkPath(root, name); err != nil {
|
|
return err
|
|
}
|
|
f, err := root.Open(name)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, readErr := readBounded(f, remaining)
|
|
closeErr := f.Close()
|
|
if readErr != nil {
|
|
return fmt.Errorf("%s: %w", name, readErr)
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
remaining -= int64(len(data))
|
|
p.Files[name] = data
|
|
if len(p.Files) > MaxFiles {
|
|
return errors.New("backup file count limit exceeded")
|
|
}
|
|
return nil
|
|
}
|
|
for _, name := range allowed {
|
|
if err := addFile(name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
visited := 0
|
|
var walk func(string) error
|
|
walk = func(name string) error {
|
|
visited++
|
|
if visited > MaxFiles*2 || len(name) > 1024 {
|
|
return errors.New("resource traversal limit exceeded")
|
|
}
|
|
info, err := root.Lstat(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return addFile(name)
|
|
}
|
|
dir, err := root.Open(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dir.Close()
|
|
for {
|
|
entries, err := dir.ReadDir(1)
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if err := walk(name + "/" + entry.Name()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, name := range resources {
|
|
info, err := root.Lstat(name)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("resource root is not a directory: %s", name)
|
|
}
|
|
if err := walk(name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
plain, err := encodeV2(p.Files, metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return err
|
|
}
|
|
archive := append([]byte(magicV2), nonce...)
|
|
archive = gcm.Seal(archive, nonce, plain, []byte(magicV2))
|
|
if len(archive) > maxArchiveBytes {
|
|
return errors.New("backup archive size limit exceeded")
|
|
}
|
|
destRoot, err := os.OpenRoot(filepath.Dir(destination))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer destRoot.Close()
|
|
return replace(destRoot, filepath.Base(destination), archive)
|
|
}
|
|
|
|
// Restore authenticates and validates everything before changing home. Files
|
|
// absent from the archive remain untouched. Replacement is atomic per file only.
|
|
func Restore(archive, home string, key []byte) error {
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
aroot, err := os.OpenRoot(filepath.Dir(archive))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer aroot.Close()
|
|
if err := checkPath(aroot, filepath.Base(archive)); err != nil {
|
|
return err
|
|
}
|
|
f, err := aroot.Open(filepath.Base(archive))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, readErr := readBounded(f, maxArchiveBytes)
|
|
closeErr := f.Close()
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
if len(data) < len(magic)+gcm.NonceSize()+gcm.Overhead() || (!bytes.HasPrefix(data, []byte(magic)) && !bytes.HasPrefix(data, []byte(magicV2))) {
|
|
return errors.New("invalid backup format")
|
|
}
|
|
nonce := data[len(magic) : len(magic)+gcm.NonceSize()]
|
|
header := string(data[:len(magic)])
|
|
plain, err := gcm.Open(nil, nonce, data[len(magic)+gcm.NonceSize():], []byte(header))
|
|
if err != nil {
|
|
return fmt.Errorf("authenticate backup: %w", err)
|
|
}
|
|
var p payload
|
|
if header == magicV2 {
|
|
p.Files, err = decodeV2(plain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := decodeJSON(plain, &p); err != nil {
|
|
return err
|
|
}
|
|
if p.Version != 1 || p.Files == nil {
|
|
return errors.New("unsupported backup payload")
|
|
}
|
|
}
|
|
if len(p.Files) > MaxFiles {
|
|
return errors.New("backup file count limit exceeded")
|
|
}
|
|
total := 0
|
|
for name, data := range p.Files {
|
|
if !allowedPath(name, header == magicV2) {
|
|
return fmt.Errorf("path not allowed: %q", name)
|
|
}
|
|
for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) {
|
|
if _, exists := p.Files[parent]; exists {
|
|
return errors.New("conflicting backup paths")
|
|
}
|
|
}
|
|
total += len(data)
|
|
if total > MaxTotalBytes {
|
|
return errors.New("backup size limit exceeded")
|
|
}
|
|
}
|
|
root, err := openHome(home)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer root.Close()
|
|
for name := range p.Files {
|
|
if err := checkPath(root, name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for name, data := range p.Files {
|
|
if err := root.MkdirAll(filepath.Dir(name), 0700); err != nil {
|
|
return err
|
|
}
|
|
if err := replace(root, name, data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeJSON(data []byte, value any) error {
|
|
d := json.NewDecoder(bytes.NewReader(data))
|
|
d.DisallowUnknownFields()
|
|
if err := d.Decode(value); err != nil {
|
|
return err
|
|
}
|
|
if err := d.Decode(new(any)); err != io.EOF {
|
|
return errors.New("trailing backup data")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func encodeV2(files map[string][]byte, metadata Metadata) ([]byte, error) {
|
|
if metadata.CreatedAt.IsZero() {
|
|
metadata.CreatedAt = time.Now().UTC()
|
|
}
|
|
if metadata.DSHVersion == "" {
|
|
metadata.DSHVersion = "unknown"
|
|
}
|
|
data, err := json.Marshal(manifest{SchemaVersion, metadata})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > maxManifestBytes {
|
|
return nil, errors.New("manifest size limit exceeded")
|
|
}
|
|
var out bytes.Buffer
|
|
gz := gzip.NewWriter(&out)
|
|
tw := tar.NewWriter(gz)
|
|
write := func(name string, data []byte) error {
|
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data)), Typeflag: tar.TypeReg, Format: tar.FormatPAX}); err != nil {
|
|
return err
|
|
}
|
|
_, err := tw.Write(data)
|
|
return err
|
|
}
|
|
if err := write("manifest.json", data); err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(files))
|
|
for name := range files {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
if err := write(name, files[name]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func decodeV2(plain []byte) (map[string][]byte, error) {
|
|
source := bytes.NewReader(plain)
|
|
gz, err := gzip.NewReader(source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer gz.Close()
|
|
gz.Multistream(false)
|
|
unpacked, err := io.ReadAll(io.LimitReader(gz, maxTarBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(unpacked) > maxTarBytes {
|
|
return nil, errors.New("expanded archive size limit exceeded")
|
|
}
|
|
if source.Len() != 0 {
|
|
return nil, errors.New("trailing gzip data")
|
|
}
|
|
input := bytes.NewReader(unpacked)
|
|
tr := tar.NewReader(input)
|
|
files := make(map[string][]byte)
|
|
seenManifest := false
|
|
remaining := int64(MaxTotalBytes)
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if h.Typeflag != tar.TypeReg || h.Linkname != "" {
|
|
return nil, errors.New("non-regular archive entry")
|
|
}
|
|
limit := remaining
|
|
if h.Name == "manifest.json" {
|
|
if seenManifest {
|
|
return nil, errors.New("duplicate manifest")
|
|
}
|
|
limit = maxManifestBytes
|
|
} else {
|
|
if !allowedPath(h.Name, true) {
|
|
return nil, fmt.Errorf("path not allowed: %q", h.Name)
|
|
}
|
|
if _, exists := files[h.Name]; exists {
|
|
return nil, errors.New("duplicate archive entry")
|
|
}
|
|
if len(files) >= MaxFiles {
|
|
return nil, errors.New("backup file count limit exceeded")
|
|
}
|
|
}
|
|
if h.Size < 0 || h.Size > limit {
|
|
return nil, errors.New("backup size limit exceeded")
|
|
}
|
|
data, err := io.ReadAll(tr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if h.Name == "manifest.json" {
|
|
var m manifest
|
|
if err := decodeJSON(data, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
if m.SchemaVersion != SchemaVersion || m.CreatedAt.IsZero() || m.DSHVersion == "" {
|
|
return nil, errors.New("invalid manifest")
|
|
}
|
|
seenManifest = true
|
|
} else {
|
|
files[h.Name] = data
|
|
remaining -= int64(len(data))
|
|
}
|
|
}
|
|
if !seenManifest || input.Len() != 0 {
|
|
return nil, errors.New("missing manifest or trailing tar data")
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func replace(root *os.Root, name string, data []byte) error {
|
|
if err := checkPath(root, name); err != nil {
|
|
return err
|
|
}
|
|
var random [16]byte
|
|
if _, err := rand.Read(random[:]); err != nil {
|
|
return err
|
|
}
|
|
tmp := filepath.Join(filepath.Dir(name), ".backup-"+hex.EncodeToString(random[:]))
|
|
f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer root.Remove(tmp)
|
|
_, writeErr := f.Write(data)
|
|
if writeErr == nil {
|
|
writeErr = f.Sync()
|
|
}
|
|
closeErr := f.Close()
|
|
if writeErr != nil {
|
|
return writeErr
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
if err := checkPath(root, name); err != nil {
|
|
return err
|
|
}
|
|
return root.Rename(tmp, name)
|
|
}
|