backup-master: сервер бэкапов, агент и восстановление VPS

Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-06 21:52:40 +03:00
commit 1d44d067e9
53 files changed
+7278

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
// Package sysx — обёртки над системными командами и файлами Linux.
package sysx
import (
"bufio"
"bytes"
"context"
"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
}