Files
backup-master/internal/agent/collect.go
T
mrleo1nidandClaude Opus 5.5 cbd3cde01d
CI / test (push) Successful in 1m48s
Бэкап fail2ban и своих geo-файлов 3x-ui
- новая категория fail2ban: jail.local, jail.d (в т.ч. 3x-ipl.local),
  IP-лимит 3x-ui (filter.d/action.d/3x-ipl.conf), свои *.local;
  при восстановлении создаются логи 3xipl*.log, без которых jail не поднимается
- xui: свои *.dat из /usr/local/x-ui/bin (ipcheck, discord-*) — на них ссылается
  роутинг; штатные geo-файлы релиза не бэкапятся
- recipe 3x-ui больше не очищает /usr/local/x-ui и не стирает восстановленные файлы
- маски (*, ?) в paths и keep_binaries каталога
- provision ставит nftables вместе с fail2ban
- .gitattributes: LF во всех файлах

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:24:21 +03:00

286 lines
7.9 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 []catalog.Spec
for _, s := range cat.Categories {
if s.Disabled {
continue
}
if s.Type == catalog.TypeCompose {
composeSpecs = append(composeSpecs, 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
}
c.checkCron()
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")
}
// 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))
}
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
}
for _, b := range expandPaths(s.KeepBinaries) {
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 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
}
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
}