Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+515
View File
@@ -0,0 +1,515 @@
// 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)
}