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:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user