Files
backup-master/cmd/backupctl/main.go
T
mrleo1nidandClaude Opus 5.5 97c7051021
CI / test (push) Successful in 1m36s
Release / release (push) Successful in 1m33s
Restore: перевод бэкапов на восстановленный сервер
Раньше восстановленная копия получала конфиг и таймер агента со старым токеном
(через категории etc и units), и бэкапы шли с двух серверов одновременно.

- конфиг и юниты агента больше не бэкапятся
- в конце restore спрашивает, сделать ли сервер основным: сервер бэкапов
  выпускает новый токен (POST /api/v1/restore/takeover), restore ставит
  агента; токен старого сервера отозван — его агент получает отказ
- --takeover / --no-takeover; без них — вопрос (с --yes — да)
- уведомление «Сервер восстановлен» в ntfy/Telegram

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:29:34 +03:00

220 lines
8.0 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] [--takeover|--no-takeover]
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
cfg.Version = version
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.BoolVar(&o.NoUpdate, "no-update", false, "не обновлять агента, даже если сервер предлагает новую версию")
_ = 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, "не удалять временные файлы (дампы) после восстановления")
takeover := fs.Bool("takeover", false, "сделать этот сервер основным для бэкапов, не спрашивая (токен старого отзывается)")
noTakeover := fs.Bool("no-takeover", false, "пробное восстановление: агента не ставить, бэкапы продолжает слать старый сервер")
_ = fs.Parse(args)
switch {
case *takeover && *noTakeover:
return errors.New("--takeover и --no-takeover вместе не указываются")
case *takeover:
o.Takeover = restore.TakeoverYes
case *noTakeover:
o.Takeover = restore.TakeoverNo
}
if o.Server == "" {
return errors.New("нужен --server")
}
return restore.Run(ctx, o)
}