Files
mrleo1nidandClaude Opus 5.5 1d44d067e9 backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:52:40 +03:00

225 lines
5.3 KiB
Go

package archive
import (
"archive/tar"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/klauspost/compress/zstd"
)
// Route отображает имя в архиве на путь назначения. ok=false — запись пропускается.
type Route func(name string) (dest string, ok bool)
type Extractor struct {
Route Route
Warnings []string
Files int
users map[string]int
groups map[string]int
dirs []dirMeta
}
type dirMeta struct {
path string
mode os.FileMode
mtime time.Time
}
// Extract распаковывает zstd-tar из r.
func (e *Extractor) Extract(r io.Reader) error {
zr, err := zstd.NewReader(r)
if err != nil {
return err
}
defer zr.Close()
tr := tar.NewReader(zr)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if strings.Contains(hdr.Name, "..") {
e.warn("пропущено подозрительное имя %q", hdr.Name)
continue
}
dest, ok := e.Route(hdr.Name)
if !ok {
continue
}
if err := e.extractOne(tr, hdr, filepath.Clean(dest)); err != nil {
return fmt.Errorf("%s: %w", hdr.Name, err)
}
}
// права и время каталогов — в конце, иначе запись файлов их перебьёт
for i := len(e.dirs) - 1; i >= 0; i-- {
d := e.dirs[i]
_ = os.Chmod(d.path, d.mode)
_ = os.Chtimes(d.path, d.mtime, d.mtime)
}
return nil
}
func (e *Extractor) warn(format string, a ...any) {
e.Warnings = append(e.Warnings, fmt.Sprintf(format, a...))
}
func (e *Extractor) extractOne(tr *tar.Reader, hdr *tar.Header, dest string) error {
mode := os.FileMode(hdr.Mode).Perm()
// tar хранит suid/sgid/sticky в битах c_ISUID/c_ISGID/c_ISVTX
if hdr.Mode&04000 != 0 {
mode |= os.ModeSetuid
}
if hdr.Mode&02000 != 0 {
mode |= os.ModeSetgid
}
if hdr.Mode&01000 != 0 {
mode |= os.ModeSticky
}
switch hdr.Typeflag {
case tar.TypeDir:
if fi, err := os.Lstat(dest); err == nil && !fi.IsDir() {
if err := os.Remove(dest); err != nil {
return err
}
}
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
e.chown(dest, hdr)
e.dirs = append(e.dirs, dirMeta{dest, mode, hdr.ModTime})
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
// пишем во временный файл и переименовываем: работает и для занятых бинарников
tmp := dest + ".bm-tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
e.chown(tmp, hdr)
if err := os.Chmod(tmp, mode); err != nil {
e.warn("%s: chmod: %v", dest, err)
}
_ = os.Chtimes(tmp, hdr.ModTime, hdr.ModTime)
if fi, err := os.Lstat(dest); err == nil && fi.IsDir() {
if err := os.RemoveAll(dest); err != nil {
os.Remove(tmp)
return err
}
}
if err := os.Rename(tmp, dest); err != nil {
os.Remove(tmp)
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if fi, err := os.Lstat(dest); err == nil {
if fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 {
if err := os.RemoveAll(dest); err != nil {
return err
}
} else if err := os.Remove(dest); err != nil {
return err
}
}
if err := os.Symlink(hdr.Linkname, dest); err != nil {
return err
}
e.chown(dest, hdr)
default:
return nil
}
e.Files++
return nil
}
// chown выставляет владельца по имени из архива (uid на новом сервере может отличаться),
// при отсутствии такого пользователя — по числовому id.
func (e *Extractor) chown(p string, hdr *tar.Header) {
if runtime.GOOS == "windows" {
return
}
uid := e.lookup(&e.users, hdr.Uname, hdr.Uid, func(n string) (string, error) {
u, err := user.Lookup(n)
if err != nil {
return "", err
}
return u.Uid, nil
})
gid := e.lookup(&e.groups, hdr.Gname, hdr.Gid, func(n string) (string, error) {
g, err := user.LookupGroup(n)
if err != nil {
return "", err
}
return g.Gid, nil
})
if err := os.Lchown(p, uid, gid); err != nil {
e.warn("%s: chown: %v", p, err)
}
}
func (e *Extractor) lookup(cache *map[string]int, name string, fallback int, find func(string) (string, error)) int {
if name == "" {
return fallback
}
if *cache == nil {
*cache = map[string]int{}
}
if id, ok := (*cache)[name]; ok {
return id
}
id := fallback
if s, err := find(name); err == nil {
if n, err := strconv.Atoi(s); err == nil {
id = n
}
}
(*cache)[name] = id
return id
}
// ReadManifest читает manifest.json из начала архива (без распаковки остального).
func ReadManifest(r io.Reader, v any) error {
zr, err := zstd.NewReader(r)
if err != nil {
return err
}
defer zr.Close()
tr := tar.NewReader(zr)
hdr, err := tr.Next()
if err != nil {
return err
}
if hdr.Name != "manifest.json" {
return fmt.Errorf("первая запись архива %q, ожидался manifest.json", hdr.Name)
}
return json.NewDecoder(tr).Decode(v)
}