Агент: - 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>
281 lines
8.0 KiB
Go
281 lines
8.0 KiB
Go
// Package sysx — обёртки над системными командами и файлами Linux.
|
|
package sysx
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// Output выполняет команду и возвращает stdout без хвостовых пробелов.
|
|
func Output(ctx context.Context, name string, args ...string) (string, error) {
|
|
var stdout, stderr bytes.Buffer
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(stdout.String())
|
|
}
|
|
return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
|
|
}
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// ToFile выполняет команду, направляя stdout в файл path.
|
|
func ToFile(ctx context.Context, path string, name string, args ...string) error {
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var stderr bytes.Buffer
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdout = f
|
|
cmd.Stderr = &stderr
|
|
runErr := cmd.Run()
|
|
closeErr := f.Close()
|
|
if runErr != nil {
|
|
return fmt.Errorf("%s: %w: %s", name, runErr, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return closeErr
|
|
}
|
|
|
|
// FromFile выполняет команду, подавая файл path на stdin.
|
|
func FromFile(ctx context.Context, path string, name string, args ...string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return Stream(ctx, f, name, args...)
|
|
}
|
|
|
|
// Stream выполняет команду с заданным stdin, вывод — в консоль.
|
|
func Stream(ctx context.Context, stdin io.Reader, name string, args ...string) error {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdin = stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run выполняет команду с выводом в консоль.
|
|
func Run(ctx context.Context, name string, args ...string) error {
|
|
return Stream(ctx, nil, name, args...)
|
|
}
|
|
|
|
// Shell выполняет строку через bash -c с выводом в консоль.
|
|
func Shell(ctx context.Context, script string, env ...string) error {
|
|
cmd := exec.CommandContext(ctx, "bash", "-c", script)
|
|
cmd.Env = append(os.Environ(), env...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("bash -c %q: %w", script, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func Exists(p string) bool {
|
|
_, err := os.Lstat(p)
|
|
return err == nil
|
|
}
|
|
|
|
func HasCommand(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|
|
|
|
// OSRelease читает /etc/os-release.
|
|
func OSRelease() map[string]string {
|
|
out := map[string]string{}
|
|
f, err := os.Open("/etc/os-release")
|
|
if err != nil {
|
|
return out
|
|
}
|
|
defer f.Close()
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
k, v, ok := strings.Cut(sc.Text(), "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
out[k] = strings.Trim(v, `"'`)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DpkgVersion возвращает установленную версию пакета или "".
|
|
func DpkgVersion(ctx context.Context, pkg string) string {
|
|
out, err := Output(ctx, "dpkg-query", "-W", "-f=${db:Status-Status} ${Version}", pkg)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
status, ver, _ := strings.Cut(out, " ")
|
|
if status != "installed" {
|
|
return ""
|
|
}
|
|
return ver
|
|
}
|
|
|
|
func DpkgArch(ctx context.Context) string {
|
|
out, err := Output(ctx, "dpkg", "--print-architecture")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UnitState возвращает (enabled, active) для systemd-юнита.
|
|
func UnitState(ctx context.Context, unit string) (bool, bool) {
|
|
en, _ := Output(ctx, "systemctl", "is-enabled", unit)
|
|
ac, _ := Output(ctx, "systemctl", "is-active", unit)
|
|
return en == "enabled", ac == "active"
|
|
}
|
|
|
|
// UnitLoaded сообщает, известен ли systemd такой юнит.
|
|
func UnitLoaded(ctx context.Context, unit string) bool {
|
|
out, err := Output(ctx, "systemctl", "show", "-p", "LoadState", "--value", unit)
|
|
return err == nil && out == "loaded"
|
|
}
|
|
|
|
// GroupMembers возвращает членов группы из /etc/group.
|
|
func GroupMembers(group string) []string {
|
|
data, err := os.ReadFile("/etc/group")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
f := strings.Split(line, ":")
|
|
if len(f) == 4 && f[0] == group && f[3] != "" {
|
|
return strings.Split(f[3], ",")
|
|
}
|
|
}
|
|
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))
|
|
}
|