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,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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultAndMerge(t *testing.T) {
|
||||
base, err := Default()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.Find("stoat") == nil || base.Find("xui") == nil {
|
||||
t.Fatal("в каталоге нет stoat/xui")
|
||||
}
|
||||
over, err := Parse([]byte(`
|
||||
categories:
|
||||
- {name: scripts, disabled: true}
|
||||
- name: extra
|
||||
type: files
|
||||
paths: [/srv/extra]
|
||||
- name: telegraf
|
||||
type: files
|
||||
paths: [/etc/telegraf]
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := Merge(base, over)
|
||||
if err := m.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Find("scripts").Disabled || len(m.Find("scripts").Paths) == 0 {
|
||||
t.Error("короткая форма disabled должна сохранять остальные поля")
|
||||
}
|
||||
if m.Find("extra") == nil {
|
||||
t.Error("новая категория не добавлена")
|
||||
}
|
||||
if m.Find("telegraf").Type != TypeFiles {
|
||||
t.Error("категория не заменена")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Встроенный каталог категорий. Переопределяется конфигом клиента в веб-морде:
|
||||
# категория с тем же name заменяется целиком, `- {name: X, disabled: true}` отключает её.
|
||||
|
||||
auto_compose: true
|
||||
|
||||
exclude:
|
||||
- "*.swp"
|
||||
|
||||
categories:
|
||||
- name: system
|
||||
type: files
|
||||
paths:
|
||||
- /etc/ufw/user.rules
|
||||
- /etc/ufw/user6.rules
|
||||
- /var/spool/cron/crontabs
|
||||
- /etc/cron.d/ban-blacklist
|
||||
- /usr/local/sbin/ban.sh
|
||||
- /etc/sysctl.d
|
||||
post_restore:
|
||||
- "ufw reload || true"
|
||||
- "sysctl --system >/dev/null || true"
|
||||
|
||||
- name: xui
|
||||
type: xui
|
||||
detect: [/usr/local/x-ui/x-ui]
|
||||
paths: [/etc/x-ui, /root/cert]
|
||||
exclude: ["/etc/x-ui/x-ui.db*"]
|
||||
service: x-ui
|
||||
version_cmd: [/usr/local/x-ui/x-ui, -v]
|
||||
install: xui
|
||||
keep_binaries: [/usr/local/x-ui, /usr/bin/x-ui, /etc/systemd/system/x-ui.service]
|
||||
|
||||
- name: nebula
|
||||
type: systemd
|
||||
detect: [/etc/nebula]
|
||||
paths: [/etc/nebula, /etc/systemd/system/nebula.service]
|
||||
service: nebula
|
||||
version_cmd: [/usr/local/bin/nebula, -version]
|
||||
install: nebula
|
||||
keep_binaries: [/usr/local/bin/nebula, /usr/local/bin/nebula-cert]
|
||||
|
||||
- name: caddy
|
||||
type: systemd
|
||||
detect: [/etc/caddy/Caddyfile]
|
||||
paths: [/etc/caddy, /var/www, /var/lib/caddy/.local/share/caddy]
|
||||
service: caddy
|
||||
|
||||
- name: telegraf
|
||||
type: systemd
|
||||
detect: [/etc/telegraf]
|
||||
paths: [/etc/telegraf, /etc/sudoers.d/telegraf]
|
||||
service: telegraf
|
||||
|
||||
- name: sing-box-warp
|
||||
type: systemd
|
||||
detect: [/opt/sing-box-warp]
|
||||
paths:
|
||||
- /opt/sing-box-warp
|
||||
- /etc/sing-box-warp
|
||||
- /etc/systemd/system/sing-box-warp.service
|
||||
- /etc/sysctl.d/99-sing-box-warp.conf
|
||||
service: sing-box-warp
|
||||
version_cmd: [/usr/local/bin/sing-box, version]
|
||||
version_regex: 'version\s+(\S+)'
|
||||
install: sing-box-extended
|
||||
keep_binaries: [/usr/local/bin/sing-box]
|
||||
ensure_dirs: [/var/cache/sing-box-warp]
|
||||
|
||||
- name: scripts
|
||||
type: files
|
||||
paths: [/var/scripts, /usr/local/bin/update-geosite.sh]
|
||||
|
||||
- name: pnvpanel
|
||||
type: compose
|
||||
dir: /opt/PnvPanel
|
||||
|
||||
- name: stoat
|
||||
type: compose
|
||||
dir: /opt/stoat
|
||||
skip_services: [rabbit, redis]
|
||||
heavy_paths: [data/minio]
|
||||
|
||||
- name: stoat-mbot
|
||||
type: compose
|
||||
dir: /opt/stoat-mbot
|
||||
restore_after: [stoat]
|
||||
|
||||
- name: psiphon
|
||||
type: compose
|
||||
dir: /opt/psiphon
|
||||
disabled: true
|
||||
Reference in new issue
Block a user