Агент: - 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>
361 lines
10 KiB
Go
361 lines
10 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// item — что положить в архив: обход Src с именами Name(path) и исключениями Skip.
|
|
type item struct {
|
|
Src string
|
|
Name func(p string) string
|
|
Skip *archive.Matcher
|
|
}
|
|
|
|
// Plan — результат сбора: манифест, содержимое частей и временные файлы дампов.
|
|
type Plan struct {
|
|
Manifest manifest.Manifest
|
|
Parts map[string][]item // имя части → элементы; порядок частей — Manifest.Parts
|
|
}
|
|
|
|
type collector struct {
|
|
ctx context.Context
|
|
cat *catalog.Config
|
|
spool string
|
|
heavyDue bool
|
|
global *archive.Matcher
|
|
plan *Plan
|
|
}
|
|
|
|
func (c *collector) warn(format string, a ...any) {
|
|
msg := fmt.Sprintf(format, a...)
|
|
c.plan.Manifest.Warnings = append(c.plan.Manifest.Warnings, msg)
|
|
}
|
|
|
|
func (c *collector) add(part string, it item) {
|
|
if _, ok := c.plan.Parts[part]; !ok {
|
|
c.plan.Manifest.Parts = append(c.plan.Manifest.Parts, part)
|
|
}
|
|
c.plan.Parts[part] = append(c.plan.Parts[part], it)
|
|
}
|
|
|
|
func filesItem(cat, src string, skip *archive.Matcher) item {
|
|
prefix := manifest.FilesPrefix(cat)
|
|
return item{Src: src, Skip: skip, Name: func(p string) string {
|
|
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
|
|
}}
|
|
}
|
|
|
|
func binItem(cat, src string) item {
|
|
prefix := manifest.BinPrefix(cat)
|
|
return item{Src: src, Name: func(p string) string {
|
|
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
|
|
}}
|
|
}
|
|
|
|
func dumpItem(cat, src, name string) item {
|
|
n := manifest.DumpsPrefix(cat) + name
|
|
return item{Src: src, Name: func(string) string { return n }}
|
|
}
|
|
|
|
func treeItem(src, prefix string, skip *archive.Matcher) item {
|
|
return item{Src: src, Skip: skip, Name: func(p string) string {
|
|
rel, err := filepath.Rel(src, p)
|
|
if err != nil || rel == "." {
|
|
return strings.TrimSuffix(prefix, "/")
|
|
}
|
|
return prefix + filepath.ToSlash(rel)
|
|
}}
|
|
}
|
|
|
|
// Collect собирает план бэкапа по каталогу: выполняет дампы в spool, определяет версии.
|
|
func Collect(ctx context.Context, cat *catalog.Config, spool string, heavyDue bool) (*Plan, error) {
|
|
c := &collector{
|
|
ctx: ctx,
|
|
cat: cat,
|
|
spool: spool,
|
|
heavyDue: heavyDue,
|
|
global: archive.NewMatcher(cat.Exclude),
|
|
plan: &Plan{Parts: map[string][]item{}},
|
|
}
|
|
c.plan.Parts[manifest.MainPart] = nil
|
|
c.plan.Manifest.Parts = []string{manifest.MainPart}
|
|
c.fillSystem()
|
|
|
|
var composeSpecs, unitSpecs []catalog.Spec
|
|
for _, s := range cat.Categories {
|
|
if s.Disabled {
|
|
continue
|
|
}
|
|
switch s.Type {
|
|
case catalog.TypeCompose:
|
|
composeSpecs = append(composeSpecs, s)
|
|
continue
|
|
case catalog.TypeUnits:
|
|
unitSpecs = append(unitSpecs, s) // после остальных: им нужно знать, что уже покрыто
|
|
continue
|
|
}
|
|
mc, err := c.collectSpec(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("категория %s: %w", s.Name, err)
|
|
}
|
|
if mc != nil {
|
|
c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc)
|
|
}
|
|
}
|
|
if err := c.collectCompose(composeSpecs); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, s := range unitSpecs {
|
|
if mc := c.collectUnits(s); mc != nil {
|
|
c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc)
|
|
}
|
|
}
|
|
c.checkCron()
|
|
c.checkCoverage()
|
|
return c.plan, nil
|
|
}
|
|
|
|
func (c *collector) fillSystem() {
|
|
m := &c.plan.Manifest
|
|
m.FormatVersion = manifest.FormatVersion
|
|
m.CreatedAt = time.Now()
|
|
if h, err := sysx.Output(c.ctx, "hostnamectl", "hostname"); err == nil && h != "" {
|
|
m.Hostname = h
|
|
} else {
|
|
m.Hostname, _ = os.Hostname()
|
|
}
|
|
osr := sysx.OSRelease()
|
|
m.OS = manifest.OSInfo{ID: osr["ID"], VersionID: osr["VERSION_ID"], PrettyName: osr["PRETTY_NAME"], Arch: sysx.DpkgArch(c.ctx)}
|
|
m.Packages = map[string]string{}
|
|
for _, p := range []string{"caddy", "telegraf", "docker-ce", "docker-ce-cli", "containerd.io", "docker-compose-plugin", "docker-buildx-plugin"} {
|
|
if v := sysx.DpkgVersion(c.ctx, p); v != "" {
|
|
m.Packages[p] = v
|
|
}
|
|
}
|
|
m.Features = manifest.Features{
|
|
Blocklist: sysx.Exists("/etc/cron.d/ban-blacklist"),
|
|
SSHHardened: sysx.Exists("/etc/ssh/sshd_config.d/00-hardening.conf"),
|
|
}
|
|
m.SudoUsers = sysx.GroupMembers("sudo")
|
|
m.Timezone = sysx.Timezone(c.ctx)
|
|
for p, size := range sysx.SwapFiles() {
|
|
m.Swap = append(m.Swap, manifest.SwapFile{Path: p, Size: size})
|
|
}
|
|
m.ManualPackages = sysx.ManualPackages(c.ctx)
|
|
}
|
|
|
|
// collectSpec обрабатывает категории files/systemd/xui. nil — категория неактивна.
|
|
func (c *collector) collectSpec(s catalog.Spec) (*manifest.Category, error) {
|
|
if len(s.Detect) > 0 && !anyExists(s.Detect) {
|
|
return nil, nil
|
|
}
|
|
mc := &manifest.Category{
|
|
Name: s.Name,
|
|
Type: s.Type,
|
|
Install: s.Install,
|
|
EnsureDirs: s.EnsureDirs,
|
|
PostRestore: s.PostRestore,
|
|
RestoreAfter: s.RestoreAfter,
|
|
}
|
|
skip := archive.NewMatcher(c.cat.Exclude, s.Exclude)
|
|
for _, p := range expandPaths(s.Paths) {
|
|
if !sysx.Exists(p) {
|
|
continue
|
|
}
|
|
mc.Paths = append(mc.Paths, p)
|
|
c.add(manifest.MainPart, filesItem(s.Name, p, skip))
|
|
}
|
|
extra := c.unowned(s.Unowned)
|
|
if s.ModifiedConffiles {
|
|
extra = append(extra, sysx.ModifiedConffiles(c.ctx)...)
|
|
}
|
|
if len(extra) > 0 {
|
|
covered := c.covered() // то, что уже взяли другие категории, не дублируем
|
|
for _, p := range extra {
|
|
ps := filepath.ToSlash(p)
|
|
if archive.Covers(mc.Paths, ps) || archive.Covers(covered, ps) || skip.Match(ps) {
|
|
continue
|
|
}
|
|
mc.Paths = append(mc.Paths, p)
|
|
c.add(manifest.MainPart, filesItem(s.Name, p, skip))
|
|
}
|
|
}
|
|
|
|
if s.Type == catalog.TypeXUI {
|
|
if err := c.snapshotXUI(mc); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, d := range s.Dumps {
|
|
if err := c.cmdDump(s.Name, d, mc); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(mc.Paths) == 0 && len(mc.Dumps) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
keepBins := s.KeepBinaries
|
|
if s.KeepBinariesIf != "" && sysx.Shell(c.ctx, s.KeepBinariesIf) != nil {
|
|
keepBins = nil
|
|
}
|
|
for _, b := range expandPaths(keepBins) {
|
|
if sysx.Exists(b) {
|
|
mc.Binaries = append(mc.Binaries, b)
|
|
c.add(manifest.MainPart, binItem(s.Name, b))
|
|
}
|
|
}
|
|
if len(s.VersionCmd) > 0 && sysx.Exists(s.VersionCmd[0]) {
|
|
mc.Version = c.version(s)
|
|
}
|
|
if mc.Version == "" && s.Service != "" {
|
|
// сервис из apt-пакета с тем же именем (caddy, telegraf, docker → docker-ce)
|
|
for _, pkg := range []string{s.Service, s.Service + "-ce"} {
|
|
if v := c.plan.Manifest.Packages[pkg]; v != "" {
|
|
mc.Version = v
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if s.Service != "" && sysx.UnitLoaded(c.ctx, s.Service) {
|
|
en, ac := sysx.UnitState(c.ctx, s.Service)
|
|
mc.Service = &manifest.ServiceState{Unit: s.Service, Enabled: en, Active: ac}
|
|
}
|
|
return mc, nil
|
|
}
|
|
|
|
var defaultVersionRe = regexp.MustCompile(`v?(\d+\.\d+(?:\.\d+)?[\w.+-]*)`)
|
|
|
|
func (c *collector) version(s catalog.Spec) string {
|
|
ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second)
|
|
defer cancel()
|
|
out, err := sysx.Output(ctx, s.VersionCmd[0], s.VersionCmd[1:]...)
|
|
if err != nil {
|
|
c.warn("%s: не удалось определить версию: %v", s.Name, err)
|
|
return ""
|
|
}
|
|
re := defaultVersionRe
|
|
if s.VersionRegex != "" {
|
|
if r, err := regexp.Compile(s.VersionRegex); err == nil {
|
|
re = r
|
|
}
|
|
}
|
|
if m := re.FindStringSubmatch(out); len(m) > 1 {
|
|
return m[1]
|
|
}
|
|
c.warn("%s: версия не распознана в выводе %q", s.Name, out)
|
|
return ""
|
|
}
|
|
|
|
func (c *collector) spoolDir(cat string) (string, error) {
|
|
d := filepath.Join(c.spool, cat)
|
|
return d, os.MkdirAll(d, 0o700)
|
|
}
|
|
|
|
func (c *collector) cmdDump(cat string, d catalog.CmdDump, mc *manifest.Category) error {
|
|
dir, err := c.spoolDir(cat)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := filepath.Join(dir, d.Name)
|
|
if err := sysx.ToFile(c.ctx, out, "bash", "-o", "pipefail", "-c", d.Cmd); err != nil {
|
|
c.warn("%s: дамп %s не удался: %v", cat, d.Name, err)
|
|
return nil
|
|
}
|
|
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: d.Name, Kind: "cmd", RestoreCmd: d.RestoreCmd})
|
|
c.add(manifest.MainPart, dumpItem(cat, out, d.Name))
|
|
return nil
|
|
}
|
|
|
|
// expandPaths раскрывает маски (*, ?, [...]) в путях категории; пути без масок — как есть.
|
|
func expandPaths(paths []string) []string {
|
|
var out []string
|
|
for _, p := range paths {
|
|
if !strings.ContainsAny(p, "*?[") {
|
|
out = append(out, p)
|
|
continue
|
|
}
|
|
m, _ := filepath.Glob(p)
|
|
out = append(out, m...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unowned раскрывает маски и оставляет только обычные файлы, не принадлежащие пакетам.
|
|
func (c *collector) unowned(patterns []string) []string {
|
|
var files []string
|
|
for _, p := range expandPaths(patterns) {
|
|
if fi, err := os.Lstat(p); err == nil && fi.Mode().IsRegular() {
|
|
files = append(files, p)
|
|
}
|
|
}
|
|
owned := sysx.DpkgOwned(c.ctx, files)
|
|
var out []string
|
|
for _, f := range files {
|
|
if !owned[f] {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// covered — все пути, которые уже попадают в бэкап (для проверок покрытия).
|
|
func (c *collector) covered() []string {
|
|
var out []string
|
|
for _, mc := range c.plan.Manifest.Categories {
|
|
for _, p := range mc.Paths {
|
|
out = append(out, filepath.ToSlash(p))
|
|
}
|
|
for _, p := range mc.Binaries {
|
|
out = append(out, filepath.ToSlash(p))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func anyExists(paths []string) bool {
|
|
for _, p := range paths {
|
|
if sysx.Exists(p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// treeSize считает размер дерева с учётом исключений (для plan).
|
|
func treeSize(src string, skip *archive.Matcher) (int64, int) {
|
|
var size int64
|
|
var n int
|
|
_ = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if skip.Match(filepath.ToSlash(p)) {
|
|
if d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.Type().IsRegular() {
|
|
if fi, err := d.Info(); err == nil {
|
|
size += fi.Size()
|
|
n++
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return size, n
|
|
}
|