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

+207
View File
@@ -0,0 +1,207 @@
// backupctl — сервер, агент и восстановление backup-master в одном бинарнике.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"golang.org/x/term"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/agent"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/restore"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/server"
)
// version задаётся при сборке: -ldflags "-X main.version=..."
var version = "dev"
const usage = `backupctl %s
Сервер бэкапов:
backupctl server [флаги] API + веб-морда
backupctl admin-passwd <логин> задать логин/пароль администратора
Клиент:
backupctl backup [--config ...] собрать и отправить бэкап
backupctl plan [--config ...] показать, что попадёт в бэкап
Новый сервер:
backupctl restore --server URL --token TOKEN [--backup ID] [--skip-heavy] [--skip-provision]
backupctl version
Флаги подкоманды: backupctl <команда> -h
`
func main() {
log.SetFlags(log.LstdFlags)
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, usage, version)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cmd, args := os.Args[1], os.Args[2:]
var err error
switch cmd {
case "server":
err = runServer(ctx, args)
case "admin-passwd":
err = runAdminPasswd(args)
case "backup":
err = runBackup(ctx, args)
case "plan":
err = runPlan(ctx, args)
case "restore":
err = runRestore(ctx, args)
case "version", "--version", "-v":
fmt.Println("backupctl", version)
case "help", "-h", "--help":
fmt.Printf(usage, version)
default:
fmt.Fprintf(os.Stderr, "неизвестная команда %q\n\n"+usage, cmd, version)
os.Exit(2)
}
if err != nil {
log.Printf("ошибка: %v", err)
os.Exit(1)
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func serverFlags(fs *flag.FlagSet) *server.Config {
cfg := &server.Config{}
fs.StringVar(&cfg.Listen, "listen", envOr("BM_LISTEN", "127.0.0.1:8080"), "адрес HTTP (env BM_LISTEN)")
fs.StringVar(&cfg.DataDir, "data", envOr("BM_DATA", "/var/lib/backup-master"), "каталог данных (env BM_DATA)")
fs.StringVar(&cfg.PublicURL, "public-url", os.Getenv("BM_PUBLIC_URL"), "внешний адрес, например https://backup.example.com (env BM_PUBLIC_URL)")
fs.StringVar(&cfg.DistDir, "dist", os.Getenv("BM_DIST"), "каталог с backupctl-linux-<arch> для раздачи агентам (env BM_DIST)")
fs.BoolVar(&cfg.TrustProxy, "trust-proxy", os.Getenv("BM_TRUST_PROXY") == "1", "брать IP клиента из X-Forwarded-For (env BM_TRUST_PROXY=1)")
return cfg
}
func runServer(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("server", flag.ExitOnError)
cfg := serverFlags(fs)
maxGB := fs.Int64("max-upload-gb", 200, "максимальный размер одной части бэкапа, ГБ")
_ = fs.Parse(args)
cfg.MaxUpload = *maxGB << 30
if cfg.PublicURL == "" {
log.Printf("ВНИМАНИЕ: --public-url не задан, адрес в командах установки берётся из запроса")
}
s, err := server.New(*cfg)
if err != nil {
return err
}
defer s.Close()
err = s.Run(ctx)
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
func runAdminPasswd(args []string) error {
fs := flag.NewFlagSet("admin-passwd", flag.ExitOnError)
data := fs.String("data", envOr("BM_DATA", "/var/lib/backup-master"), "каталог данных сервера (env BM_DATA)")
_ = fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("использование: backupctl admin-passwd [--data DIR] <логин>")
}
user := fs.Arg(0)
pass, err := readPassword("Пароль: ")
if err != nil {
return err
}
if term.IsTerminal(int(os.Stdin.Fd())) {
again, err := readPassword("Повторите: ")
if err != nil {
return err
}
if again != pass {
return errors.New("пароли не совпадают")
}
}
if len(pass) < 10 {
return errors.New("пароль короче 10 символов")
}
s, err := server.New(server.Config{DataDir: *data})
if err != nil {
return err
}
defer s.Close()
if err := s.Store().SetAdmin(user, pass); err != nil {
return err
}
fmt.Println("Администратор сохранён, все сессии сброшены.")
return nil
}
// readPassword читает пароль без эха с терминала или строку из stdin (для скриптов).
func readPassword(prompt string) (string, error) {
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprint(os.Stderr, prompt)
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return string(b), err
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
func agentFlags(fs *flag.FlagSet) *agent.Options {
o := &agent.Options{AgentVersion: version}
fs.StringVar(&o.ConfigPath, "config", agent.DefaultConfigPath, "конфиг агента")
fs.BoolVar(&o.ForceHeavy, "heavy", false, "включить heavy-части независимо от расписания")
return o
}
func runBackup(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
o := agentFlags(fs)
fs.StringVar(&o.Output, "output", "", "записать части в каталог вместо отправки на сервер (отладка)")
_ = fs.Parse(args)
return agent.Backup(ctx, *o)
}
func runPlan(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("plan", flag.ExitOnError)
o := agentFlags(fs)
_ = fs.Parse(args)
return agent.PrintPlan(ctx, *o, os.Stdout)
}
func runRestore(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("restore", flag.ExitOnError)
var o restore.Options
fs.StringVar(&o.Server, "server", "", "адрес сервера бэкапов")
fs.StringVar(&o.Token, "token", "", "restore-токен из веб-морды")
fs.Int64Var(&o.BackupID, "backup", 0, "номер бэкапа (по умолчанию последний успешный)")
fs.StringVar(&o.User, "user", "", "пользователь с sudo на новом сервере (по умолчанию спросит)")
fs.BoolVar(&o.SkipProvision, "skip-provision", false, "не выполнять базовую подготовку (повторный запуск)")
fs.BoolVar(&o.SkipHeavy, "skip-heavy", false, "не скачивать heavy-части (например, MinIO Stoat)")
fs.BoolVar(&o.Yes, "yes", false, "не спрашивать подтверждение")
fs.StringVar(&o.StagingDir, "staging", "", "каталог для временных файлов (по умолчанию /var/tmp/backup-restore)")
fs.BoolVar(&o.KeepStaging, "keep-staging", false, "не удалять временные файлы (дампы) после восстановления")
_ = fs.Parse(args)
if o.Server == "" {
return errors.New("нужен --server")
}
return restore.Run(ctx, o)
}