| new file mode 100644 |
| index 0000000..4c071d7 |
| --- /dev/null |
| +++ b/internal/provisioner/backup_linux.go |
| @@ -0,0 +1,261 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "archive/tar" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "filippo.io/age" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +const maxRegistryBytes = int64(maxRecords) * (128 << 10) |
| + |
| +type BackupInfo struct { |
| + Digest string `json:"archive_digest"` |
| + Sequence int64 `json:"sequence"` |
| +} |
| + |
| +func existingJournal(statePath, floorPath string, password []byte) (*journal, error) { |
| + if statePath == floorPath || strings.HasPrefix(statePath, floorPath+"/") || strings.HasPrefix(floorPath, statePath+"/") { |
| + return nil, ErrRefused |
| + } |
| + state, e := openPrivateDirectory(statePath) |
| + if e != nil { |
| + return nil, e |
| + } |
| + floorDir, e := openPrivateDirectory(floorPath) |
| + if e != nil { |
| + state.close() |
| + return nil, e |
| + } |
| + fail := func(e error) (*journal, error) { state.close(); floorDir.close(); return nil, e } |
| + wrapped, e := state.read("credential.age", 128<<10) |
| + if e != nil { |
| + return fail(e) |
| + } |
| + id, key, e := unwrapCredential(wrapped, password) |
| + if e != nil { |
| + return fail(e) |
| + } |
| + floor, e := newNativeFloor(floorDir, id) |
| + if e != nil { |
| + return fail(e) |
| + } |
| + j, e := openJournal(state, floor, key, false) |
| + if e != nil { |
| + return fail(e) |
| + } |
| + return j, nil |
| +} |
| +func closeExisting(j *journal) { j.close(); j.floor.dir.close(); j.dir.close() } |
| +func ownedFile(dir *privateDirectory, name string) (*os.File, error) { |
| + if !leaf(name) { |
| + return nil, ErrRefused |
| + } |
| + fd, e := unix.Openat(int(dir.file.Fd()), name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0600) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + return os.NewFile(uintptr(fd), name), nil |
| +} |
| + |
| +// Backup exports only encrypted state. The independent floor is never included. |
| +func Backup(statePath, floorPath, output string, password []byte) (BackupInfo, error) { |
| + if !filepath.IsAbs(output) || filepath.Clean(output) != output || strings.HasPrefix(output, statePath+"/") || strings.HasPrefix(output, floorPath+"/") { |
| + return BackupInfo{}, ErrRefused |
| + } |
| + destination, e := openPrivateDirectory(filepath.Dir(output)) |
| + if e != nil { |
| + return BackupInfo{}, e |
| + } |
| + defer destination.close() |
| + name := filepath.Base(output) |
| + if !leaf(name) || !leaf(name+".tmp") { |
| + return BackupInfo{}, ErrRefused |
| + } |
| + j, e := existingJournal(statePath, floorPath, password) |
| + if e != nil { |
| + return BackupInfo{}, e |
| + } |
| + defer closeExisting(j) |
| + j.mu.Lock() |
| + defer j.mu.Unlock() |
| + point, _, e := j.floor.current() |
| + if e != nil { |
| + return BackupInfo{}, e |
| + } |
| + snapshot, e := ownedFile(j.dir, "backup.snapshot") |
| + if e != nil { |
| + return BackupInfo{}, e |
| + } |
| + snapshot.Close() |
| + defer unix.Unlinkat(int(j.dir.file.Fd()), "backup.snapshot", 0) |
| + snapshotPath := filepath.Join(j.dir.file.Name(), "backup.snapshot") |
| + if _, e = j.db.Exec("VACUUM INTO ?", snapshotPath); e != nil { |
| + return BackupInfo{}, ErrUnavailable |
| + } |
| + dbFile, e := os.Open(snapshotPath) |
| + if e != nil { |
| + return BackupInfo{}, ErrUnavailable |
| + } |
| + defer dbFile.Close() |
| + info, e := dbFile.Stat() |
| + if e != nil || info.Size() > maxRegistryBytes { |
| + return BackupInfo{}, ErrRefused |
| + } |
| + wrapped, e := j.dir.read("credential.age", 128<<10) |
| + if e != nil { |
| + return BackupInfo{}, e |
| + } |
| + genesis, e := j.dir.read("initializing.json", 8192) |
| + if e != nil { |
| + return BackupInfo{}, e |
| + } |
| + outputFile, e := ownedFile(destination, name+".tmp") |
| + if e != nil { |
| + return BackupInfo{}, e |
| + } |
| + defer outputFile.Close() |
| + defer unix.Unlinkat(int(destination.file.Fd()), name+".tmp", 0) |
| + recipient, e := age.NewScryptRecipient(string(password)) |
| + if e != nil { |
| + return BackupInfo{}, ErrRefused |
| + } |
| + digest := sha256.New() |
| + encrypted, e := age.Encrypt(io.MultiWriter(outputFile, digest), recipient) |
| + if e != nil { |
| + return BackupInfo{}, ErrUnavailable |
| + } |
| + archive := tar.NewWriter(encrypted) |
| + for _, entry := range []struct { |
| + name string |
| + size int64 |
| + reader io.Reader |
| + }{{"registry.db", info.Size(), dbFile}, {"credential.age", int64(len(wrapped)), strings.NewReader(string(wrapped))}, {"initializing.json", int64(len(genesis)), strings.NewReader(string(genesis))}} { |
| + if archive.WriteHeader(&tar.Header{Name: entry.name, Mode: 0600, Size: entry.size, Typeflag: tar.TypeReg}) != nil { |
| + return BackupInfo{}, ErrUnavailable |
| + } |
| + if n, e := io.Copy(archive, entry.reader); e != nil || n != entry.size { |
| + return BackupInfo{}, ErrUnavailable |
| + } |
| + } |
| + if archive.Close() != nil || encrypted.Close() != nil || outputFile.Sync() != nil || outputFile.Close() != nil { |
| + return BackupInfo{}, ErrUnavailable |
| + } |
| + if unix.Renameat2(int(destination.file.Fd()), name+".tmp", int(destination.file.Fd()), name, unix.RENAME_NOREPLACE) != nil || destination.file.Sync() != nil { |
| + return BackupInfo{}, ErrRefused |
| + } |
| + return BackupInfo{hex.EncodeToString(digest.Sum(nil)), point.Sequence}, nil |
| +} |
| + |
| +// Restore requires a separate empty destination and the surviving independent |
| +// floor. A failed validation never updates that floor or grants dispatch. |
| +func Restore(statePath, floorPath, input string, password []byte) (RegistryInfo, error) { |
| + if !filepath.IsAbs(input) || filepath.Clean(input) != input || len(password) == 0 || len(password) > 1024 || statePath == floorPath || strings.HasPrefix(statePath, floorPath+"/") || strings.HasPrefix(floorPath, statePath+"/") { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + state, e := openPrivateDirectory(statePath) |
| + if e != nil { |
| + return RegistryInfo{}, e |
| + } |
| + defer state.close() |
| + names, e := directoryNames(state) |
| + if e != nil || len(names) != 0 { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + lock, e := state.lock() |
| + if e != nil { |
| + return RegistryInfo{}, e |
| + } |
| + defer lock.Close() |
| + source, e := openPrivateDirectory(filepath.Dir(input)) |
| + if e != nil { |
| + return RegistryInfo{}, e |
| + } |
| + defer source.close() |
| + fd, e := unix.Openat(int(source.file.Fd()), filepath.Base(input), unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + f := os.NewFile(uintptr(fd), "private-backup") |
| + defer f.Close() |
| + info, e := f.Stat() |
| + if e != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || info.Size() > maxRegistryBytes+2<<20 { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + identity, e := age.NewScryptIdentity(string(password)) |
| + if e != nil { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + identity.SetMaxWorkFactor(18) |
| + decrypted, e := age.Decrypt(f, identity) |
| + if e != nil { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + archive := tar.NewReader(decrypted) |
| + seen := map[string]bool{} |
| + for { |
| + header, e := archive.Next() |
| + if e == io.EOF { |
| + break |
| + } |
| + if e != nil { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + limit := int64(0) |
| + switch header.Name { |
| + case "registry.db": |
| + limit = maxRegistryBytes |
| + case "credential.age": |
| + limit = 128 << 10 |
| + case "initializing.json": |
| + limit = 8192 |
| + default: |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + if seen[header.Name] || header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > limit || header.Mode != 0600 { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + seen[header.Name] = true |
| + output, e := ownedFile(state, ".restore-"+header.Name) |
| + if e != nil { |
| + return RegistryInfo{}, e |
| + } |
| + n, copyErr := io.Copy(output, archive) |
| + syncErr := output.Sync() |
| + closeErr := output.Close() |
| + if copyErr != nil || syncErr != nil || closeErr != nil || n != header.Size { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + } |
| + trailing, e := io.ReadAll(io.LimitReader(decrypted, 1)) |
| + if e != nil || len(trailing) != 0 || len(seen) != 3 || state.file.Sync() != nil { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + for _, name := range []string{"registry.db", "credential.age", "initializing.json"} { |
| + if unix.Renameat2(int(state.file.Fd()), ".restore-"+name, int(state.file.Fd()), name, unix.RENAME_NOREPLACE) != nil { |
| + return RegistryInfo{}, ErrRefused |
| + } |
| + } |
| + if state.file.Sync() != nil { |
| + return RegistryInfo{}, ErrUnavailable |
| + } |
| + lock.Close() |
| + j, e := existingJournal(statePath, floorPath, password) |
| + if e != nil { |
| + return RegistryInfo{}, e |
| + } |
| + defer closeExisting(j) |
| + point, _, e := j.floor.current() |
| + if e != nil { |
| + return RegistryInfo{}, e |
| + } |
| + return RegistryInfo{j.floor.identity.Registry, j.floor.identity.Device, j.floor.identity.Public, point.Sequence}, nil |
| +} |