Полнота бэкапа: SSH, весь UFW, свои юниты, конфиги пакетов, системные параметры
CI / test (push) Successful in 1m27s
Release / release (push) Successful in 1m26s

Агент:
- ssh: sshd_config(.d), ключи хоста, authorized_keys root и пользователей
- system: весь /etc/ufw и /etc/default/ufw, /etc/sysctl.conf, /etc/hosts,
  свои файлы в /etc/cron.d (пакетные отсекаются через dpkg -S)
- certs: /etc/letsencrypt, /root/.acme.sh; xui: сертификаты по путям из базы
- docker: daemon.json и логины в registry; telegraf: /etc/default/telegraf
- caddy: бинарник бэкапится, если Caddy собран с плагинами
- units: свои systemd-юниты с состоянием enabled/active и drop-in *.service.d
- etc: изменённые конфиги любых пакетов (dpkg conffiles) и свои файлы в /etc,
  кроме привязанного к железу и облаку
- в манифесте: часовой пояс, swap, вручную поставленные пакеты; версия
  apt-пакета в строке категории
- предупреждения: программы юнитов вне бэкапа, каталоги /opt и /usr/local/bin
  без категории, контейнеры без compose

Restore:
- часовой пояс, swap-файл, доустановка вручную поставленных пакетов
- свои юниты включаются и запускаются как на старом сервере

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-17 15:19:34 +03:00
1 parent cbd3cde01d
commit 1947b26913
14 files changed
+747 -45

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
package agent
import (
"bufio"
"os"
"path/filepath"
"slices"
"strings"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
)
// collectUnits собирает свои systemd-юниты (не из пакетов и не описанные другими
// категориями) вместе с состоянием enabled/active, а также drop-in каталоги *.d —
// переопределения пакетных юнитов (например, docker.service.d/override.conf).
func (c *collector) collectUnits(s catalog.Spec) *manifest.Category {
mc := &manifest.Category{Name: s.Name, Type: catalog.TypeUnits, PostRestore: s.PostRestore, RestoreAfter: s.RestoreAfter}
covered := c.covered()
services := map[string]bool{}
for _, cat := range c.plan.Manifest.Categories {
if cat.Service != nil {
services[cat.Service.Unit] = true
}
}
skip := archive.NewMatcher(c.cat.Exclude, s.Exclude)
for _, f := range c.unowned(s.Unowned) {
unit := filepath.Base(f)
name := strings.TrimSuffix(strings.TrimSuffix(unit, ".service"), ".timer")
if archive.Covers(covered, filepath.ToSlash(f)) || services[name] || services[unit] {
continue
}
mc.Paths = append(mc.Paths, f)
c.add(manifest.MainPart, filesItem(s.Name, f, skip))
st := manifest.ServiceState{Unit: unit}
if sysx.UnitLoaded(c.ctx, unit) {
st.Enabled, st.Active = sysx.UnitState(c.ctx, unit)
}
mc.Units = append(mc.Units, st)
c.checkExec(unit, f, covered)
}
for _, d := range expandPaths(s.Paths) {
if fi, err := os.Stat(d); err != nil || !fi.IsDir() || archive.Covers(covered, filepath.ToSlash(d)) {
continue
}
mc.Paths = append(mc.Paths, d)
c.add(manifest.MainPart, filesItem(s.Name, d, skip))
}
if len(mc.Paths) == 0 {
return nil
}
return mc
}
// checkExec предупреждает, если юнит запускает программу, которая не попадает в бэкап
// и не ставится пакетом: после восстановления такой сервис не стартует.
func (c *collector) checkExec(unit, file string, covered []string) {
var missing []string
for _, p := range unitExecPaths(file) {
if archive.Covers(covered, p) || archive.Covers(systemBinDirs, p) || slices.Contains(missing, p) {
continue
}
if !sysx.Exists(p) {
continue
}
if sysx.DpkgOwned(c.ctx, []string{p})[p] {
continue
}
missing = append(missing, p)
}
for _, p := range missing {
c.warn("юнит %s запускает %s, но он не попадает в бэкап — добавьте путь в категорию", unit, p)
}
}
// unitExecPaths извлекает пути программ из Exec*= строк unit-файла.
func unitExecPaths(file string) []string {
f, err := os.Open(file)
if err != nil {
return nil
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
if !ok || !strings.HasPrefix(k, "Exec") {
continue
}
// префиксы systemd: -, @, :, +, !
v = strings.TrimLeft(strings.TrimSpace(v), "-@:+!")
if fields := strings.Fields(v); len(fields) > 0 && strings.HasPrefix(fields[0], "/") {
out = append(out, fields[0])
}
}
return out
}
// checkCoverage ищет типичные места, где лежит что-то своё, не попавшее в бэкап:
// каталоги в /opt, программы в /usr/local/bin и sbin, контейнеры без compose.
func (c *collector) checkCoverage() {
covered := c.covered()
ignore := []string{"/opt/containerd", "/usr/local/bin/backupctl"}
for _, s := range c.cat.Categories {
if s.Disabled && s.Dir != "" {
ignore = append(ignore, filepath.ToSlash(s.Dir)) // отключено сознательно
}
}
check := func(pattern, what string) {
matches, _ := filepath.Glob(pattern)
for _, p := range matches {
ps := filepath.ToSlash(p)
if archive.Covers(covered, ps) || slices.Contains(ignore, ps) || c.global.Match(ps) {
continue
}
c.warn("%s %s не попадает в бэкап — добавьте его в категорию или в глобальный exclude, если он не нужен", what, p)
}
}
check("/opt/*", "каталог")
check("/usr/local/bin/*", "программа")
check("/usr/local/sbin/*", "программа")
if sysx.HasCommand("docker") {
out, err := sysx.Output(c.ctx, "docker", "ps", "--format", `{{.Names}} {{.Label "com.docker.compose.project"}}`)
if err == nil {
for _, line := range strings.Split(out, "\n") {
name, project, _ := strings.Cut(line, "\t")
if name != "" && project == "" {
c.warn("контейнер %s запущен без docker compose (docker run) — его настройки и данные не бэкапятся", name)
}
}
}
}
}