// 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- для раздачи агентам (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) }