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

+150
View File
@@ -0,0 +1,150 @@
// Package catalog описывает категории бэкапа. Встроенный каталог (default.yaml)
// покрывает типовой набор софта, конфиг клиента с сервера переопределяет его по имени.
package catalog
import (
_ "embed"
"fmt"
"gopkg.in/yaml.v3"
)
//go:embed default.yaml
var defaultYAML []byte
const (
TypeFiles = "files"
TypeSystemd = "systemd"
TypeXUI = "xui"
TypeCompose = "compose"
)
type Config struct {
// AutoCompose — бэкапить compose-проекты, которых нет в каталоге.
AutoCompose *bool `yaml:"auto_compose,omitempty"`
Exclude []string `yaml:"exclude,omitempty"` // глобальные исключения
Categories []Spec `yaml:"categories"`
}
type Spec struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Disabled bool `yaml:"disabled,omitempty"`
Detect []string `yaml:"detect,omitempty"` // категория активна, если существует хоть один путь
Paths []string `yaml:"paths,omitempty"`
Exclude []string `yaml:"exclude,omitempty"`
Service string `yaml:"service,omitempty"`
VersionCmd []string `yaml:"version_cmd,omitempty"`
VersionRegex string `yaml:"version_regex,omitempty"`
Install string `yaml:"install,omitempty"` // recipe: nebula, xui, sing-box-extended
KeepBinaries []string `yaml:"keep_binaries,omitempty"` // fallback-копии бинарников
EnsureDirs []string `yaml:"ensure_dirs,omitempty"`
PostRestore []string `yaml:"post_restore,omitempty"`
Dumps []CmdDump `yaml:"dumps,omitempty"`
// compose
Dir string `yaml:"dir,omitempty"`
Project string `yaml:"project,omitempty"`
SkipServices []string `yaml:"skip_services,omitempty"`
HeavyPaths []string `yaml:"heavy_paths,omitempty"` // относительно dir (или абсолютные)
RestoreAfter []string `yaml:"restore_after,omitempty"`
}
// CmdDump — произвольный дамп: stdout команды cmd сохраняется в файл name.
type CmdDump struct {
Name string `yaml:"name"`
Cmd string `yaml:"cmd"`
RestoreCmd string `yaml:"restore_cmd,omitempty"`
}
func (c *Config) AutoComposeEnabled() bool { return c.AutoCompose == nil || *c.AutoCompose }
// Default возвращает встроенный каталог.
func Default() (*Config, error) {
var c Config
if err := yaml.Unmarshal(defaultYAML, &c); err != nil {
return nil, fmt.Errorf("встроенный каталог: %w", err)
}
return &c, c.Validate()
}
// Parse разбирает YAML-переопределения клиента.
func Parse(data []byte) (*Config, error) {
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, err
}
return &c, nil
}
// Merge накладывает override на base: категории с тем же именем заменяются целиком,
// новые добавляются в конец, глобальные исключения складываются.
func Merge(base, override *Config) *Config {
out := &Config{AutoCompose: base.AutoCompose}
out.Exclude = append(out.Exclude, base.Exclude...)
out.Categories = append(out.Categories, base.Categories...)
if override == nil {
return out
}
if override.AutoCompose != nil {
out.AutoCompose = override.AutoCompose
}
out.Exclude = append(out.Exclude, override.Exclude...)
for _, s := range override.Categories {
replaced := false
for i := range out.Categories {
if out.Categories[i].Name == s.Name {
// "name + disabled: true" — короткая форма отключения встроенной категории
if s.Disabled && s.Type == "" {
out.Categories[i].Disabled = true
} else {
out.Categories[i] = s
}
replaced = true
break
}
}
if !replaced {
out.Categories = append(out.Categories, s)
}
}
return out
}
func (c *Config) Validate() error {
seen := map[string]bool{}
for _, s := range c.Categories {
if s.Name == "" {
return fmt.Errorf("категория без имени")
}
if seen[s.Name] {
return fmt.Errorf("категория %q описана дважды", s.Name)
}
seen[s.Name] = true
if s.Disabled {
continue
}
switch s.Type {
case TypeFiles, TypeSystemd, TypeXUI:
case TypeCompose:
if s.Dir == "" && s.Project == "" {
return fmt.Errorf("категория %q: для compose нужен dir или project", s.Name)
}
default:
return fmt.Errorf("категория %q: неизвестный тип %q", s.Name, s.Type)
}
}
return nil
}
// Find возвращает категорию по имени.
func (c *Config) Find(name string) *Spec {
for i := range c.Categories {
if c.Categories[i].Name == name {
return &c.Categories[i]
}
}
return nil
}