Files
backup-master/internal/agent/units.go
T
mrleo1nidandClaude Opus 5.5 1947b26913
CI / test (push) Successful in 1m27s
Release / release (push) Successful in 1m26s
Полнота бэкапа: SSH, весь UFW, свои юниты, конфиги пакетов, системные параметры
Агент:
- 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>
2026-10-17 15:19:34 +03:00

138 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
}
}