Агент: - 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>
105 lines
2.9 KiB
Go
105 lines
2.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
|
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
const xuiDB = "/etc/x-ui/x-ui.db"
|
|
|
|
// snapshotXUI делает консистентную копию базы 3x-ui на горячую (VACUUM INTO),
|
|
// без логина в панель и без остановки сервиса.
|
|
func (c *collector) snapshotXUI(mc *manifest.Category) error {
|
|
if _, err := os.Stat(xuiDB); err != nil {
|
|
c.warn("xui: нет %s", xuiDB)
|
|
return nil
|
|
}
|
|
dir, err := c.spoolDir(mc.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := filepath.Join(dir, "x-ui.db")
|
|
if err := vacuumInto(xuiDB, out); err != nil {
|
|
c.warn("xui: снимок базы не удался: %v", err)
|
|
return nil
|
|
}
|
|
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: "x-ui.db", Kind: "sqlite", Target: xuiDB})
|
|
c.add(manifest.MainPart, dumpItem(mc.Name, out, "x-ui.db"))
|
|
|
|
// сертификаты панели, подписки и инбаундов могут лежать где угодно — берём пути из базы
|
|
for _, p := range xuiCertPaths(out) {
|
|
if !sysx.Exists(p) {
|
|
c.warn("xui: в настройках указан %s, но файла нет", p)
|
|
continue
|
|
}
|
|
if archive.Covers(mc.Paths, filepath.ToSlash(p)) {
|
|
continue
|
|
}
|
|
mc.Paths = append(mc.Paths, p)
|
|
c.add(manifest.MainPart, filesItem(mc.Name, p, nil))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var certFileRe = regexp.MustCompile(`"(?:certificateFile|keyFile)"\s*:\s*"([^"]+)"`)
|
|
|
|
// xuiCertPaths читает из снимка базы 3x-ui пути к сертификатам и ключам.
|
|
func xuiCertPaths(dbPath string) []string {
|
|
db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer db.Close()
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
addPath := func(p string) {
|
|
p = strings.TrimSpace(p)
|
|
if strings.HasPrefix(p, "/") && !seen[p] {
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
if rows, err := db.Query(`SELECT value FROM settings WHERE key IN ('webCertFile','webKeyFile','subCertFile','subKeyFile')`); err == nil {
|
|
for rows.Next() {
|
|
var v string
|
|
if rows.Scan(&v) == nil {
|
|
addPath(v)
|
|
}
|
|
}
|
|
rows.Close()
|
|
}
|
|
if rows, err := db.Query(`SELECT stream_settings FROM inbounds`); err == nil {
|
|
for rows.Next() {
|
|
var v string
|
|
if rows.Scan(&v) == nil {
|
|
for _, m := range certFileRe.FindAllStringSubmatch(v, -1) {
|
|
addPath(m[1])
|
|
}
|
|
}
|
|
}
|
|
rows.Close()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func vacuumInto(src, dst string) error {
|
|
_ = os.Remove(dst)
|
|
db, err := sql.Open("sqlite", "file:"+src+"?_pragma=busy_timeout(10000)")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
_, err = db.Exec(fmt.Sprintf("VACUUM INTO '%s'", strings.ReplaceAll(dst, "'", "''")))
|
|
return err
|
|
}
|