Files
backup-master/cmd/backupctl/main.go
T
mrleo1nidandClaude Opus 5.5 1d44d067e9 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>
2026-10-06 21:52:40 +03:00

208 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}