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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-06 21:52:40 +03:00
commit 1d44d067e9
53 files changed
+7278

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
package archive
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestMatcher(t *testing.T) {
m := NewMatcher([]string{"/opt/stoat/data/db", "/etc/x-ui/x-ui.db*", "*.swp", "/opt/p/pgdata/"})
cases := map[string]bool{
"/opt/stoat/data/db": true,
"/opt/stoat/data/db/WiredTiger": true,
"/opt/stoat/data/dbx": false,
"/etc/x-ui/x-ui.db": true,
"/etc/x-ui/x-ui.db-wal": true,
"/etc/x-ui/config.json": false,
"/var/scripts/.a.sh.swp": true,
"/opt/p/pgdata/base/1": true,
"/opt/p/pgdata2": false,
}
for p, want := range cases {
if got := m.Match(p); got != want {
t.Errorf("Match(%q) = %v, want %v", p, got, want)
}
}
}
func TestRoundtrip(t *testing.T) {
src := t.TempDir()
must(t, os.MkdirAll(filepath.Join(src, "a", "b"), 0o750))
must(t, os.WriteFile(filepath.Join(src, "a", "b", "f.txt"), []byte("hello"), 0o640))
must(t, os.WriteFile(filepath.Join(src, "a", "skip.swp"), []byte("x"), 0o644))
must(t, os.MkdirAll(filepath.Join(src, "a", "pgdata"), 0o700))
must(t, os.WriteFile(filepath.Join(src, "a", "pgdata", "PG_VERSION"), []byte("17"), 0o600))
symlinks := runtime.GOOS != "windows"
if symlinks {
must(t, os.Symlink("b/f.txt", filepath.Join(src, "a", "link")))
}
var buf bytes.Buffer
w, err := NewWriter(&buf)
must(t, err)
must(t, w.AddBytes("manifest.json", []byte(`{"domain":"x.example"}`), 0o644))
skip := NewMatcher([]string{"*.swp", filepath.ToSlash(filepath.Join(src, "a", "pgdata"))})
must(t, w.AddTree(filepath.Join(src, "a"), func(p string) string {
rel, _ := filepath.Rel(src, p)
return "cat/files/" + filepath.ToSlash(rel)
}, skip))
must(t, w.Close())
if len(w.Warnings) > 0 {
t.Fatalf("warnings: %v", w.Warnings)
}
var m struct{ Domain string }
must(t, ReadManifest(bytes.NewReader(buf.Bytes()), &m))
if m.Domain != "x.example" {
t.Fatalf("manifest domain = %q", m.Domain)
}
dst := t.TempDir()
e := &Extractor{Route: func(name string) (string, bool) {
rest, ok := strings.CutPrefix(name, "cat/files/")
if !ok {
return "", false
}
return filepath.Join(dst, filepath.FromSlash(rest)), true
}}
must(t, e.Extract(bytes.NewReader(buf.Bytes())))
data, err := os.ReadFile(filepath.Join(dst, "a", "b", "f.txt"))
must(t, err)
if string(data) != "hello" {
t.Fatalf("content = %q", data)
}
if _, err := os.Stat(filepath.Join(dst, "a", "skip.swp")); !os.IsNotExist(err) {
t.Fatalf("skip.swp должен быть исключён")
}
if _, err := os.Stat(filepath.Join(dst, "a", "pgdata")); !os.IsNotExist(err) {
t.Fatalf("pgdata должен быть исключён")
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(filepath.Join(dst, "a", "b", "f.txt"))
must(t, err)
if fi.Mode().Perm() != 0o640 {
t.Fatalf("mode = %v", fi.Mode().Perm())
}
fi, err = os.Stat(filepath.Join(dst, "a", "b"))
must(t, err)
if fi.Mode().Perm() != 0o750 {
t.Fatalf("dir mode = %v", fi.Mode().Perm())
}
}
if symlinks {
target, err := os.Readlink(filepath.Join(dst, "a", "link"))
must(t, err)
if target != "b/f.txt" {
t.Fatalf("symlink = %q", target)
}
}
// повторная распаковка поверх существующих файлов
e2 := &Extractor{Route: e.Route}
must(t, e2.Extract(bytes.NewReader(buf.Bytes())))
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
+71
View File
@@ -0,0 +1,71 @@
package archive
import (
"path"
"strings"
)
// Matcher проверяет абсолютные пути (со слешами) по списку исключений.
//
// - шаблон без glob-символов — префикс: "/opt/x/pgdata" исключает и сам каталог, и всё внутри;
// - шаблон с "/" и glob-символами — сопоставляется с полным путём ("/etc/x-ui/x-ui.db*");
// - шаблон без "/" — с именем файла ("*.swp").
type Matcher struct {
patterns []string
}
func NewMatcher(patterns ...[]string) *Matcher {
m := &Matcher{}
for _, ps := range patterns {
for _, p := range ps {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if p != "/" {
p = strings.TrimSuffix(p, "/")
}
m.patterns = append(m.patterns, p)
}
}
return m
}
func (m *Matcher) Add(p ...string) {
m.patterns = append(m.patterns, NewMatcher(p).patterns...)
}
func (m *Matcher) Match(abs string) bool {
if m == nil {
return false
}
for _, p := range m.patterns {
if !strings.ContainsAny(p, "*?[") {
if abs == p || strings.HasPrefix(abs, p+"/") {
return true
}
continue
}
if strings.Contains(p, "/") {
if ok, _ := path.Match(p, abs); ok {
return true
}
continue
}
if ok, _ := path.Match(p, path.Base(abs)); ok {
return true
}
}
return false
}
// Covers сообщает, попадает ли abs под один из путей (точное совпадение или вложенность).
func Covers(paths []string, abs string) bool {
for _, p := range paths {
p = strings.TrimSuffix(p, "/")
if abs == p || strings.HasPrefix(abs, p+"/") {
return true
}
}
return false
}
+224
View File
@@ -0,0 +1,224 @@
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)
}
+167
View File
@@ -0,0 +1,167 @@
// Package archive пишет и читает архивы бэкапа: tar (PAX) поверх zstd.
package archive
import (
"archive/tar"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/klauspost/compress/zstd"
)
type Writer struct {
zw *zstd.Encoder
tw *tar.Writer
seen map[string]bool
Files int
Bytes int64
Warnings []string
}
func NewWriter(w io.Writer) (*Writer, error) {
zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.SpeedDefault))
if err != nil {
return nil, err
}
return &Writer{zw: zw, tw: tar.NewWriter(zw), seen: map[string]bool{}}, nil
}
func (w *Writer) warn(format string, a ...any) {
w.Warnings = append(w.Warnings, fmt.Sprintf(format, a...))
}
// AddBytes пишет в архив файл из памяти.
func (w *Writer) AddBytes(name string, data []byte, mode int64) error {
hdr := &tar.Header{
Name: name,
Mode: mode,
Size: int64(len(data)),
ModTime: time.Now(),
Typeflag: tar.TypeReg,
Format: tar.FormatPAX,
}
if err := w.tw.WriteHeader(hdr); err != nil {
return err
}
_, err := w.tw.Write(data)
w.seen[name] = true
w.Files++
w.Bytes += hdr.Size
return err
}
// AddFiles добавляет абсолютный путь src (файл или каталог) под именами prefix+<путь без ведущего />.
func (w *Writer) AddFiles(prefix, src string, skip *Matcher) error {
return w.AddTree(src, func(p string) string {
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
}, skip)
}
// AddTree обходит src (не следуя симлинкам) и пишет каждую запись под именем name(path).
// Ошибки отдельных файлов (нет доступа, исчез во время обхода) становятся предупреждениями.
func (w *Writer) AddTree(src string, name func(p string) string, skip *Matcher) error {
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if errors.Is(err, fs.ErrNotExist) && p != src {
return nil
}
w.warn("%s: %v", p, err)
if d != nil && d.IsDir() {
return fs.SkipDir
}
return nil
}
if skip.Match(filepath.ToSlash(p)) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
return w.addEntry(p, name(p))
})
}
func (w *Writer) addEntry(p, name string) error {
fi, err := os.Lstat(p)
if err != nil {
w.warn("%s: %v", p, err)
return nil
}
mode := fi.Mode()
if mode&(fs.ModeSocket|fs.ModeDevice|fs.ModeCharDevice|fs.ModeNamedPipe|fs.ModeIrregular) != 0 {
return nil
}
if fi.IsDir() {
name = strings.TrimSuffix(name, "/") + "/"
}
if w.seen[name] {
return nil
}
var link string
if mode&fs.ModeSymlink != 0 {
if link, err = os.Readlink(p); err != nil {
w.warn("%s: %v", p, err)
return nil
}
}
hdr, err := tar.FileInfoHeader(fi, link)
if err != nil {
w.warn("%s: %v", p, err)
return nil
}
hdr.Name = name
hdr.Format = tar.FormatPAX
var f *os.File
if hdr.Typeflag == tar.TypeReg {
// открываем до заголовка: недоступный файл просто пропускаем
if f, err = os.Open(p); err != nil {
w.warn("%s: %v", p, err)
return nil
}
defer f.Close()
}
if err := w.tw.WriteHeader(hdr); err != nil {
return err
}
w.seen[name] = true
w.Files++
if f == nil {
return nil
}
n, err := io.CopyN(w.tw, f, hdr.Size)
w.Bytes += n
if err != nil {
if !errors.Is(err, io.EOF) {
return fmt.Errorf("%s: %w", p, err)
}
// файл уменьшился во время чтения — добиваем нулями до размера из заголовка
w.warn("%s: файл изменился во время чтения (%d из %d байт)", p, n, hdr.Size)
if _, err := io.CopyN(w.tw, zeroReader{}, hdr.Size-n); err != nil {
return err
}
}
return nil
}
func (w *Writer) Close() error {
if err := w.tw.Close(); err != nil {
w.zw.Close()
return err
}
return w.zw.Close()
}
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}