Полнота бэкапа: 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>
This commit is contained in:
1 parent
cbd3cde01d
commit
1947b26913
14 files changed
+747
-45
No files matched your search
@@ -5,6 +5,8 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -162,3 +164,117 @@ func GroupMembers(group string) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DpkgOwned возвращает множество путей, принадлежащих какому-либо пакету (dpkg -S).
|
||||
// Без dpkg (не Debian/Ubuntu) считает все пути своими — ничего не теряется.
|
||||
func DpkgOwned(ctx context.Context, paths []string) map[string]bool {
|
||||
owned := map[string]bool{}
|
||||
if len(paths) == 0 || !HasCommand("dpkg") {
|
||||
return owned
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "dpkg", append([]string{"-S"}, paths...)...)
|
||||
out, _ := cmd.Output() // код возврата 1, если хоть один путь ничей, — это нормально
|
||||
return ParseDpkgS(string(out), paths)
|
||||
}
|
||||
|
||||
// ParseDpkgS разбирает вывод `dpkg -S`: "pkg1, pkg2: /path" или "diversion by ...".
|
||||
func ParseDpkgS(out string, paths []string) map[string]bool {
|
||||
want := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
want[p] = true
|
||||
}
|
||||
owned := map[string]bool{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(line, "diversion ") {
|
||||
continue
|
||||
}
|
||||
_, p, ok := strings.Cut(line, ": ")
|
||||
if p = strings.TrimSpace(p); ok && want[p] {
|
||||
owned[p] = true
|
||||
}
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
// Timezone возвращает часовой пояс системы (Europe/Moscow) или "".
|
||||
func Timezone(ctx context.Context) string {
|
||||
if tz, err := Output(ctx, "timedatectl", "show", "-p", "Timezone", "--value"); err == nil && tz != "" {
|
||||
return tz
|
||||
}
|
||||
if link, err := os.Readlink("/etc/localtime"); err == nil {
|
||||
if _, tz, ok := strings.Cut(link, "zoneinfo/"); ok {
|
||||
return tz
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SwapFiles возвращает swap-файлы (не разделы) из /proc/swaps: путь и размер в байтах.
|
||||
func SwapFiles() map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
data, err := os.ReadFile("/proc/swaps")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n")[1:] {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 || f[1] != "file" {
|
||||
continue
|
||||
}
|
||||
var kb int64
|
||||
fmt.Sscan(f[2], &kb)
|
||||
out[f[0]] = kb * 1024
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ManualPackages — пакеты, поставленные вручную (apt-mark showmanual).
|
||||
func ManualPackages(ctx context.Context) []string {
|
||||
out, err := Output(ctx, "apt-mark", "showmanual")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(out)
|
||||
}
|
||||
|
||||
// ModifiedConffiles возвращает конфиги пакетов (conffiles), изменённые относительно
|
||||
// поставки: md5 файла не совпадает с записанным dpkg.
|
||||
func ModifiedConffiles(ctx context.Context) []string {
|
||||
out, err := Output(ctx, "dpkg-query", "-W", `-f=${Conffiles}\n`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return modifiedConffiles(out, fileMD5)
|
||||
}
|
||||
|
||||
func modifiedConffiles(out string, sum func(string) string) []string {
|
||||
var res []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 || !strings.HasPrefix(f[0], "/") || seen[f[0]] {
|
||||
continue
|
||||
}
|
||||
if len(f) > 2 && (f[2] == "obsolete" || f[2] == "remove-on-upgrade") {
|
||||
continue
|
||||
}
|
||||
seen[f[0]] = true
|
||||
if s := sum(f[0]); s != "" && s != f[1] {
|
||||
res = append(res, f[0])
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func fileMD5(p string) string {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package sysx
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDpkgS(t *testing.T) {
|
||||
out := "openssh-server: /etc/ssh/sshd_config\n" +
|
||||
"diversion by foo from: /usr/bin/caddy\n" +
|
||||
"cron, cron-daemon-common: /etc/cron.d/e2scrub_all\n"
|
||||
paths := []string{"/etc/ssh/sshd_config", "/etc/cron.d/e2scrub_all", "/etc/cron.d/ban-blacklist"}
|
||||
owned := ParseDpkgS(out, paths)
|
||||
if !owned["/etc/ssh/sshd_config"] || !owned["/etc/cron.d/e2scrub_all"] || owned["/etc/cron.d/ban-blacklist"] {
|
||||
t.Fatalf("owned = %v", owned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModifiedConffiles(t *testing.T) {
|
||||
out := " /etc/ssh/sshd_config aaa\n" +
|
||||
" /etc/fail2ban/fail2ban.conf bbb\n" +
|
||||
" /etc/old.conf ccc obsolete\n" +
|
||||
" /etc/missing.conf ddd\n" +
|
||||
"\n"
|
||||
sums := map[string]string{
|
||||
"/etc/ssh/sshd_config": "aaa", // не менялся
|
||||
"/etc/fail2ban/fail2ban.conf": "changed", // изменён
|
||||
"/etc/old.conf": "x", // obsolete — пропускаем
|
||||
}
|
||||
got := modifiedConffiles(out, func(p string) string { return sums[p] })
|
||||
if !slices.Equal(got, []string{"/etc/fail2ban/fail2ban.conf"}) {
|
||||
t.Fatalf("modified = %v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user