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:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user