commit 1d44d067e9801bb67a2ca2bb9af8ad9349e3d5ce Author: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue Oct 6 21:52:40 2026 +0300 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee46411 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist/ +*.exe diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..94701a0 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: build test vet clean + +build: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o dist/backupctl-linux-amd64 ./cmd/backupctl + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o dist/backupctl-linux-arm64 ./cmd/backupctl + +test: + go test ./... + +vet: + go vet ./... + GOOS=linux go vet ./... + +clean: + rm -rf dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ef7e0a --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# backup-master + +Сервис бэкапов для VPS. Делает три вещи: + +- принимает и хранит бэкапы, у него есть простая веб-морда для одного админа; +- агент на каждом сервере раз в сутки собирает бэкап по категориям, корректно дампит базы и записывает, какие версии софта стоят; +- восстанавливает бэкап **на новом сервере**: готовит систему (на основе [vps_installer](https://gitea.hsrv.site/mrleo1nid/vps_installer)), ставит **те же версии** утилит, раскладывает конфиги и данные, запускает сервисы. + +Всё это делает один бинарник `backupctl`. Как он устроен внутри — в [docs/DESIGN.md](docs/DESIGN.md). + +## Сборка + +```bash +make build # dist/backupctl-linux-amd64, dist/backupctl-linux-arm64 +make test +``` + +Без make: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o dist/backupctl-linux-amd64 ./cmd/backupctl +``` + +## Установка сервера + +```bash +sudo useradd -r -s /usr/sbin/nologin -d /var/lib/backup-master backup-master +sudo install -d -o backup-master -g backup-master -m 700 /var/lib/backup-master +sudo install -d /opt/backup-master/dist +sudo cp dist/backupctl-linux-* /opt/backup-master/dist/ +sudo cp dist/backupctl-linux-amd64 /usr/local/bin/backupctl +sudo -u backup-master BM_DATA=/var/lib/backup-master backupctl admin-passwd admin +sudo cp deploy/backup-master.service /etc/systemd/system/ # поправьте BM_PUBLIC_URL +sudo systemctl enable --now backup-master +``` + +Сервер слушает `127.0.0.1:8080`, TLS даёт Caddy (пример — `deploy/Caddyfile.example`). Агентам сервер раздаёт бинарники из `BM_DIST`. Для своей архитектуры сервер раздаёт сам себя. + +В «Настройках» укажите топик ntfy. Уведомления приходят: +- об ошибках бэкапа; +- когда от клиента нет бэкапа дольше заданного срока (по умолчанию 26 ч); +- если включено — и об успешных бэкапах. + +## Добавление клиента + +1. В веб-морде нажмите «Добавить клиента» и укажите домен сервера. +2. Выполните на сервере выданную команду: + ```bash + curl -fsSL https://backup.example.com/agent.sh | sudo bash -s -- --token bm_... --now + ``` + Команда ставит `backupctl`, конфиг `/etc/backup-agent/agent.yaml` и таймер `backup-agent.timer` (03:00 ± 30 мин), показывает план (`backupctl plan`) и запускает первый бэкап. +3. В карточке клиента видно, что попадает в бэкап, версии софта и предупреждения. Например, cron запускает скрипт, который не попадает ни в одну категорию. + +Полезные команды на клиенте: + +```bash +sudo backupctl plan # что будет в бэкапе (дампы реально выполняются во временный каталог) +sudo systemctl start backup-agent # бэкап сейчас +journalctl -u backup-agent -f +sudo backupctl backup --heavy # с heavy-частями вне расписания +``` + +## Восстановление + +В карточке клиента нажмите «Команда восстановления». Токен живёт 24 часа и даёт доступ только на чтение бэкапов этого клиента. Если нужен конкретный бэкап, откройте его и нажмите «Восстановить из этого бэкапа». На чистой Ubuntu 24.04: + +```bash +curl -fsSL https://backup.example.com/restore.sh | sudo bash -s -- --token bm_... +``` + +Что происходит: + +1. Показывается сводка бэкапа и запрашивается подтверждение. +2. Базовая подготовка системы (`internal/restore/provision.sh`): пользователь с sudo, hostname, fail2ban, UFW, blocklist, SSH hardening. Docker, Caddy и Telegraf ставятся **тех же версий apt-пакетов**, если такие версии ещё есть в репозитории. Спрашиваются только логин и пароль пользователя. +3. Распаковка: файлы ложатся на свои места, docker-тома — прямо в тома. +4. Категории восстанавливаются по порядку: + - 3x-ui, Nebula и sing-box ставятся той версии, что была на старом сервере. Если скачать не получилось, используется копия бинарника из бэкапа; + - база x-ui кладётся на место; + - compose-проекты: образы закрепляются по digest (`compose.restore.yml`), базы поднимаются, в них заливаются дампы, затем запускается весь проект; + - systemd-сервисы включаются и запускаются так, как было на старом сервере. +5. В конце выводятся итог и список того, что осталось сделать: DNS, lighthouse, перезагрузка. + +Флаги: +- `--backup ID` — конкретный бэкап; +- `--skip-heavy` — без MinIO Stoat; +- `--skip-provision` — повторный прогон без базовой подготовки; +- `--user NAME`, `--yes`, `--keep-staging`. + +## Что бэкапится + +Встроенный каталог — [internal/catalog/default.yaml](internal/catalog/default.yaml): + +| Категория | Что входит | +|---|---| +| system | правила UFW, все crontab (`/var/spool/cron/crontabs`), `ban-blacklist`, `/etc/sysctl.d` | +| xui | `/etc/x-ui` (снимок базы через `VACUUM INTO`, без логина в панель), `/root/cert`, версия 3x-ui | +| nebula | `/etc/nebula`, юнит, версия | +| caddy | `/etc/caddy`, `/var/www`, сертификаты | +| telegraf | `/etc/telegraf`, sudoers | +| sing-box-warp | `/opt/sing-box-warp`, `/etc/sing-box-warp`, юнит, sysctl, версия sing-box-extended | +| scripts | `/var/scripts`, `update-geosite.sh` | +| pnvpanel | `/opt/PnvPanel` целиком, кроме `pgdata`; `pg_dump`; том `ticket_uploads` | +| stoat | `/opt/stoat`, кроме `data/db` и `data/rabbit`; `mongodump`; `data/minio` идёт отдельной heavy-частью раз в неделю | +| stoat-mbot | `/opt/stoat-mbot` целиком, восстанавливается после stoat | +| psiphon | отключён | + +Compose-проекты, которых нет в каталоге, находятся автоматически (`docker compose ls`). Они бэкапятся с настройками по умолчанию и помечаются в морде как «авто». Базы распознаются по образу (postgres, mongo, mysql, mariadb) и дампятся без остановки. + +Каталог для конкретного клиента можно переопределить в его карточке (YAML): + +```yaml +categories: + - {name: scripts, disabled: true} # отключить встроенную + - name: hostdb # своя категория с дампом + type: files + paths: [/etc/mysql] + dumps: + - name: full_dump.sql + cmd: mysqldump --all-databases --single-transaction + restore_cmd: mysql < "$DUMP_FILE" +``` + +## Хранение + +Бэкапы лежат в `/var/lib/backup-master/backups/<домен>/<дата>-/`: `main.tar.zst`, heavy-части и `manifest.json`. Архив — обычный tar+zstd, его можно разобрать и без backupctl: + +```bash +zstd -dc main.tar.zst | tar -tv +``` + +Ротация: +- обычные бэкапы — по схеме GFS: последний всегда + N дневных, недельных и месячных (по умолчанию 7/4/6); +- heavy-части — только последние `heavy_keep` (по умолчанию 1). diff --git a/cmd/backupctl/main.go b/cmd/backupctl/main.go new file mode 100644 index 0000000..2a85ba4 --- /dev/null +++ b/cmd/backupctl/main.go @@ -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- для раздачи агентам (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) +} diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 0000000..bf7bfc2 --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,12 @@ +backup.example.com { + # части бэкапа большие и грузятся долго — без лимита размера и с длинными таймаутами + request_body { + max_size 200GB + } + reverse_proxy 127.0.0.1:8080 { + transport http { + read_timeout 6h + write_timeout 6h + } + } +} diff --git a/deploy/backup-master.service b/deploy/backup-master.service new file mode 100644 index 0000000..9723bbf --- /dev/null +++ b/deploy/backup-master.service @@ -0,0 +1,25 @@ +[Unit] +Description=backup-master server +After=network-online.target +Wants=network-online.target + +[Service] +User=backup-master +Group=backup-master +Environment=BM_LISTEN=127.0.0.1:8080 +Environment=BM_DATA=/var/lib/backup-master +Environment=BM_PUBLIC_URL=https://backup.example.com +Environment=BM_DIST=/opt/backup-master/dist +Environment=BM_TRUST_PROXY=1 +ExecStart=/usr/local/bin/backupctl server +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/backup-master + +[Install] +WantedBy=multi-user.target diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..1205ae6 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,70 @@ +# Устройство backup-master + +## Компоненты + +``` +Старый сервер Сервер бэкапов Новый сервер +backupctl backup ──PUT части──► backupctl server ◄── backupctl restore + (systemd timer) SQLite + файлы, веб-морда provision.sh → recipes → распаковка → сервисы + ретеншн, ntfy +``` + +| Пакет | Назначение | +|---|---| +| `internal/manifest` | формат манифеста бэкапа (общий для всех) | +| `internal/catalog` | категории бэкапа: встроенный `default.yaml` + переопределения клиента | +| `internal/archive` | tar (PAX) + zstd: запись, распаковка, исключения, владельцы по имени | +| `internal/api` | протокол и HTTP-клиент | +| `internal/agent` | сбор: files / systemd / xui / compose, дампы, проверка crontab, отправка | +| `internal/restore` | восстановление: provision.sh, recipes бинарников, compose, порядок категорий | +| `internal/server` | API, веб-морда, ретеншн, уведомления | + +## Формат архива + +Каждая часть — `tar.zst`. В main первой записью идёт `manifest.json`. Пути внутри архива: + +- `/files/<абсолютный путь>` — восстанавливается в `/`; +- `/dumps/<файл>` — дампы БД и снимок x-ui.db; +- `/volumes/<том>/…` — именованные docker-тома; +- `/bin/<абсолютный путь>` — fallback-копии бинарников. + +Heavy-часть называется именем категории и устроена так же. Main без неё самодостаточен. + +## Протокол + +| Метод | Токен | Назначение | +|---|---|---| +| `GET /api/v1/agent/config` | агент | домен, переопределения каталога, пора ли слать heavy | +| `POST /api/v1/backups` | агент | манифест → id | +| `PUT /api/v1/backups/{id}/parts/{name}` | агент | потоковая загрузка части | +| `POST /api/v1/backups/{id}/complete` | агент | sha256/размеры частей + предупреждения архивации | +| `POST /api/v1/backups/{id}/fail` | агент | ошибка | +| `GET /api/v1/restore/manifest[?backup=]` | restore | манифест и доступные части | +| `GET /api/v1/restore/{id}/parts/{name}` | restore | скачивание (Range — докачка) | + +Агентский токен даёт только запись: если клиента взломают, злоумышленник не сможет прочитать или удалить его историю. Restore-токен выпускается в морде на 24 часа и даёт только чтение бэкапов одного клиента. В БД хранятся только sha256 токенов. + +Агент архивирует часть прямо в тело PUT (`io.Pipe`), временный архив на диске не создаётся. Дампы БД снимаются **до** архивации. Поэтому в Stoat порядок «сначала mongodump, потом файлы MinIO» соблюдается сам собой: файлы, загруженные во время бэкапа, станут лишними объектами без ссылок, но ссылок на отсутствующие файлы не появится. + +## Compose + +- Проекты берутся из `docker compose ls -a`, к ним добавляются явные `dir` из каталога. Сервисы, bind-mounts и тома — из `docker compose config --format json`. +- БД определяются по образу. Если контейнер запущен, делается дамп через `docker exec`: учётные данные берутся из окружения контейнера, а каталог данных исключается. Если остановлен — каталог данных копируется как есть (он консистентен). +- Данные `rabbitmq` и сервисов из `skip_services` не бэкапятся. +- Записываются digest запущенных образов. При восстановлении создаётся `compose.restore.yml` с `image: repo@sha256:…`, потому что `latest` на новом сервере дал бы другую Mongo/MinIO. +- Отсутствующие external-сети создаются пустыми. Так stoat-mbot поднимается без psiphon. +- Порядок восстановления: `pull` → `build` → `create` → для каждой БД `up -d ` → ожидание TCP-готовности → заливка дампа → `up -d`. + +## Версии + +| Что | Как записывается | Как восстанавливается | +|---|---|---| +| apt-пакеты (caddy, telegraf, docker-ce, docker-compose-plugin) | `dpkg-query` | `provision.sh` ставит `pkg=version`, если версия есть в репозитории | +| 3x-ui, nebula, sing-box-extended | `version_cmd` | recipes в `internal/restore/recipes.go`: release-архивы нужного тега; при неудаче — копия из `bin/` | +| образы compose | digest | `compose.restore.yml` | + +## Расширение + +- Новая категория — запись в `default.yaml` или в YAML клиента в морде. +- Новый способ установки бинарника — функция в `recipes`, имя указывается в поле `install` категории. +- Новый тип БД в compose — `dbKind`/`dumpCmds` в `internal/agent/compose.go` и `dbReady`/`dbRestore` в `internal/restore/compose.go`. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..040693b --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module gitea.hsrv.site/mrleo1nid/backup-master + +go 1.26.0 + +require ( + github.com/klauspost/compress v1.20.1 + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.60.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.48.0 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d28e957 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/agent/collect.go b/internal/agent/collect.go new file mode 100644 index 0000000..603caf4 --- /dev/null +++ b/internal/agent/collect.go @@ -0,0 +1,271 @@ +package agent + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/archive" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx" +) + +// item — что положить в архив: обход Src с именами Name(path) и исключениями Skip. +type item struct { + Src string + Name func(p string) string + Skip *archive.Matcher +} + +// Plan — результат сбора: манифест, содержимое частей и временные файлы дампов. +type Plan struct { + Manifest manifest.Manifest + Parts map[string][]item // имя части → элементы; порядок частей — Manifest.Parts +} + +type collector struct { + ctx context.Context + cat *catalog.Config + spool string + heavyDue bool + global *archive.Matcher + plan *Plan +} + +func (c *collector) warn(format string, a ...any) { + msg := fmt.Sprintf(format, a...) + c.plan.Manifest.Warnings = append(c.plan.Manifest.Warnings, msg) +} + +func (c *collector) add(part string, it item) { + if _, ok := c.plan.Parts[part]; !ok { + c.plan.Manifest.Parts = append(c.plan.Manifest.Parts, part) + } + c.plan.Parts[part] = append(c.plan.Parts[part], it) +} + +func filesItem(cat, src string, skip *archive.Matcher) item { + prefix := manifest.FilesPrefix(cat) + return item{Src: src, Skip: skip, Name: func(p string) string { + return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/") + }} +} + +func binItem(cat, src string) item { + prefix := manifest.BinPrefix(cat) + return item{Src: src, Name: func(p string) string { + return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/") + }} +} + +func dumpItem(cat, src, name string) item { + n := manifest.DumpsPrefix(cat) + name + return item{Src: src, Name: func(string) string { return n }} +} + +func treeItem(src, prefix string, skip *archive.Matcher) item { + return item{Src: src, Skip: skip, Name: func(p string) string { + rel, err := filepath.Rel(src, p) + if err != nil || rel == "." { + return strings.TrimSuffix(prefix, "/") + } + return prefix + filepath.ToSlash(rel) + }} +} + +// Collect собирает план бэкапа по каталогу: выполняет дампы в spool, определяет версии. +func Collect(ctx context.Context, cat *catalog.Config, spool string, heavyDue bool) (*Plan, error) { + c := &collector{ + ctx: ctx, + cat: cat, + spool: spool, + heavyDue: heavyDue, + global: archive.NewMatcher(cat.Exclude), + plan: &Plan{Parts: map[string][]item{}}, + } + c.plan.Parts[manifest.MainPart] = nil + c.plan.Manifest.Parts = []string{manifest.MainPart} + c.fillSystem() + + var composeSpecs []catalog.Spec + for _, s := range cat.Categories { + if s.Disabled { + continue + } + if s.Type == catalog.TypeCompose { + composeSpecs = append(composeSpecs, s) + continue + } + mc, err := c.collectSpec(s) + if err != nil { + return nil, fmt.Errorf("категория %s: %w", s.Name, err) + } + if mc != nil { + c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc) + } + } + if err := c.collectCompose(composeSpecs); err != nil { + return nil, err + } + c.checkCron() + return c.plan, nil +} + +func (c *collector) fillSystem() { + m := &c.plan.Manifest + m.FormatVersion = manifest.FormatVersion + m.CreatedAt = time.Now() + if h, err := sysx.Output(c.ctx, "hostnamectl", "hostname"); err == nil && h != "" { + m.Hostname = h + } else { + m.Hostname, _ = os.Hostname() + } + osr := sysx.OSRelease() + m.OS = manifest.OSInfo{ID: osr["ID"], VersionID: osr["VERSION_ID"], PrettyName: osr["PRETTY_NAME"], Arch: sysx.DpkgArch(c.ctx)} + m.Packages = map[string]string{} + for _, p := range []string{"caddy", "telegraf", "docker-ce", "docker-ce-cli", "containerd.io", "docker-compose-plugin", "docker-buildx-plugin"} { + if v := sysx.DpkgVersion(c.ctx, p); v != "" { + m.Packages[p] = v + } + } + m.Features = manifest.Features{ + Blocklist: sysx.Exists("/etc/cron.d/ban-blacklist"), + SSHHardened: sysx.Exists("/etc/ssh/sshd_config.d/00-hardening.conf"), + } + m.SudoUsers = sysx.GroupMembers("sudo") +} + +// collectSpec обрабатывает категории files/systemd/xui. nil — категория неактивна. +func (c *collector) collectSpec(s catalog.Spec) (*manifest.Category, error) { + if len(s.Detect) > 0 && !anyExists(s.Detect) { + return nil, nil + } + mc := &manifest.Category{ + Name: s.Name, + Type: s.Type, + Install: s.Install, + EnsureDirs: s.EnsureDirs, + PostRestore: s.PostRestore, + RestoreAfter: s.RestoreAfter, + } + skip := archive.NewMatcher(c.cat.Exclude, s.Exclude) + for _, p := range s.Paths { + if !sysx.Exists(p) { + continue + } + mc.Paths = append(mc.Paths, p) + c.add(manifest.MainPart, filesItem(s.Name, p, skip)) + } + + if s.Type == catalog.TypeXUI { + if err := c.snapshotXUI(mc); err != nil { + return nil, err + } + } + for _, d := range s.Dumps { + if err := c.cmdDump(s.Name, d, mc); err != nil { + return nil, err + } + } + if len(mc.Paths) == 0 && len(mc.Dumps) == 0 { + return nil, nil + } + + for _, b := range s.KeepBinaries { + if sysx.Exists(b) { + mc.Binaries = append(mc.Binaries, b) + c.add(manifest.MainPart, binItem(s.Name, b)) + } + } + if len(s.VersionCmd) > 0 && sysx.Exists(s.VersionCmd[0]) { + mc.Version = c.version(s) + } + if s.Service != "" && sysx.UnitLoaded(c.ctx, s.Service) { + en, ac := sysx.UnitState(c.ctx, s.Service) + mc.Service = &manifest.ServiceState{Unit: s.Service, Enabled: en, Active: ac} + } + return mc, nil +} + +var defaultVersionRe = regexp.MustCompile(`v?(\d+\.\d+(?:\.\d+)?[\w.+-]*)`) + +func (c *collector) version(s catalog.Spec) string { + ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second) + defer cancel() + out, err := sysx.Output(ctx, s.VersionCmd[0], s.VersionCmd[1:]...) + if err != nil { + c.warn("%s: не удалось определить версию: %v", s.Name, err) + return "" + } + re := defaultVersionRe + if s.VersionRegex != "" { + if r, err := regexp.Compile(s.VersionRegex); err == nil { + re = r + } + } + if m := re.FindStringSubmatch(out); len(m) > 1 { + return m[1] + } + c.warn("%s: версия не распознана в выводе %q", s.Name, out) + return "" +} + +func (c *collector) spoolDir(cat string) (string, error) { + d := filepath.Join(c.spool, cat) + return d, os.MkdirAll(d, 0o700) +} + +func (c *collector) cmdDump(cat string, d catalog.CmdDump, mc *manifest.Category) error { + dir, err := c.spoolDir(cat) + if err != nil { + return err + } + out := filepath.Join(dir, d.Name) + if err := sysx.ToFile(c.ctx, out, "bash", "-o", "pipefail", "-c", d.Cmd); err != nil { + c.warn("%s: дамп %s не удался: %v", cat, d.Name, err) + return nil + } + mc.Dumps = append(mc.Dumps, manifest.Dump{Name: d.Name, Kind: "cmd", RestoreCmd: d.RestoreCmd}) + c.add(manifest.MainPart, dumpItem(cat, out, d.Name)) + return nil +} + +func anyExists(paths []string) bool { + for _, p := range paths { + if sysx.Exists(p) { + return true + } + } + return false +} + +// treeSize считает размер дерева с учётом исключений (для plan). +func treeSize(src string, skip *archive.Matcher) (int64, int) { + var size int64 + var n int + _ = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if skip.Match(filepath.ToSlash(p)) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.Type().IsRegular() { + if fi, err := d.Info(); err == nil { + size += fi.Size() + n++ + } + } + return nil + }) + return size, n +} diff --git a/internal/agent/compose.go b/internal/agent/compose.go new file mode 100644 index 0000000..5227cd1 --- /dev/null +++ b/internal/agent/compose.go @@ -0,0 +1,462 @@ +package agent + +import ( + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "slices" + "strings" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/archive" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx" +) + +type lsEntry struct { + Name string + Status string + ConfigFiles string +} + +func (e lsEntry) files() []string { + var out []string + for _, f := range strings.Split(e.ConfigFiles, ",") { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} + +func (e lsEntry) dir() string { + if fs := e.files(); len(fs) > 0 { + return filepath.Dir(fs[0]) + } + return "" +} + +type composeConfig struct { + Name string `json:"name"` + Services map[string]composeService `json:"services"` + Volumes map[string]struct { + Name string `json:"name"` + External bool `json:"external"` + } `json:"volumes"` + Networks map[string]struct { + Name string `json:"name"` + External bool `json:"external"` + } `json:"networks"` +} + +type composeService struct { + Image string `json:"image"` + Build json.RawMessage `json:"build"` + Volumes []struct { + Type string `json:"type"` + Source string `json:"source"` + Target string `json:"target"` + } `json:"volumes"` +} + +type container struct { + ID string + Service string + Running bool +} + +// Пути, которые не имеет смысла бэкапить как bind-mount. +var systemMounts = []string{"/run", "/var/run", "/proc", "/sys", "/dev", "/etc/localtime", "/etc/timezone", + "/var/lib/docker", "/usr", "/lib", "/bin", "/sbin", "/tmp"} + +var composeFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"} +var overrideFileNames = []string{"compose.override.yaml", "compose.override.yml", "docker-compose.override.yaml", "docker-compose.override.yml"} + +func findComposeFiles(dir string) []string { + var out []string + for _, group := range [][]string{composeFileNames, overrideFileNames} { + for _, n := range group { + if p := filepath.Join(dir, n); sysx.Exists(p) { + out = append(out, p) + break + } + } + } + if len(out) > 0 && !slices.Contains(composeFileNames, filepath.Base(out[0])) { + return nil // только override без основного файла + } + return out +} + +func (c *collector) composeLS() []lsEntry { + if !sysx.HasCommand("docker") { + return nil + } + out, err := sysx.Output(c.ctx, "docker", "compose", "ls", "-a", "--format", "json") + if err != nil { + c.warn("docker compose ls: %v", err) + return nil + } + var entries []lsEntry + if err := json.Unmarshal([]byte(out), &entries); err != nil { + c.warn("docker compose ls: %v", err) + } + return entries +} + +var nonNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +// collectCompose обрабатывает compose-категории каталога и автонайденные проекты. +func (c *collector) collectCompose(specs []catalog.Spec) error { + entries := c.composeLS() + used := map[string]bool{} + taken := map[string]bool{} + for _, mc := range c.plan.Manifest.Categories { + taken[mc.Name] = true + } + // отключённые категории тоже «занимают» свои проекты, чтобы не попасть в авто + for _, s := range c.cat.Categories { + if s.Type != catalog.TypeCompose || !s.Disabled { + continue + } + for _, e := range entries { + if e.Name == s.Project || (s.Dir != "" && e.dir() == filepath.Clean(s.Dir)) { + used[e.Name] = true + } + } + } + + type job struct { + spec catalog.Spec + entry *lsEntry + auto bool + } + var jobs []job + for _, s := range specs { + var match *lsEntry + for i, e := range entries { + if (s.Project != "" && e.Name == s.Project) || (s.Dir != "" && e.dir() == filepath.Clean(s.Dir)) { + match = &entries[i] + used[e.Name] = true + break + } + } + if match == nil && (s.Dir == "" || len(findComposeFiles(s.Dir)) == 0) { + continue // проекта на этом сервере нет + } + jobs = append(jobs, job{spec: s, entry: match}) + } + if c.cat.AutoComposeEnabled() { + for i, e := range entries { + if used[e.Name] || e.dir() == "" { + continue + } + name := nonNameRe.ReplaceAllString(e.Name, "-") + if taken[name] { + name = "compose-" + name + } + jobs = append(jobs, job{spec: catalog.Spec{Name: name, Type: catalog.TypeCompose, Dir: e.dir(), Project: e.Name}, entry: &entries[i], auto: true}) + } + } + for _, j := range jobs { + mc, err := c.composeProject(j.spec, j.entry) + if err != nil { + return fmt.Errorf("compose %s: %w", j.spec.Name, err) + } + mc.Auto = j.auto + c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc) + } + return nil +} + +func (c *collector) composeProject(s catalog.Spec, e *lsEntry) (*manifest.Category, error) { + mc := &manifest.Category{Name: s.Name, Type: catalog.TypeCompose, PostRestore: s.PostRestore, RestoreAfter: s.RestoreAfter} + info := &manifest.Compose{Dir: filepath.Clean(s.Dir), Project: s.Project} + if e != nil { + info.Project = e.Name + info.ConfigFiles = e.files() + if info.Dir == "." || info.Dir == "" { + info.Dir = e.dir() + } + } else { + info.ConfigFiles = findComposeFiles(info.Dir) + } + mc.Compose = info + dir := info.Dir + dirSlash := filepath.ToSlash(dir) + + skip := archive.NewMatcher(c.cat.Exclude) + for _, p := range s.Exclude { + skip.Add(absUnder(dirSlash, p)) + } + var heavy []string + for _, p := range s.HeavyPaths { + h := absUnder(dirSlash, p) + skip.Add(h) // heavy никогда не попадает в main + if sysx.Exists(h) { + heavy = append(heavy, h) + } + } + + cfg, err := c.composeConfig(info) + if err != nil { + c.warn("%s: docker compose config не удался, бэкаплю только каталог: %v", s.Name, err) + } + var containers map[string]container + if cfg != nil { + if info.Project == "" { + info.Project = cfg.Name + } + containers = c.composeContainers(info.Project) + for _, ct := range containers { + if ct.Running { + info.Running = true + } + } + for _, n := range cfg.Networks { + if n.External && n.Name != "" { + info.ExternalNetworks = append(info.ExternalNetworks, n.Name) + } + } + slices.Sort(info.ExternalNetworks) + info.Images = map[string]manifest.Image{} + + names := make([]string, 0, len(cfg.Services)) + for name := range cfg.Services { + names = append(names, name) + } + slices.Sort(names) + var extraBinds []string + for _, name := range names { + svc := cfg.Services[name] + ct, hasCt := containers[name] + img := manifest.Image{Ref: svc.Image, Built: len(svc.Build) > 0 && string(svc.Build) != "null"} + if hasCt && !img.Built { + img.Digest = c.imageDigest(ct.ID) + } + info.Images[name] = img + + kind := dbKind(svc.Image) + skipData := slices.Contains(s.SkipServices, name) || imageName(svc.Image) == "rabbitmq" + dumped := false + if kind != "" { + if hasCt && ct.Running { + if err := c.dbDump(mc, name, kind, ct.ID); err != nil { + return nil, err + } + dumped = true + } else { + c.warn("%s: контейнер БД %s не запущен — каталог данных копируется как есть", s.Name, name) + } + } + for _, v := range svc.Volumes { + switch v.Type { + case "bind": + src := filepath.ToSlash(v.Source) + if dumped || skipData { + skip.Add(src) + continue + } + if !archive.Covers([]string{dirSlash}, src) && !archive.Covers(systemMounts, src) && !strings.HasSuffix(src, "docker.sock") { + extraBinds = append(extraBinds, v.Source) + } + case "volume": + if dumped || skipData || v.Source == "" { + continue + } + vol := cfg.Volumes[v.Source] + real := vol.Name + if real == "" { + real = info.Project + "_" + v.Source + } + key := v.Source + if vol.External { + key = "" + } + c.addVolume(mc, real, key, skip) + } + } + } + for _, b := range extraBinds { + if sysx.Exists(b) && !archive.Covers(mc.Paths, filepath.ToSlash(b)) { + mc.Paths = append(mc.Paths, b) + c.add(manifest.MainPart, filesItem(s.Name, b, skip)) + } + } + } + + mc.Paths = append([]string{dir}, mc.Paths...) + c.add(manifest.MainPart, filesItem(s.Name, dir, skip)) + + if len(heavy) > 0 { + mc.Heavy = heavy + if c.heavyDue { + for _, h := range heavy { + c.add(s.Name, filesItem(s.Name, h, archive.NewMatcher(c.cat.Exclude))) + } + } else { + mc.HeavySkipped = true + } + } + info.Git = c.gitInfo(dir) + return mc, nil +} + +func absUnder(dir, p string) string { + p = filepath.ToSlash(p) + if strings.HasPrefix(p, "/") { + return p + } + return path.Join(dir, p) +} + +func (c *collector) composeArgs(info *manifest.Compose) []string { + args := []string{"compose", "--project-directory", info.Dir} + if info.Project != "" { + args = append(args, "-p", info.Project) + } + for _, f := range info.ConfigFiles { + args = append(args, "-f", f) + } + return args +} + +func (c *collector) composeConfig(info *manifest.Compose) (*composeConfig, error) { + if !sysx.HasCommand("docker") { + return nil, fmt.Errorf("docker не установлен") + } + if len(info.ConfigFiles) == 0 { + return nil, fmt.Errorf("в %s нет compose-файла", info.Dir) + } + out, err := sysx.Output(c.ctx, "docker", append(c.composeArgs(info), "config", "--format", "json")...) + if err != nil { + return nil, err + } + var cfg composeConfig + if err := json.Unmarshal([]byte(out), &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func (c *collector) composeContainers(project string) map[string]container { + out, err := sysx.Output(c.ctx, "docker", "ps", "-a", "--filter", "label=com.docker.compose.project="+project, + "--format", `{{.ID}} {{.Label "com.docker.compose.service"}} {{.State}}`) + res := map[string]container{} + if err != nil { + c.warn("docker ps %s: %v", project, err) + return res + } + for _, line := range strings.Split(out, "\n") { + f := strings.Split(line, "\t") + if len(f) != 3 || f[1] == "" { + continue + } + ct := container{ID: f[0], Service: f[1], Running: f[2] == "running"} + // при нескольких репликах важнее запущенная + if prev, ok := res[ct.Service]; !ok || (!prev.Running && ct.Running) { + res[ct.Service] = ct + } + } + return res +} + +func (c *collector) imageDigest(containerID string) string { + imgID, err := sysx.Output(c.ctx, "docker", "inspect", "-f", "{{.Image}}", containerID) + if err != nil { + return "" + } + out, err := sysx.Output(c.ctx, "docker", "image", "inspect", "-f", "{{json .RepoDigests}}", imgID) + if err != nil { + return "" + } + var digests []string + if json.Unmarshal([]byte(out), &digests) != nil || len(digests) == 0 { + return "" + } + return digests[0] +} + +func (c *collector) addVolume(mc *manifest.Category, name, key string, skip *archive.Matcher) { + for _, v := range mc.Volumes { + if v.Name == name { + return + } + } + mp, err := sysx.Output(c.ctx, "docker", "volume", "inspect", "-f", "{{.Mountpoint}}", name) + if err != nil || mp == "" { + c.warn("%s: том %s не найден", mc.Name, name) + return + } + mc.Volumes = append(mc.Volumes, manifest.Volume{Name: name, Key: key}) + c.add(manifest.MainPart, treeItem(mp, manifest.VolumesPrefix(mc.Name)+name+"/", skip)) +} + +// imageName — последний компонент имени образа без тега и digest: docker.io/library/mongo:7 → mongo. +func imageName(image string) string { + image, _, _ = strings.Cut(image, "@") + if i := strings.LastIndex(image, "/"); i >= 0 { + image = image[i+1:] + } + image, _, _ = strings.Cut(image, ":") + return strings.ToLower(image) +} + +func dbKind(image string) string { + switch imageName(image) { + case "postgres", "postgresql", "postgis": + return "postgres" + case "mongo", "mongodb": + return "mongo" + case "mysql": + return "mysql" + case "mariadb": + return "mariadb" + } + return "" +} + +// Команды дампа выполняются внутри контейнера: учётные данные берутся из его окружения. +var dumpCmds = map[string]struct{ ext, cmd string }{ + "postgres": {"pgdump", `exec pg_dump -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" -Fc`}, + "mongo": {"mongo.archive.gz", `if [ -n "$MONGO_INITDB_ROOT_USERNAME" ]; then exec mongodump --quiet --archive --gzip -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else exec mongodump --quiet --archive --gzip; fi`}, + "mysql": {"sql", `exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction --routines --events --triggers`}, + "mariadb": {"sql", `D=$(command -v mariadb-dump || command -v mysqldump); exec "$D" -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --all-databases --single-transaction --routines --events --triggers`}, +} + +func (c *collector) dbDump(mc *manifest.Category, service, kind, containerID string) error { + dc := dumpCmds[kind] + dir, err := c.spoolDir(mc.Name) + if err != nil { + return err + } + name := service + "." + dc.ext + out := filepath.Join(dir, name) + if err := sysx.ToFile(c.ctx, out, "docker", "exec", containerID, "sh", "-c", dc.cmd); err != nil { + return fmt.Errorf("дамп %s (%s): %w", service, kind, err) + } + if fi, err := os.Stat(out); err != nil || fi.Size() == 0 { + return fmt.Errorf("дамп %s (%s) пустой", service, kind) + } + mc.Dumps = append(mc.Dumps, manifest.Dump{Name: name, Kind: kind, Service: service}) + c.add(manifest.MainPart, dumpItem(mc.Name, out, name)) + return nil +} + +func (c *collector) gitInfo(dir string) *manifest.GitInfo { + if !sysx.Exists(filepath.Join(dir, ".git")) || !sysx.HasCommand("git") { + return nil + } + git := func(args ...string) string { + out, _ := sysx.Output(c.ctx, "git", append([]string{"-c", "safe.directory=*", "-C", dir}, args...)...) + return out + } + commit := git("rev-parse", "HEAD") + if commit == "" { + return nil + } + return &manifest.GitInfo{Remote: git("remote", "get-url", "origin"), Commit: commit, Dirty: git("status", "--porcelain") != ""} +} diff --git a/internal/agent/config.go b/internal/agent/config.go new file mode 100644 index 0000000..54f750e --- /dev/null +++ b/internal/agent/config.go @@ -0,0 +1,35 @@ +// Package agent собирает бэкап на клиенте и отправляет его на сервер. +package agent + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +const DefaultConfigPath = "/etc/backup-agent/agent.yaml" + +type Config struct { + Server string `yaml:"server"` + Token string `yaml:"token"` + SpoolDir string `yaml:"spool_dir"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var c Config + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if c.Server == "" || c.Token == "" { + return nil, fmt.Errorf("%s: нужны server и token", path) + } + if c.SpoolDir == "" { + c.SpoolDir = "/var/tmp/backup-agent" + } + return &c, nil +} diff --git a/internal/agent/cron.go b/internal/agent/cron.go new file mode 100644 index 0000000..4bd847b --- /dev/null +++ b/internal/agent/cron.go @@ -0,0 +1,87 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/archive" +) + +// Каталоги, скрипты из которых ставятся пакетами и в бэкапе не нужны. +var systemBinDirs = []string{"/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/lib", "/usr/share", "/lib", "/etc/cron.daily", + "/etc/cron.hourly", "/etc/cron.weekly", "/etc/cron.monthly", "/usr/libexec"} + +// checkCron ищет в crontab упоминания скриптов, не покрытых ни одной категорией. +func (c *collector) checkCron() { + var files []string + for _, pattern := range []string{"/var/spool/cron/crontabs/*", "/etc/cron.d/*", "/etc/crontab"} { + m, _ := filepath.Glob(pattern) + files = append(files, m...) + } + var covered []string + for _, mc := range c.plan.Manifest.Categories { + for _, p := range mc.Paths { + covered = append(covered, filepath.ToSlash(p)) + } + } + reported := map[string]bool{} + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + continue + } + for _, p := range cronPaths(string(data)) { + if reported[p] || archive.Covers(covered, p) || archive.Covers(systemBinDirs, p) { + continue + } + fi, err := os.Stat(p) + if err != nil || !fi.Mode().IsRegular() { + continue + } + reported[p] = true + c.warn("cron (%s) запускает %s, но он не попадает в бэкап — добавьте путь в категорию", f, p) + } + } +} + +// cronPaths извлекает абсолютные пути из команд crontab, кроме целей перенаправления вывода. +func cronPaths(text string) []string { + var out []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 && strings.Contains(fields[0], "=") && !strings.HasPrefix(fields[0], "/") { + // присваивание переменной (SHELL=/bin/bash) в отдельной строке + if len(fields) == 1 { + continue + } + } + redirect := false + for _, tok := range fields { + if strings.HasSuffix(tok, ">") || strings.HasSuffix(tok, "<") { + redirect = true + continue + } + if redirect { + redirect = false + continue + } + // "2>/dev/null", ">>file" — перенаправление слитно с целью + if i := strings.IndexAny(tok, "<>"); i >= 0 { + continue + } + if _, v, ok := strings.Cut(tok, "="); ok { + tok = v + } + tok = strings.Trim(tok, `"';&|()`) + if strings.HasPrefix(tok, "/") && !strings.ContainsAny(tok, "*$") { + out = append(out, tok) + } + } + } + return out +} diff --git a/internal/agent/cron_test.go b/internal/agent/cron_test.go new file mode 100644 index 0000000..6660b06 --- /dev/null +++ b/internal/agent/cron_test.go @@ -0,0 +1,44 @@ +package agent + +import ( + "slices" + "testing" +) + +func TestCronPaths(t *testing.T) { + text := `# m h dom mon dow command +SHELL=/bin/bash +17 4 * * * RESTART_CMD='x-ui restart' /usr/local/bin/update-geosite.sh >> /var/log/ipcheck-update.log 2>&1 +0 5 * * 1 /var/scripts/update-geodata.sh >> /var/log/update-geodata.log 2>&1 +@reboot sleep 60 && bash /usr/local/sbin/ban.sh +0 */6 * * * bash /usr/local/sbin/ban.sh >/dev/null 2>&1 +` + got := cronPaths(text) + for _, want := range []string{"/usr/local/bin/update-geosite.sh", "/var/scripts/update-geodata.sh", "/usr/local/sbin/ban.sh"} { + if !slices.Contains(got, want) { + t.Errorf("нет %s в %v", want, got) + } + } + for _, bad := range []string{"/var/log/ipcheck-update.log", "/var/log/update-geodata.log", "/dev/null", "/bin/bash"} { + if slices.Contains(got, bad) { + t.Errorf("лишний путь %s в %v", bad, got) + } + } +} + +func TestDBKind(t *testing.T) { + cases := map[string]string{ + "postgres:17-alpine": "postgres", + "docker.io/mongo": "mongo", + "docker.io/library/mongo@sha256:abc": "mongo", + "mariadb:11": "mariadb", + "mongo-express": "", + "ghcr.io/stoatchat/api:v0.15.5": "", + "valkey/valkey:9-alpine": "", + } + for img, want := range cases { + if got := dbKind(img); got != want { + t.Errorf("dbKind(%q) = %q, want %q", img, got, want) + } + } +} diff --git a/internal/agent/lock_linux.go b/internal/agent/lock_linux.go new file mode 100644 index 0000000..f85c71a --- /dev/null +++ b/internal/agent/lock_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package agent + +import ( + "fmt" + "os" + "syscall" +) + +// lock не даёт запустить два бэкапа одновременно (таймер + ручной запуск). +func lock(path string) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, fmt.Errorf("бэкап уже выполняется (%s)", path) + } + return func() { f.Close() }, nil +} diff --git a/internal/agent/lock_other.go b/internal/agent/lock_other.go new file mode 100644 index 0000000..3488d71 --- /dev/null +++ b/internal/agent/lock_other.go @@ -0,0 +1,5 @@ +//go:build !linux + +package agent + +func lock(string) (func(), error) { return func() {}, nil } diff --git a/internal/agent/run.go b/internal/agent/run.go new file mode 100644 index 0000000..c9c4f8d --- /dev/null +++ b/internal/agent/run.go @@ -0,0 +1,356 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/api" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/archive" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" +) + +type Options struct { + ConfigPath string + AgentVersion string + // Output — записать части локально в каталог вместо отправки (для отладки). + Output string + // ForceHeavy — включить heavy-части независимо от расписания. + ForceHeavy bool +} + +// prepare загружает конфиг, получает переопределения с сервера и собирает план. +func prepare(ctx context.Context, opts Options, spool string) (*Plan, *api.Client, error) { + cfg, err := LoadConfig(opts.ConfigPath) + if err != nil { + return nil, nil, err + } + client := api.NewClient(cfg.Server, cfg.Token) + remote, err := client.AgentConfig(ctx) + if err != nil { + return nil, nil, fmt.Errorf("конфиг с сервера: %w", err) + } + cat, err := catalog.Default() + if err != nil { + return nil, nil, err + } + if strings.TrimSpace(remote.ConfigYAML) != "" { + over, err := catalog.Parse([]byte(remote.ConfigYAML)) + if err != nil { + return nil, nil, fmt.Errorf("конфиг клиента с сервера: %w", err) + } + cat = catalog.Merge(cat, over) + } + if err := cat.Validate(); err != nil { + return nil, nil, err + } + if spool == "" { + spool = cfg.SpoolDir + } + plan, err := Collect(ctx, cat, spool, remote.HeavyDue || opts.ForceHeavy) + if err != nil { + return nil, nil, err + } + plan.Manifest.Domain = remote.Domain + plan.Manifest.AgentVersion = opts.AgentVersion + return plan, client, nil +} + +func newSpool(base string) (string, error) { + dir := filepath.Join(base, time.Now().Format("20060102-150405")) + return dir, os.MkdirAll(dir, 0o700) +} + +// PrintPlan выводит, что попадёт в бэкап. Дампы БД при этом реально выполняются (во временный каталог). +func PrintPlan(ctx context.Context, opts Options, w io.Writer) error { + cfg, err := LoadConfig(opts.ConfigPath) + if err != nil { + return err + } + spool, err := newSpool(cfg.SpoolDir) + if err != nil { + return err + } + defer os.RemoveAll(spool) + plan, _, err := prepare(ctx, opts, spool) + if err != nil { + return err + } + m := &plan.Manifest + fmt.Fprintf(w, "Домен: %s, hostname: %s, %s (%s)\n", m.Domain, m.Hostname, m.OS.PrettyName, m.OS.Arch) + if len(m.Packages) > 0 { + fmt.Fprintf(w, "Пакеты:") + for k, v := range m.Packages { + fmt.Fprintf(w, " %s=%s", k, v) + } + fmt.Fprintln(w) + } + for _, c := range m.Categories { + line := fmt.Sprintf("\n[%s] %s", c.Name, c.Type) + if c.Auto { + line += " (найден автоматически)" + } + if c.Version != "" { + line += ", версия " + c.Version + } + if c.Service != nil { + line += fmt.Sprintf(", сервис %s enabled=%v active=%v", c.Service.Unit, c.Service.Enabled, c.Service.Active) + } + fmt.Fprintln(w, line) + if c.Compose != nil { + fmt.Fprintf(w, " проект %s, запущен=%v\n", c.Compose.Project, c.Compose.Running) + for svc, img := range c.Compose.Images { + ref := img.Digest + if img.Built { + ref = "собирается из исходников" + } else if ref == "" { + ref = img.Ref + " (digest не определён)" + } + fmt.Fprintf(w, " образ %s: %s\n", svc, ref) + } + } + for _, d := range c.Dumps { + fmt.Fprintf(w, " дамп %s %s\n", d.Kind, d.Name) + } + for _, v := range c.Volumes { + fmt.Fprintf(w, " том %s\n", v.Name) + } + for _, h := range c.Heavy { + note := "в этот раз" + if c.HeavySkipped { + note = "по расписанию, не в этот раз" + } + fmt.Fprintf(w, " heavy %s (%s)\n", h, note) + } + } + fmt.Fprintln(w, "\nСодержимое частей:") + for _, part := range m.Parts { + var total int64 + var files int + for _, it := range plan.Parts[part] { + size, n := treeSize(it.Src, it.Skip) + total += size + files += n + } + fmt.Fprintf(w, " %s: %d файлов, %s до сжатия\n", part, files, human(total)) + } + if len(m.Warnings) > 0 { + fmt.Fprintln(w, "\nПредупреждения:") + for _, wr := range m.Warnings { + fmt.Fprintln(w, " ! "+wr) + } + } + return nil +} + +// Backup выполняет полный цикл: сбор, отправка частей, подтверждение. +func Backup(ctx context.Context, opts Options) (err error) { + unlock, err := lock("/run/backup-agent.lock") + if err != nil { + return err + } + defer unlock() + + cfg, err := LoadConfig(opts.ConfigPath) + if err != nil { + return err + } + spool, err := newSpool(cfg.SpoolDir) + if err != nil { + return err + } + defer os.RemoveAll(spool) + + start := time.Now() + plan, client, err := prepare(ctx, opts, spool) + if err != nil { + return err + } + m := &plan.Manifest + log.Printf("собрано: %d категорий, части %v, предупреждений %d", len(m.Categories), m.Parts, len(m.Warnings)) + for _, w := range m.Warnings { + log.Printf("! %s", w) + } + if len(m.Categories) == 0 { + // пустой «успешный» бэкап хуже ошибки: сервер решит, что всё в порядке + return errors.New("ни одна категория не найдена на сервере — проверьте каталог (backupctl plan)") + } + + if opts.Output != "" { + return writeLocal(plan, opts.Output) + } + + id, err := client.CreateBackup(ctx, m) + if err != nil { + return fmt.Errorf("создание бэкапа на сервере: %w", err) + } + defer func() { + if err != nil { + fctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if ferr := client.Fail(fctx, id, err.Error()); ferr != nil { + log.Printf("не удалось сообщить серверу об ошибке: %v", ferr) + } + } + }() + + var done api.CompleteRequest + for _, part := range m.Parts { + info, warns, err := uploadWithRetry(ctx, client, id, plan, part) + if err != nil { + return fmt.Errorf("часть %s: %w", part, err) + } + done.Parts = append(done.Parts, *info) + done.Warnings = append(done.Warnings, warns...) + log.Printf("часть %s отправлена: %s", part, human(info.Size)) + } + for _, w := range done.Warnings { + log.Printf("! %s", w) + } + if err := client.Complete(ctx, id, done); err != nil { + return fmt.Errorf("подтверждение: %w", err) + } + log.Printf("бэкап #%d готов за %s", id, time.Since(start).Round(time.Second)) + return nil +} + +func uploadWithRetry(ctx context.Context, client *api.Client, id int64, plan *Plan, part string) (*api.PartInfo, []string, error) { + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + if attempt > 1 { + wait := time.Duration(attempt*attempt) * 20 * time.Second + log.Printf("часть %s: попытка %d через %s (%v)", part, attempt, wait, lastErr) + select { + case <-ctx.Done(): + return nil, nil, ctx.Err() + case <-time.After(wait): + } + } + info, warns, err := uploadPart(ctx, client, id, plan, part) + if err == nil { + return info, warns, nil + } + lastErr = err + var apiErr *api.Error + if errors.As(err, &apiErr) && apiErr.Status >= 400 && apiErr.Status < 500 && apiErr.Status != 408 && apiErr.Status != 429 { + break // повтор не поможет + } + } + return nil, nil, lastErr +} + +// uploadPart архивирует часть прямо в тело PUT-запроса, параллельно считая sha256. +func uploadPart(ctx context.Context, client *api.Client, id int64, plan *Plan, part string) (*api.PartInfo, []string, error) { + pr, pw := io.Pipe() + h := sha256.New() + cw := &countWriter{w: io.MultiWriter(pw, h)} + type result struct { + warns []string + err error + } + done := make(chan result, 1) + go func() { + warns, err := writePart(cw, plan, part) + pw.CloseWithError(err) + done <- result{warns, err} + }() + info, err := client.UploadPart(ctx, id, part, pr) + pr.CloseWithError(errors.New("загрузка прервана")) // освобождаем писателя, если сервер ответил раньше + res := <-done + if err != nil { + if res.err != nil && !errors.Is(res.err, io.ErrClosedPipe) && !strings.Contains(res.err.Error(), "загрузка прервана") { + return nil, nil, fmt.Errorf("архивация: %w", res.err) + } + return nil, nil, err + } + if res.err != nil { + return nil, nil, fmt.Errorf("архивация: %w", res.err) + } + warns := res.warns + sum := hex.EncodeToString(h.Sum(nil)) + if info.SHA256 != sum || info.Size != cw.n { + return nil, nil, fmt.Errorf("сервер получил %d байт (%s), отправлено %d (%s)", info.Size, info.SHA256, cw.n, sum) + } + return info, warns, nil +} + +func writePart(w io.Writer, plan *Plan, part string) ([]string, error) { + aw, err := archive.NewWriter(w) + if err != nil { + return nil, err + } + if part == manifest.MainPart { + data, err := json.MarshalIndent(plan.Manifest, "", " ") + if err != nil { + return nil, err + } + if err := aw.AddBytes(manifest.ManifestFile, data, 0o600); err != nil { + return nil, err + } + } + for _, it := range plan.Parts[part] { + if err := aw.AddTree(it.Src, it.Name, it.Skip); err != nil { + return aw.Warnings, err + } + } + return aw.Warnings, aw.Close() +} + +func writeLocal(plan *Plan, dir string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + for _, part := range plan.Manifest.Parts { + p := filepath.Join(dir, part+".tar.zst") + f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + warns, err := writePart(f, plan, part) + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + for _, w := range warns { + log.Printf("! %s", w) + } + log.Printf("записано %s", p) + } + return nil +} + +type countWriter struct { + w io.Writer + n int64 +} + +func (c *countWriter) Write(p []byte) (int, error) { + n, err := c.w.Write(p) + c.n += int64(n) + return n, err +} + +func human(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d Б", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp]) +} diff --git a/internal/agent/xui.go b/internal/agent/xui.go new file mode 100644 index 0000000..1915c32 --- /dev/null +++ b/internal/agent/xui.go @@ -0,0 +1,47 @@ +package agent + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" + + _ "modernc.org/sqlite" +) + +const xuiDB = "/etc/x-ui/x-ui.db" + +// snapshotXUI делает консистентную копию базы 3x-ui на горячую (VACUUM INTO), +// без логина в панель и без остановки сервиса. +func (c *collector) snapshotXUI(mc *manifest.Category) error { + if _, err := os.Stat(xuiDB); err != nil { + c.warn("xui: нет %s", xuiDB) + return nil + } + dir, err := c.spoolDir(mc.Name) + if err != nil { + return err + } + out := filepath.Join(dir, "x-ui.db") + if err := vacuumInto(xuiDB, out); err != nil { + c.warn("xui: снимок базы не удался: %v", err) + return nil + } + mc.Dumps = append(mc.Dumps, manifest.Dump{Name: "x-ui.db", Kind: "sqlite", Target: xuiDB}) + c.add(manifest.MainPart, dumpItem(mc.Name, out, "x-ui.db")) + return nil +} + +func vacuumInto(src, dst string) error { + _ = os.Remove(dst) + db, err := sql.Open("sqlite", "file:"+src+"?_pragma=busy_timeout(10000)") + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec(fmt.Sprintf("VACUUM INTO '%s'", strings.ReplaceAll(dst, "'", "''"))) + return err +} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..c7b9a03 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,161 @@ +// Package api — типы протокола сервер↔агент/restore и HTTP-клиент к нему. +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" +) + +type AgentConfig struct { + Domain string `json:"domain"` + ConfigYAML string `json:"config_yaml"` + HeavyDue bool `json:"heavy_due"` +} + +type PartInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type CompleteRequest struct { + Parts []PartInfo `json:"parts"` + // Warnings — предупреждения, возникшие при архивации (после отправки манифеста). + Warnings []string `json:"warnings,omitempty"` +} + +type RestoreManifest struct { + BackupID int64 `json:"backup_id"` + CreatedAt time.Time `json:"created_at"` + Manifest json.RawMessage `json:"manifest"` + Parts []PartInfo `json:"parts"` // только доступные (heavy могли удалить ротацией) +} + +// Client — HTTP-клиент с Bearer-токеном. +type Client struct { + Server string + Token string + HTTP *http.Client +} + +func NewClient(server, token string) *Client { + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + TLSHandshakeTimeout: 30 * time.Second, + ResponseHeaderTimeout: 10 * time.Minute, // complete на сервере может считать ретеншн + ForceAttemptHTTP2: true, + } + return &Client{Server: strings.TrimRight(server, "/"), Token: token, HTTP: &http.Client{Transport: tr}} +} + +type Error struct { + Status int + Msg string +} + +func (e *Error) Error() string { + return fmt.Sprintf("сервер ответил %d: %s", e.Status, e.Msg) +} + +func (c *Client) do(ctx context.Context, method, path string, body io.Reader, out any) error { + req, err := http.NewRequestWithContext(ctx, method, c.Server+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + if body != nil && method != http.MethodPut { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + var e struct{ Error string } + data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if json.Unmarshal(data, &e) != nil || e.Error == "" { + e.Error = strings.TrimSpace(string(data)) + } + return &Error{Status: resp.StatusCode, Msg: e.Error} + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +func jsonBody(v any) io.Reader { + data, _ := json.Marshal(v) + return bytes.NewReader(data) +} + +func (c *Client) AgentConfig(ctx context.Context) (*AgentConfig, error) { + var out AgentConfig + return &out, c.do(ctx, http.MethodGet, "/api/v1/agent/config", nil, &out) +} + +func (c *Client) CreateBackup(ctx context.Context, m *manifest.Manifest) (int64, error) { + var out struct{ ID int64 } + err := c.do(ctx, http.MethodPost, "/api/v1/backups", jsonBody(m), &out) + return out.ID, err +} + +func (c *Client) UploadPart(ctx context.Context, id int64, name string, body io.Reader) (*PartInfo, error) { + var out PartInfo + err := c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/backups/%d/parts/%s", id, name), body, &out) + return &out, err +} + +func (c *Client) Complete(ctx context.Context, id int64, req CompleteRequest) error { + return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/complete", id), jsonBody(req), nil) +} + +func (c *Client) Fail(ctx context.Context, id int64, msg string) error { + return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/fail", id), jsonBody(map[string]string{"error": msg}), nil) +} + +func (c *Client) RestoreManifest(ctx context.Context, backupID int64) (*RestoreManifest, error) { + path := "/api/v1/restore/manifest" + if backupID != 0 { + path += fmt.Sprintf("?backup=%d", backupID) + } + var out RestoreManifest + return &out, c.do(ctx, http.MethodGet, path, nil, &out) +} + +// DownloadPart открывает поток части начиная с offset (для докачки). +func (c *Client) DownloadPart(ctx context.Context, id int64, name string, offset int64) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/restore/%d/parts/%s", c.Server, id, name), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + if offset > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + want := http.StatusOK + if offset > 0 { + want = http.StatusPartialContent + } + if resp.StatusCode != want { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + return nil, &Error{Status: resp.StatusCode, Msg: strings.TrimSpace(string(data))} + } + return resp.Body, nil +} diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go new file mode 100644 index 0000000..ada62da --- /dev/null +++ b/internal/archive/archive_test.go @@ -0,0 +1,115 @@ +package archive + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestMatcher(t *testing.T) { + m := NewMatcher([]string{"/opt/stoat/data/db", "/etc/x-ui/x-ui.db*", "*.swp", "/opt/p/pgdata/"}) + cases := map[string]bool{ + "/opt/stoat/data/db": true, + "/opt/stoat/data/db/WiredTiger": true, + "/opt/stoat/data/dbx": false, + "/etc/x-ui/x-ui.db": true, + "/etc/x-ui/x-ui.db-wal": true, + "/etc/x-ui/config.json": false, + "/var/scripts/.a.sh.swp": true, + "/opt/p/pgdata/base/1": true, + "/opt/p/pgdata2": false, + } + for p, want := range cases { + if got := m.Match(p); got != want { + t.Errorf("Match(%q) = %v, want %v", p, got, want) + } + } +} + +func TestRoundtrip(t *testing.T) { + src := t.TempDir() + must(t, os.MkdirAll(filepath.Join(src, "a", "b"), 0o750)) + must(t, os.WriteFile(filepath.Join(src, "a", "b", "f.txt"), []byte("hello"), 0o640)) + must(t, os.WriteFile(filepath.Join(src, "a", "skip.swp"), []byte("x"), 0o644)) + must(t, os.MkdirAll(filepath.Join(src, "a", "pgdata"), 0o700)) + must(t, os.WriteFile(filepath.Join(src, "a", "pgdata", "PG_VERSION"), []byte("17"), 0o600)) + symlinks := runtime.GOOS != "windows" + if symlinks { + must(t, os.Symlink("b/f.txt", filepath.Join(src, "a", "link"))) + } + + var buf bytes.Buffer + w, err := NewWriter(&buf) + must(t, err) + must(t, w.AddBytes("manifest.json", []byte(`{"domain":"x.example"}`), 0o644)) + skip := NewMatcher([]string{"*.swp", filepath.ToSlash(filepath.Join(src, "a", "pgdata"))}) + must(t, w.AddTree(filepath.Join(src, "a"), func(p string) string { + rel, _ := filepath.Rel(src, p) + return "cat/files/" + filepath.ToSlash(rel) + }, skip)) + must(t, w.Close()) + if len(w.Warnings) > 0 { + t.Fatalf("warnings: %v", w.Warnings) + } + + var m struct{ Domain string } + must(t, ReadManifest(bytes.NewReader(buf.Bytes()), &m)) + if m.Domain != "x.example" { + t.Fatalf("manifest domain = %q", m.Domain) + } + + dst := t.TempDir() + e := &Extractor{Route: func(name string) (string, bool) { + rest, ok := strings.CutPrefix(name, "cat/files/") + if !ok { + return "", false + } + return filepath.Join(dst, filepath.FromSlash(rest)), true + }} + must(t, e.Extract(bytes.NewReader(buf.Bytes()))) + + data, err := os.ReadFile(filepath.Join(dst, "a", "b", "f.txt")) + must(t, err) + if string(data) != "hello" { + t.Fatalf("content = %q", data) + } + if _, err := os.Stat(filepath.Join(dst, "a", "skip.swp")); !os.IsNotExist(err) { + t.Fatalf("skip.swp должен быть исключён") + } + if _, err := os.Stat(filepath.Join(dst, "a", "pgdata")); !os.IsNotExist(err) { + t.Fatalf("pgdata должен быть исключён") + } + if runtime.GOOS != "windows" { + fi, err := os.Stat(filepath.Join(dst, "a", "b", "f.txt")) + must(t, err) + if fi.Mode().Perm() != 0o640 { + t.Fatalf("mode = %v", fi.Mode().Perm()) + } + fi, err = os.Stat(filepath.Join(dst, "a", "b")) + must(t, err) + if fi.Mode().Perm() != 0o750 { + t.Fatalf("dir mode = %v", fi.Mode().Perm()) + } + } + if symlinks { + target, err := os.Readlink(filepath.Join(dst, "a", "link")) + must(t, err) + if target != "b/f.txt" { + t.Fatalf("symlink = %q", target) + } + } + + // повторная распаковка поверх существующих файлов + e2 := &Extractor{Route: e.Route} + must(t, e2.Extract(bytes.NewReader(buf.Bytes()))) +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/archive/exclude.go b/internal/archive/exclude.go new file mode 100644 index 0000000..fc20e7f --- /dev/null +++ b/internal/archive/exclude.go @@ -0,0 +1,71 @@ +package archive + +import ( + "path" + "strings" +) + +// Matcher проверяет абсолютные пути (со слешами) по списку исключений. +// +// - шаблон без glob-символов — префикс: "/opt/x/pgdata" исключает и сам каталог, и всё внутри; +// - шаблон с "/" и glob-символами — сопоставляется с полным путём ("/etc/x-ui/x-ui.db*"); +// - шаблон без "/" — с именем файла ("*.swp"). +type Matcher struct { + patterns []string +} + +func NewMatcher(patterns ...[]string) *Matcher { + m := &Matcher{} + for _, ps := range patterns { + for _, p := range ps { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if p != "/" { + p = strings.TrimSuffix(p, "/") + } + m.patterns = append(m.patterns, p) + } + } + return m +} + +func (m *Matcher) Add(p ...string) { + m.patterns = append(m.patterns, NewMatcher(p).patterns...) +} + +func (m *Matcher) Match(abs string) bool { + if m == nil { + return false + } + for _, p := range m.patterns { + if !strings.ContainsAny(p, "*?[") { + if abs == p || strings.HasPrefix(abs, p+"/") { + return true + } + continue + } + if strings.Contains(p, "/") { + if ok, _ := path.Match(p, abs); ok { + return true + } + continue + } + if ok, _ := path.Match(p, path.Base(abs)); ok { + return true + } + } + return false +} + +// Covers сообщает, попадает ли abs под один из путей (точное совпадение или вложенность). +func Covers(paths []string, abs string) bool { + for _, p := range paths { + p = strings.TrimSuffix(p, "/") + if abs == p || strings.HasPrefix(abs, p+"/") { + return true + } + } + return false +} diff --git a/internal/archive/reader.go b/internal/archive/reader.go new file mode 100644 index 0000000..4f313ef --- /dev/null +++ b/internal/archive/reader.go @@ -0,0 +1,224 @@ +package archive + +import ( + "archive/tar" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/user" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/klauspost/compress/zstd" +) + +// Route отображает имя в архиве на путь назначения. ok=false — запись пропускается. +type Route func(name string) (dest string, ok bool) + +type Extractor struct { + Route Route + Warnings []string + Files int + + users map[string]int + groups map[string]int + dirs []dirMeta +} + +type dirMeta struct { + path string + mode os.FileMode + mtime time.Time +} + +// Extract распаковывает zstd-tar из r. +func (e *Extractor) Extract(r io.Reader) error { + zr, err := zstd.NewReader(r) + if err != nil { + return err + } + defer zr.Close() + tr := tar.NewReader(zr) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + if strings.Contains(hdr.Name, "..") { + e.warn("пропущено подозрительное имя %q", hdr.Name) + continue + } + dest, ok := e.Route(hdr.Name) + if !ok { + continue + } + if err := e.extractOne(tr, hdr, filepath.Clean(dest)); err != nil { + return fmt.Errorf("%s: %w", hdr.Name, err) + } + } + // права и время каталогов — в конце, иначе запись файлов их перебьёт + for i := len(e.dirs) - 1; i >= 0; i-- { + d := e.dirs[i] + _ = os.Chmod(d.path, d.mode) + _ = os.Chtimes(d.path, d.mtime, d.mtime) + } + return nil +} + +func (e *Extractor) warn(format string, a ...any) { + e.Warnings = append(e.Warnings, fmt.Sprintf(format, a...)) +} + +func (e *Extractor) extractOne(tr *tar.Reader, hdr *tar.Header, dest string) error { + mode := os.FileMode(hdr.Mode).Perm() + // tar хранит suid/sgid/sticky в битах c_ISUID/c_ISGID/c_ISVTX + if hdr.Mode&04000 != 0 { + mode |= os.ModeSetuid + } + if hdr.Mode&02000 != 0 { + mode |= os.ModeSetgid + } + if hdr.Mode&01000 != 0 { + mode |= os.ModeSticky + } + + switch hdr.Typeflag { + case tar.TypeDir: + if fi, err := os.Lstat(dest); err == nil && !fi.IsDir() { + if err := os.Remove(dest); err != nil { + return err + } + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + e.chown(dest, hdr) + e.dirs = append(e.dirs, dirMeta{dest, mode, hdr.ModTime}) + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + // пишем во временный файл и переименовываем: работает и для занятых бинарников + tmp := dest + ".bm-tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + e.chown(tmp, hdr) + if err := os.Chmod(tmp, mode); err != nil { + e.warn("%s: chmod: %v", dest, err) + } + _ = os.Chtimes(tmp, hdr.ModTime, hdr.ModTime) + if fi, err := os.Lstat(dest); err == nil && fi.IsDir() { + if err := os.RemoveAll(dest); err != nil { + os.Remove(tmp) + return err + } + } + if err := os.Rename(tmp, dest); err != nil { + os.Remove(tmp) + return err + } + case tar.TypeSymlink: + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if fi, err := os.Lstat(dest); err == nil { + if fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 { + if err := os.RemoveAll(dest); err != nil { + return err + } + } else if err := os.Remove(dest); err != nil { + return err + } + } + if err := os.Symlink(hdr.Linkname, dest); err != nil { + return err + } + e.chown(dest, hdr) + default: + return nil + } + e.Files++ + return nil +} + +// chown выставляет владельца по имени из архива (uid на новом сервере может отличаться), +// при отсутствии такого пользователя — по числовому id. +func (e *Extractor) chown(p string, hdr *tar.Header) { + if runtime.GOOS == "windows" { + return + } + uid := e.lookup(&e.users, hdr.Uname, hdr.Uid, func(n string) (string, error) { + u, err := user.Lookup(n) + if err != nil { + return "", err + } + return u.Uid, nil + }) + gid := e.lookup(&e.groups, hdr.Gname, hdr.Gid, func(n string) (string, error) { + g, err := user.LookupGroup(n) + if err != nil { + return "", err + } + return g.Gid, nil + }) + if err := os.Lchown(p, uid, gid); err != nil { + e.warn("%s: chown: %v", p, err) + } +} + +func (e *Extractor) lookup(cache *map[string]int, name string, fallback int, find func(string) (string, error)) int { + if name == "" { + return fallback + } + if *cache == nil { + *cache = map[string]int{} + } + if id, ok := (*cache)[name]; ok { + return id + } + id := fallback + if s, err := find(name); err == nil { + if n, err := strconv.Atoi(s); err == nil { + id = n + } + } + (*cache)[name] = id + return id +} + +// ReadManifest читает manifest.json из начала архива (без распаковки остального). +func ReadManifest(r io.Reader, v any) error { + zr, err := zstd.NewReader(r) + if err != nil { + return err + } + defer zr.Close() + tr := tar.NewReader(zr) + hdr, err := tr.Next() + if err != nil { + return err + } + if hdr.Name != "manifest.json" { + return fmt.Errorf("первая запись архива %q, ожидался manifest.json", hdr.Name) + } + return json.NewDecoder(tr).Decode(v) +} diff --git a/internal/archive/writer.go b/internal/archive/writer.go new file mode 100644 index 0000000..6749824 --- /dev/null +++ b/internal/archive/writer.go @@ -0,0 +1,167 @@ +// Package archive пишет и читает архивы бэкапа: tar (PAX) поверх zstd. +package archive + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/klauspost/compress/zstd" +) + +type Writer struct { + zw *zstd.Encoder + tw *tar.Writer + seen map[string]bool + + Files int + Bytes int64 + Warnings []string +} + +func NewWriter(w io.Writer) (*Writer, error) { + zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.SpeedDefault)) + if err != nil { + return nil, err + } + return &Writer{zw: zw, tw: tar.NewWriter(zw), seen: map[string]bool{}}, nil +} + +func (w *Writer) warn(format string, a ...any) { + w.Warnings = append(w.Warnings, fmt.Sprintf(format, a...)) +} + +// AddBytes пишет в архив файл из памяти. +func (w *Writer) AddBytes(name string, data []byte, mode int64) error { + hdr := &tar.Header{ + Name: name, + Mode: mode, + Size: int64(len(data)), + ModTime: time.Now(), + Typeflag: tar.TypeReg, + Format: tar.FormatPAX, + } + if err := w.tw.WriteHeader(hdr); err != nil { + return err + } + _, err := w.tw.Write(data) + w.seen[name] = true + w.Files++ + w.Bytes += hdr.Size + return err +} + +// AddFiles добавляет абсолютный путь src (файл или каталог) под именами prefix+<путь без ведущего />. +func (w *Writer) AddFiles(prefix, src string, skip *Matcher) error { + return w.AddTree(src, func(p string) string { + return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/") + }, skip) +} + +// AddTree обходит src (не следуя симлинкам) и пишет каждую запись под именем name(path). +// Ошибки отдельных файлов (нет доступа, исчез во время обхода) становятся предупреждениями. +func (w *Writer) AddTree(src string, name func(p string) string, skip *Matcher) error { + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + if errors.Is(err, fs.ErrNotExist) && p != src { + return nil + } + w.warn("%s: %v", p, err) + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil + } + if skip.Match(filepath.ToSlash(p)) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + return w.addEntry(p, name(p)) + }) +} + +func (w *Writer) addEntry(p, name string) error { + fi, err := os.Lstat(p) + if err != nil { + w.warn("%s: %v", p, err) + return nil + } + mode := fi.Mode() + if mode&(fs.ModeSocket|fs.ModeDevice|fs.ModeCharDevice|fs.ModeNamedPipe|fs.ModeIrregular) != 0 { + return nil + } + if fi.IsDir() { + name = strings.TrimSuffix(name, "/") + "/" + } + if w.seen[name] { + return nil + } + var link string + if mode&fs.ModeSymlink != 0 { + if link, err = os.Readlink(p); err != nil { + w.warn("%s: %v", p, err) + return nil + } + } + hdr, err := tar.FileInfoHeader(fi, link) + if err != nil { + w.warn("%s: %v", p, err) + return nil + } + hdr.Name = name + hdr.Format = tar.FormatPAX + + var f *os.File + if hdr.Typeflag == tar.TypeReg { + // открываем до заголовка: недоступный файл просто пропускаем + if f, err = os.Open(p); err != nil { + w.warn("%s: %v", p, err) + return nil + } + defer f.Close() + } + if err := w.tw.WriteHeader(hdr); err != nil { + return err + } + w.seen[name] = true + w.Files++ + if f == nil { + return nil + } + n, err := io.CopyN(w.tw, f, hdr.Size) + w.Bytes += n + if err != nil { + if !errors.Is(err, io.EOF) { + return fmt.Errorf("%s: %w", p, err) + } + // файл уменьшился во время чтения — добиваем нулями до размера из заголовка + w.warn("%s: файл изменился во время чтения (%d из %d байт)", p, n, hdr.Size) + if _, err := io.CopyN(w.tw, zeroReader{}, hdr.Size-n); err != nil { + return err + } + } + return nil +} + +func (w *Writer) Close() error { + if err := w.tw.Close(); err != nil { + w.zw.Close() + return err + } + return w.zw.Close() +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + clear(p) + return len(p), nil +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go new file mode 100644 index 0000000..9d6c778 --- /dev/null +++ b/internal/catalog/catalog.go @@ -0,0 +1,150 @@ +// Package catalog описывает категории бэкапа. Встроенный каталог (default.yaml) +// покрывает типовой набор софта, конфиг клиента с сервера переопределяет его по имени. +package catalog + +import ( + _ "embed" + "fmt" + + "gopkg.in/yaml.v3" +) + +//go:embed default.yaml +var defaultYAML []byte + +const ( + TypeFiles = "files" + TypeSystemd = "systemd" + TypeXUI = "xui" + TypeCompose = "compose" +) + +type Config struct { + // AutoCompose — бэкапить compose-проекты, которых нет в каталоге. + AutoCompose *bool `yaml:"auto_compose,omitempty"` + Exclude []string `yaml:"exclude,omitempty"` // глобальные исключения + Categories []Spec `yaml:"categories"` +} + +type Spec struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Disabled bool `yaml:"disabled,omitempty"` + Detect []string `yaml:"detect,omitempty"` // категория активна, если существует хоть один путь + Paths []string `yaml:"paths,omitempty"` + Exclude []string `yaml:"exclude,omitempty"` + + Service string `yaml:"service,omitempty"` + VersionCmd []string `yaml:"version_cmd,omitempty"` + VersionRegex string `yaml:"version_regex,omitempty"` + Install string `yaml:"install,omitempty"` // recipe: nebula, xui, sing-box-extended + KeepBinaries []string `yaml:"keep_binaries,omitempty"` // fallback-копии бинарников + EnsureDirs []string `yaml:"ensure_dirs,omitempty"` + PostRestore []string `yaml:"post_restore,omitempty"` + Dumps []CmdDump `yaml:"dumps,omitempty"` + + // compose + Dir string `yaml:"dir,omitempty"` + Project string `yaml:"project,omitempty"` + SkipServices []string `yaml:"skip_services,omitempty"` + HeavyPaths []string `yaml:"heavy_paths,omitempty"` // относительно dir (или абсолютные) + + RestoreAfter []string `yaml:"restore_after,omitempty"` +} + +// CmdDump — произвольный дамп: stdout команды cmd сохраняется в файл name. +type CmdDump struct { + Name string `yaml:"name"` + Cmd string `yaml:"cmd"` + RestoreCmd string `yaml:"restore_cmd,omitempty"` +} + +func (c *Config) AutoComposeEnabled() bool { return c.AutoCompose == nil || *c.AutoCompose } + +// Default возвращает встроенный каталог. +func Default() (*Config, error) { + var c Config + if err := yaml.Unmarshal(defaultYAML, &c); err != nil { + return nil, fmt.Errorf("встроенный каталог: %w", err) + } + return &c, c.Validate() +} + +// Parse разбирает YAML-переопределения клиента. +func Parse(data []byte) (*Config, error) { + var c Config + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, err + } + return &c, nil +} + +// Merge накладывает override на base: категории с тем же именем заменяются целиком, +// новые добавляются в конец, глобальные исключения складываются. +func Merge(base, override *Config) *Config { + out := &Config{AutoCompose: base.AutoCompose} + out.Exclude = append(out.Exclude, base.Exclude...) + out.Categories = append(out.Categories, base.Categories...) + if override == nil { + return out + } + if override.AutoCompose != nil { + out.AutoCompose = override.AutoCompose + } + out.Exclude = append(out.Exclude, override.Exclude...) + for _, s := range override.Categories { + replaced := false + for i := range out.Categories { + if out.Categories[i].Name == s.Name { + // "name + disabled: true" — короткая форма отключения встроенной категории + if s.Disabled && s.Type == "" { + out.Categories[i].Disabled = true + } else { + out.Categories[i] = s + } + replaced = true + break + } + } + if !replaced { + out.Categories = append(out.Categories, s) + } + } + return out +} + +func (c *Config) Validate() error { + seen := map[string]bool{} + for _, s := range c.Categories { + if s.Name == "" { + return fmt.Errorf("категория без имени") + } + if seen[s.Name] { + return fmt.Errorf("категория %q описана дважды", s.Name) + } + seen[s.Name] = true + if s.Disabled { + continue + } + switch s.Type { + case TypeFiles, TypeSystemd, TypeXUI: + case TypeCompose: + if s.Dir == "" && s.Project == "" { + return fmt.Errorf("категория %q: для compose нужен dir или project", s.Name) + } + default: + return fmt.Errorf("категория %q: неизвестный тип %q", s.Name, s.Type) + } + } + return nil +} + +// Find возвращает категорию по имени. +func (c *Config) Find(name string) *Spec { + for i := range c.Categories { + if c.Categories[i].Name == name { + return &c.Categories[i] + } + } + return nil +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go new file mode 100644 index 0000000..76f7c7c --- /dev/null +++ b/internal/catalog/catalog_test.go @@ -0,0 +1,39 @@ +package catalog + +import "testing" + +func TestDefaultAndMerge(t *testing.T) { + base, err := Default() + if err != nil { + t.Fatal(err) + } + if base.Find("stoat") == nil || base.Find("xui") == nil { + t.Fatal("в каталоге нет stoat/xui") + } + over, err := Parse([]byte(` +categories: + - {name: scripts, disabled: true} + - name: extra + type: files + paths: [/srv/extra] + - name: telegraf + type: files + paths: [/etc/telegraf] +`)) + if err != nil { + t.Fatal(err) + } + m := Merge(base, over) + if err := m.Validate(); err != nil { + t.Fatal(err) + } + if !m.Find("scripts").Disabled || len(m.Find("scripts").Paths) == 0 { + t.Error("короткая форма disabled должна сохранять остальные поля") + } + if m.Find("extra") == nil { + t.Error("новая категория не добавлена") + } + if m.Find("telegraf").Type != TypeFiles { + t.Error("категория не заменена") + } +} diff --git a/internal/catalog/default.yaml b/internal/catalog/default.yaml new file mode 100644 index 0000000..5d199aa --- /dev/null +++ b/internal/catalog/default.yaml @@ -0,0 +1,91 @@ +# Встроенный каталог категорий. Переопределяется конфигом клиента в веб-морде: +# категория с тем же name заменяется целиком, `- {name: X, disabled: true}` отключает её. + +auto_compose: true + +exclude: + - "*.swp" + +categories: + - name: system + type: files + paths: + - /etc/ufw/user.rules + - /etc/ufw/user6.rules + - /var/spool/cron/crontabs + - /etc/cron.d/ban-blacklist + - /usr/local/sbin/ban.sh + - /etc/sysctl.d + post_restore: + - "ufw reload || true" + - "sysctl --system >/dev/null || true" + + - name: xui + type: xui + detect: [/usr/local/x-ui/x-ui] + paths: [/etc/x-ui, /root/cert] + exclude: ["/etc/x-ui/x-ui.db*"] + service: x-ui + version_cmd: [/usr/local/x-ui/x-ui, -v] + install: xui + keep_binaries: [/usr/local/x-ui, /usr/bin/x-ui, /etc/systemd/system/x-ui.service] + + - name: nebula + type: systemd + detect: [/etc/nebula] + paths: [/etc/nebula, /etc/systemd/system/nebula.service] + service: nebula + version_cmd: [/usr/local/bin/nebula, -version] + install: nebula + keep_binaries: [/usr/local/bin/nebula, /usr/local/bin/nebula-cert] + + - name: caddy + type: systemd + detect: [/etc/caddy/Caddyfile] + paths: [/etc/caddy, /var/www, /var/lib/caddy/.local/share/caddy] + service: caddy + + - name: telegraf + type: systemd + detect: [/etc/telegraf] + paths: [/etc/telegraf, /etc/sudoers.d/telegraf] + service: telegraf + + - name: sing-box-warp + type: systemd + detect: [/opt/sing-box-warp] + paths: + - /opt/sing-box-warp + - /etc/sing-box-warp + - /etc/systemd/system/sing-box-warp.service + - /etc/sysctl.d/99-sing-box-warp.conf + service: sing-box-warp + version_cmd: [/usr/local/bin/sing-box, version] + version_regex: 'version\s+(\S+)' + install: sing-box-extended + keep_binaries: [/usr/local/bin/sing-box] + ensure_dirs: [/var/cache/sing-box-warp] + + - name: scripts + type: files + paths: [/var/scripts, /usr/local/bin/update-geosite.sh] + + - name: pnvpanel + type: compose + dir: /opt/PnvPanel + + - name: stoat + type: compose + dir: /opt/stoat + skip_services: [rabbit, redis] + heavy_paths: [data/minio] + + - name: stoat-mbot + type: compose + dir: /opt/stoat-mbot + restore_after: [stoat] + + - name: psiphon + type: compose + dir: /opt/psiphon + disabled: true diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..d583f44 --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,110 @@ +// Package manifest описывает метаданные бэкапа: что и откуда собрано, какие версии +// стояли на сервере. Манифест лежит первым файлом в main-архиве и отдельно хранится +// на сервере, чтобы restore мог спланировать восстановление до скачивания архива. +package manifest + +import "time" + +const FormatVersion = 1 + +// MainPart — имя основной части бэкапа. Остальные части — heavy-категории. +const MainPart = "main" + +type Manifest struct { + FormatVersion int `json:"format_version"` + AgentVersion string `json:"agent_version"` + Domain string `json:"domain"` + Hostname string `json:"hostname"` + CreatedAt time.Time `json:"created_at"` + OS OSInfo `json:"os"` + Packages map[string]string `json:"packages,omitempty"` // apt-пакет → точная версия dpkg + Features Features `json:"features"` + SudoUsers []string `json:"sudo_users,omitempty"` + Categories []Category `json:"categories"` + Parts []string `json:"parts"` // main + heavy-части, вошедшие в этот бэкап + Warnings []string `json:"warnings,omitempty"` +} + +type OSInfo struct { + ID string `json:"id"` + VersionID string `json:"version_id"` + PrettyName string `json:"pretty_name"` + Arch string `json:"arch"` // как dpkg --print-architecture: amd64, arm64 +} + +// Features — признаки базовой настройки vps_installer, которые нужно повторить. +type Features struct { + Blocklist bool `json:"blocklist"` + SSHHardened bool `json:"ssh_hardened"` +} + +type Category struct { + Name string `json:"name"` + Type string `json:"type"` + Auto bool `json:"auto,omitempty"` // найдена автоматически, в каталоге не описана + Version string `json:"version,omitempty"` + Install string `json:"install,omitempty"` // recipe установки бинарника + Service *ServiceState `json:"service,omitempty"` + Paths []string `json:"paths,omitempty"` // абсолютные пути в /files/ + Binaries []string `json:"binaries,omitempty"` // абсолютные пути в /bin/ (fallback) + Dumps []Dump `json:"dumps,omitempty"` + Volumes []Volume `json:"volumes,omitempty"` + Heavy []string `json:"heavy,omitempty"` // пути, ушедшие в отдельную часть + HeavySkipped bool `json:"heavy_skipped,omitempty"` + Compose *Compose `json:"compose,omitempty"` + EnsureDirs []string `json:"ensure_dirs,omitempty"` + PostRestore []string `json:"post_restore,omitempty"` + RestoreAfter []string `json:"restore_after,omitempty"` +} + +type ServiceState struct { + Unit string `json:"unit"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` +} + +// Dump — файл в /dumps/. Kind: postgres, mongo, mysql, mariadb, sqlite, cmd. +type Dump struct { + Name string `json:"name"` + Kind string `json:"kind"` + Service string `json:"service,omitempty"` // compose-сервис + Target string `json:"target,omitempty"` // sqlite: куда положить файл при восстановлении + RestoreCmd string `json:"restore_cmd,omitempty"` // cmd: команда, получает DUMP_FILE +} + +// Volume — именованный docker-том, содержимое в /volumes//. +type Volume struct { + Name string `json:"name"` // реальное имя тома в docker + Key string `json:"key"` // ключ в compose-файле +} + +type Compose struct { + Project string `json:"project"` + Dir string `json:"dir"` + ConfigFiles []string `json:"config_files"` // абсолютные пути + Running bool `json:"running"` + Images map[string]Image `json:"images,omitempty"` // по имени сервиса + ExternalNetworks []string `json:"external_networks,omitempty"` + Git *GitInfo `json:"git,omitempty"` +} + +type Image struct { + Ref string `json:"ref"` + Digest string `json:"digest,omitempty"` // repo@sha256:... + Built bool `json:"built,omitempty"` +} + +type GitInfo struct { + Remote string `json:"remote,omitempty"` + Commit string `json:"commit"` + Dirty bool `json:"dirty,omitempty"` +} + +// Archive-пути внутри tar. + +func FilesPrefix(cat string) string { return cat + "/files/" } +func BinPrefix(cat string) string { return cat + "/bin/" } +func DumpsPrefix(cat string) string { return cat + "/dumps/" } +func VolumesPrefix(cat string) string { return cat + "/volumes/" } + +const ManifestFile = "manifest.json" diff --git a/internal/restore/compose.go b/internal/restore/compose.go new file mode 100644 index 0000000..f467e8d --- /dev/null +++ b/internal/restore/compose.go @@ -0,0 +1,186 @@ +package restore + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx" +) + +const pinFile = "compose.restore.yml" + +// restoreCompose поднимает compose-проект: закрепляет образы по digest, создаёт +// недостающие external-сети, заливает дампы БД и запускает проект. +func (r *restorer) restoreCompose(c manifest.Category) error { + info := c.Compose + if info == nil { + return fmt.Errorf("нет сведений о compose-проекте") + } + if !sysx.HasCommand("docker") { + return fmt.Errorf("docker не установлен") + } + if len(info.ConfigFiles) == 0 { + return fmt.Errorf("неизвестны compose-файлы проекта") + } + args := []string{"compose", "--project-directory", info.Dir, "-p", info.Project} + for _, f := range info.ConfigFiles { + args = append(args, "-f", f) + } + if pin, err := r.writePinFile(c); err != nil { + return err + } else if pin != "" { + args = append(args, "-f", pin) + r.todo = append(r.todo, fmt.Sprintf("%s: образы закреплены по digest в %s — добавляйте `-f %s` к docker compose, иначе подтянутся новые версии", + c.Name, filepath.Join(info.Dir, pinFile), pinFile)) + } + compose := func(extra ...string) error { + return sysx.Run(r.ctx, "docker", append(slices.Clone(args), extra...)...) + } + + for _, n := range info.ExternalNetworks { + if _, err := sysx.Output(r.ctx, "docker", "network", "inspect", n); err == nil { + continue + } + if _, err := sysx.Output(r.ctx, "docker", "network", "create", n); err != nil { + return fmt.Errorf("сеть %s: %w", n, err) + } + r.todo = append(r.todo, fmt.Sprintf("%s: external-сеть %s создана пустой — сервис, который её предоставлял, на этом сервере не восстановлен", c.Name, n)) + } + + if err := compose("pull", "--ignore-buildable", "--quiet"); err != nil { + r.warn("%s: pull: %v", c.Name, err) + } + if hasBuilt(info) { + if err := compose("build"); err != nil { + return fmt.Errorf("сборка образов: %w", err) + } + } + if err := compose("create"); err != nil { + return err + } + + for _, d := range c.Dumps { + if d.Service == "" { + continue + } + if err := r.restoreDB(c, d, compose, args); err != nil { + return err + } + } + + if info.Running { + if err := compose("up", "-d"); err != nil { + return err + } + } else { + r.warn("%s: на старом сервере проект был остановлен — не запускаю", c.Name) + } + return nil +} + +func hasBuilt(info *manifest.Compose) bool { + for _, img := range info.Images { + if img.Built { + return true + } + } + return false +} + +// writePinFile пишет compose.restore.yml с образами по digest. Пустая строка — закреплять нечего. +func (r *restorer) writePinFile(c manifest.Category) (string, error) { + var services []string + for name := range c.Compose.Images { + services = append(services, name) + } + slices.Sort(services) + var b strings.Builder + b.WriteString("# Создано backup-master при восстановлении: образы закреплены по digest со старого сервера.\n") + b.WriteString("services:\n") + n := 0 + for _, svc := range services { + img := c.Compose.Images[svc] + if img.Built || img.Digest == "" { + continue + } + fmt.Fprintf(&b, " %q:\n image: %q\n", svc, img.Digest) + n++ + } + if n == 0 { + return "", nil + } + p := filepath.Join(c.Compose.Dir, pinFile) + return p, os.WriteFile(p, []byte(b.String()), 0o644) +} + +var dbReady = map[string]string{ + "postgres": `pg_isready -q -h 127.0.0.1 -U "${POSTGRES_USER:-postgres}"`, + "mongo": `(command -v mongosh >/dev/null && mongosh --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok') || mongo --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok'`, + "mysql": `mysqladmin ping -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" --silent`, + "mariadb": `A=$(command -v mariadb-admin || command -v mysqladmin); "$A" ping -h 127.0.0.1 -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --silent`, +} + +var dbRestore = map[string]string{ + "postgres": `pg_restore -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" --clean --if-exists --no-owner`, + "mongo": `if [ -n "$MONGO_INITDB_ROOT_USERNAME" ]; then exec mongorestore --quiet --archive --gzip --drop -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else exec mongorestore --quiet --archive --gzip --drop; fi`, + "mysql": `exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"`, + "mariadb": `M=$(command -v mariadb || command -v mysql); exec "$M" -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}"`, +} + +func (r *restorer) restoreDB(c manifest.Category, d manifest.Dump, compose func(...string) error, args []string) error { + file := filepath.Join(r.staging, c.Name, "dumps", d.Name) + if !exists(file) { + return fmt.Errorf("дамп %s не найден в архиве", d.Name) + } + info("БД %s (%s): запуск", d.Service, d.Kind) + if err := compose("up", "-d", d.Service); err != nil { + return err + } + cid, err := sysx.Output(r.ctx, "docker", append(slices.Clone(args), "ps", "-q", d.Service)...) + if err != nil || cid == "" { + return fmt.Errorf("контейнер %s не найден: %v", d.Service, err) + } + cid = strings.Fields(cid)[0] + if err := r.waitDB(cid, d.Kind); err != nil { + return fmt.Errorf("БД %s не поднялась: %w", d.Service, err) + } + info("БД %s: заливка дампа %s", d.Service, d.Name) + err = sysx.FromFile(r.ctx, file, "docker", "exec", "-i", cid, "sh", "-c", dbRestore[d.Kind]) + if err != nil { + if d.Kind == "postgres" { + // pg_restore возвращает 1 и на некритичных предупреждениях — данные обычно на месте + r.warn("%s: pg_restore завершился с предупреждениями, проверьте вывод выше", c.Name) + return nil + } + return fmt.Errorf("заливка дампа %s: %w", d.Name, err) + } + return nil +} + +// waitDB ждёт, пока БД стабильно принимает TCP-подключения: init-скрипты образов +// поднимают временный сервер без сети, поэтому проверка через 127.0.0.1. +func (r *restorer) waitDB(cid, kind string) error { + probe := dbReady[kind] + deadline := time.Now().Add(4 * time.Minute) + okInRow := 0 + for time.Now().Before(deadline) { + if _, err := sysx.Output(r.ctx, "docker", "exec", cid, "sh", "-c", probe); err == nil { + if okInRow++; okInRow >= 3 { + return nil + } + } else { + okInRow = 0 + } + select { + case <-r.ctx.Done(): + return r.ctx.Err() + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("таймаут ожидания") +} diff --git a/internal/restore/order.go b/internal/restore/order.go new file mode 100644 index 0000000..1f77716 --- /dev/null +++ b/internal/restore/order.go @@ -0,0 +1,44 @@ +package restore + +import "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" + +// order сортирует категории так, чтобы restore_after шли раньше зависящих от них. +// Порядок манифеста сохраняется, отсутствующие зависимости игнорируются, при цикле +// оставшиеся категории идут в исходном порядке. +func order(cats []manifest.Category) []manifest.Category { + idx := map[string]int{} + for i, c := range cats { + idx[c.Name] = i + } + done := make([]bool, len(cats)) + var out []manifest.Category + for len(out) < len(cats) { + progressed := false + for i, c := range cats { + if done[i] { + continue + } + ready := true + for _, dep := range c.RestoreAfter { + if j, ok := idx[dep]; ok && !done[j] && j != i { + ready = false + break + } + } + if ready { + done[i] = true + out = append(out, c) + progressed = true + } + } + if !progressed { + for i, c := range cats { + if !done[i] { + done[i] = true + out = append(out, c) + } + } + } + } + return out +} diff --git a/internal/restore/provision.sh b/internal/restore/provision.sh new file mode 100644 index 0000000..62a1c17 --- /dev/null +++ b/internal/restore/provision.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# +# Базовая подготовка нового сервера перед восстановлением из backup-master. +# Основано на vps_installer/setup.sh (https://gitea.hsrv.site/mrleo1nid/vps_installer), +# но рассчитано на запуск из `backupctl restore`: +# - все параметры приходят через окружение (их выставляет restore по манифесту); +# - интерактивны только NEW_USER (по умолчанию DEFAULT_USER) и NEW_PASS; +# - apt-пакеты ставятся точных версий со старого сервера, если они есть в репозитории; +# - 3x-ui, Nebula, sing-box ставит restore (нужной версии), здесь их нет; +# - без перезагрузки — о ней напомнит restore в конце. +# +# Окружение: +# NEW_USER, NEW_PASS, DEFAULT_USER, NEW_HOSTNAME, +# INSTALL_BLOCKLIST, BLOCKLIST_URL, HARDEN_SSH (y/n), +# INSTALL_CADDY, CADDY_VERSION, INSTALL_TELEGRAF, TELEGRAF_VERSION, +# INSTALL_DOCKER, DOCKER_CE_VERSION, DOCKER_CLI_VERSION, COMPOSE_VERSION + +set -Eeuo pipefail + +REPO_RAW="https://gitea.hsrv.site/mrleo1nid/vps_installer/raw/branch/main" +STATUSPAGE_DIR="/var/www/statuspage" +LOG_FILE="/var/log/vps-setup.log" +DEFAULT_BLOCKLIST_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.txt" +BAN_SCRIPT_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.sh" +# 00- — чтобы файл читался раньше 50-cloud-init.conf: sshd берёт ПЕРВОЕ найденное значение +SSHD_HARDENING="/etc/ssh/sshd_config.d/00-hardening.conf" + +export DEBIAN_FRONTEND=noninteractive +export NEEDRESTART_MODE=a + +: "${INSTALL_BLOCKLIST:=y}" "${HARDEN_SSH:=y}" "${INSTALL_CADDY:=n}" "${INSTALL_TELEGRAF:=n}" "${INSTALL_DOCKER:=n}" +: "${BLOCKLIST_URL:=$DEFAULT_BLOCKLIST_URL}" + +# ---------- вывод ---------- +if [[ -t 1 ]]; then + C_RED=$'\e[31m' C_GRN=$'\e[32m' C_YLW=$'\e[33m' C_BLU=$'\e[1;34m' C_RST=$'\e[0m' +else + C_RED='' C_GRN='' C_YLW='' C_BLU='' C_RST='' +fi +step() { echo; echo "${C_BLU}==> $*${C_RST}"; } +info() { echo " $*"; } +ok() { echo "${C_GRN} ✓ $*${C_RST}"; } +warn() { echo "${C_YLW} ! $*${C_RST}"; } +die() { echo "${C_RED}ОШИБКА: $*${C_RST}" >&2; exit 1; } + +trap 'echo "${C_RED}Скрипт упал на строке $LINENO: $BASH_COMMAND${C_RST}" >&2' ERR + +# ---------- ввод (через /dev/tty) ---------- +TTY=/dev/tty +have_tty() { [[ -r $TTY && -w $TTY ]] && { : <"$TTY"; } 2>/dev/null; } + +ask() { # ask VAR "вопрос" [default] + local __var=$1 __q=$2 __def=${3:-} __ans + [[ -n ${!__var:-} ]] && return 0 + have_tty || die "Нет терминала для вопроса «$__q». Задайте переменную $__var." + if [[ -n $__def ]]; then + read -r -p "$__q [$__def]: " __ans <"$TTY" + __ans=${__ans:-$__def} + else + read -r -p "$__q: " __ans <"$TTY" + fi + printf -v "$__var" '%s' "$__ans" +} + +ask_password() { # ask_password VAR + local __var=$1 p1 p2 + [[ -n ${!__var:-} ]] && return 0 + have_tty || die "Нет терминала для ввода пароля. Задайте переменную $__var." + while true; do + read -r -s -p "Пароль для $NEW_USER: " p1 <"$TTY"; echo >"$TTY" + read -r -s -p "Повторите пароль: " p2 <"$TTY"; echo >"$TTY" + if [[ -z $p1 ]]; then echo "Пароль не может быть пустым." >"$TTY" + elif [[ $p1 != "$p2" ]]; then echo "Пароли не совпадают, ещё раз." >"$TTY" + elif (( ${#p1} < 8 )); then echo "Слишком короткий, минимум 8 символов." >"$TTY" + else break + fi + done + printf -v "$__var" '%s' "$p1" +} + +apt_install() { + apt-get install -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@" +} + +# pkg_spec PKG VERSION — "pkg=version", если такая версия есть в репозитории, иначе "pkg" +pkg_spec() { + local pkg=$1 ver=${2:-} + if [[ -n $ver ]] && apt-cache madison "$pkg" 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qxF "$ver"; then + echo "$pkg=$ver" + else + [[ -n $ver ]] && warn "$pkg $ver нет в репозитории — будет установлена последняя версия" >&2 + echo "$pkg" + fi +} + +# ---------- проверки ---------- +[[ $EUID -eq 0 ]] || die "Нужен root" +. /etc/os-release +[[ ${ID:-} == ubuntu ]] || die "Скрипт рассчитан на Ubuntu (обнаружено: ${PRETTY_NAME:-?})" +[[ ${VERSION_ID:-} == 24.04 ]] || warn "Скрипт писался под Ubuntu 24.04, у вас ${PRETTY_NAME}. Продолжаю." +ARCH=$(dpkg --print-architecture) + +CLIENT_IP="" +if [[ -n ${SSH_CLIENT:-} ]]; then CLIENT_IP=${SSH_CLIENT%% *} +else CLIENT_IP=$(who am i 2>/dev/null | sed -nE 's/.*\(([0-9a-fA-F.:]+)\).*/\1/p' || true) +fi + +# ---------- параметры ---------- +step "Параметры подготовки" +while true; do + ask NEW_USER "Логин пользователя с sudo" "${DEFAULT_USER:-}" + if [[ $NEW_USER =~ ^[a-z_][a-z0-9_-]{0,31}$ && $NEW_USER != root ]]; then break; fi + echo "Недопустимый логин (строчные латинские буквы, цифры, _ и -; не root)." + NEW_USER="" +done +USER_EXISTS=n +if id "$NEW_USER" &>/dev/null; then + USER_EXISTS=y + info "Пользователь $NEW_USER уже есть — пароль не меняю (задайте NEW_PASS, чтобы сменить)" +else + ask_password NEW_PASS +fi +[[ -n ${NEW_HOSTNAME:-} ]] || ask NEW_HOSTNAME "Имя машины (hostname)" "$(hostname)" +NEW_HOSTNAME=${NEW_HOSTNAME,,} + +touch "$LOG_FILE" && chmod 600 "$LOG_FILE" +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "===== $(date '+%F %T') backup-master provision start =====" >>"$LOG_FILE" + +# ---------- 1. Обновление системы ---------- +step "1. Обновление системы" +apt-get update -q +apt-get -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade +apt_install unattended-upgrades curl ca-certificates gnupg +cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF' +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +EOF +ok "Система обновлена, автообновления безопасности включены" + +# ---------- 2. Пользователь с sudo ---------- +step "2. Пользователь $NEW_USER" +if [[ $USER_EXISTS == n ]]; then + useradd -m -s /bin/bash "$NEW_USER" + ok "Пользователь создан" +fi +if [[ -n ${NEW_PASS:-} ]]; then + echo "$NEW_USER:$NEW_PASS" | chpasswd + unset NEW_PASS +fi +usermod -aG sudo "$NEW_USER" +SUDOERS_FILE="/etc/sudoers.d/$NEW_USER" +echo "$NEW_USER ALL=(ALL) NOPASSWD:ALL" >"$SUDOERS_FILE.tmp" +chmod 440 "$SUDOERS_FILE.tmp" +if visudo -c -f "$SUDOERS_FILE.tmp" >/dev/null; then + mv "$SUDOERS_FILE.tmp" "$SUDOERS_FILE" + ok "sudo без пароля ($SUDOERS_FILE)" +else + rm -f "$SUDOERS_FILE.tmp" + die "visudo отверг файл sudoers" +fi + +# ---------- 3. Имя машины ---------- +step "3. Имя машины: $NEW_HOSTNAME" +SHORT_HOSTNAME=${NEW_HOSTNAME%%.*} +hostnamectl set-hostname "$NEW_HOSTNAME" +HOSTS_LINE="127.0.1.1 $NEW_HOSTNAME" +[[ $SHORT_HOSTNAME != "$NEW_HOSTNAME" ]] && HOSTS_LINE+=" $SHORT_HOSTNAME" +if grep -q '^127\.0\.1\.1[[:space:]]' /etc/hosts; then + sed -i "s/^127\.0\.1\.1[[:space:]].*/$HOSTS_LINE/" /etc/hosts +else + echo "$HOSTS_LINE" >>/etc/hosts +fi +if [[ -d /etc/cloud/cloud.cfg.d ]]; then + echo "preserve_hostname: true" >/etc/cloud/cloud.cfg.d/99-preserve-hostname.cfg +fi +ok "hostname: $(hostname)" + +# ---------- 4. iptables legacy ---------- +step "4. Переключение на iptables-legacy" +apt_install iptables ipset +for t in iptables ip6tables arptables ebtables; do + if [[ -x /usr/sbin/$t-legacy ]] && update-alternatives --list "$t" &>/dev/null; then + update-alternatives --set "$t" "/usr/sbin/$t-legacy" >/dev/null + fi +done +iptables --version | grep -q legacy || die "iptables не переключился на legacy" +ok "$(iptables --version)" + +# ---------- 5. Fail2ban ---------- +step "5. Fail2ban" +apt_install fail2ban python3-systemd +IGNORE_IP="127.0.0.1/8 ::1 192.168.0.0/16 10.0.0.0/8" +if [[ -n $CLIENT_IP ]]; then + IGNORE_IP+=" $CLIENT_IP" + info "Ваш текущий IP $CLIENT_IP добавлен в ignoreip" +fi +cat >/etc/fail2ban/jail.local </dev/null 2>&1 +systemctl restart fail2ban +ok "Fail2ban включён" + +# ---------- 6. UFW ---------- +# правила со старого сервера восстановит категория system (ufw reload после распаковки) +step "6. Firewall (UFW)" +apt_install ufw +ufw default deny incoming >/dev/null +ufw default allow outgoing >/dev/null +ufw allow OpenSSH >/dev/null +mkdir -p /run/sshd +SSH_PORT=$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}' || true) +if [[ -n $SSH_PORT && $SSH_PORT != 22 ]]; then + ufw allow "$SSH_PORT/tcp" >/dev/null +fi +ufw allow 80/tcp >/dev/null +ufw allow 443/tcp >/dev/null +ufw --force enable >/dev/null +ok "UFW включён: SSH, 80, 443" + +# ---------- 7. Блокировка IP по списку ---------- +if [[ $INSTALL_BLOCKLIST == y ]]; then + step "7. Блокировка IP по внешнему списку" + curl -fsSL "$BAN_SCRIPT_URL" -o /usr/local/sbin/ban.sh + chmod +x /usr/local/sbin/ban.sh + if [[ $BLOCKLIST_URL != "$DEFAULT_BLOCKLIST_URL" ]]; then + ESCAPED_URL=$(printf '%s' "$BLOCKLIST_URL" | sed 's/[&|\\]/\\&/g') + sed -i "s|^URL=.*|URL=\"$ESCAPED_URL\"|" /usr/local/sbin/ban.sh + fi + bash /usr/local/sbin/ban.sh || warn "ban.sh завершился с ошибкой" + cat >/etc/cron.d/ban-blacklist <<'EOF' +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +@reboot root sleep 60 && bash /usr/local/sbin/ban.sh >/dev/null 2>&1 +0 */6 * * * root bash /usr/local/sbin/ban.sh >/dev/null 2>&1 +EOF + chmod 644 /etc/cron.d/ban-blacklist + ok "ipset blacklist настроен" +else + step "7. Блокировка IP — пропущено" +fi + +# ---------- 8. Статус-страница ---------- +step "8. Статус-страница $STATUSPAGE_DIR" +if [[ -f $STATUSPAGE_DIR/index.html ]]; then + info "уже есть — не трогаю" +else + install -d -m 755 "$STATUSPAGE_DIR" + curl -fsSL "$REPO_RAW/statuspage/index.html" -o "$STATUSPAGE_DIR/index.html" || warn "статус-страница не скачалась" + chmod 644 "$STATUSPAGE_DIR/index.html" 2>/dev/null || true +fi + +# ---------- 9. Docker ---------- +if [[ $INSTALL_DOCKER == y ]]; then + step "9. Docker + Docker Compose" + for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do + dpkg -s "$pkg" &>/dev/null && apt-get remove -y -q "$pkg" + done + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $VERSION_CODENAME stable" \ + >/etc/apt/sources.list.d/docker.list + apt-get update -q + apt_install --allow-downgrades \ + "$(pkg_spec docker-ce "${DOCKER_CE_VERSION:-}")" \ + "$(pkg_spec docker-ce-cli "${DOCKER_CLI_VERSION:-}")" \ + containerd.io docker-buildx-plugin \ + "$(pkg_spec docker-compose-plugin "${COMPOSE_VERSION:-}")" + systemctl enable --now docker.service containerd.service >/dev/null 2>&1 + usermod -aG docker "$NEW_USER" + ok "$(docker --version), $(docker compose version | head -n1)" +else + step "9. Docker — пропущено" +fi + +# ---------- 10. Telegraf ---------- +if [[ $INSTALL_TELEGRAF == y ]]; then + step "10. Telegraf" + mkdir -p /etc/apt/keyrings + curl -fsSL https://repos.influxdata.com/influxdata-archive.key \ + | gpg --batch --yes --dearmor -o /etc/apt/keyrings/influxdata.gpg + echo "deb [signed-by=/etc/apt/keyrings/influxdata.gpg] https://repos.influxdata.com/ubuntu stable main" \ + >/etc/apt/sources.list.d/influxdata.list + apt-get update -q + apt_install --allow-downgrades "$(pkg_spec telegraf "${TELEGRAF_VERSION:-}")" influxdata-archive-keyring + # запуск — после восстановления конфига, по состоянию со старого сервера + systemctl disable --now telegraf >/dev/null 2>&1 || true + ok "$(telegraf --version) установлен" +else + step "10. Telegraf — пропущено" +fi + +# ---------- 11. Caddy ---------- +if [[ $INSTALL_CADDY == y ]]; then + step "11. Caddy" + apt_install debian-keyring debian-archive-keyring apt-transport-https + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + >/etc/apt/sources.list.d/caddy-stable.list + apt-get update -q + apt_install --allow-downgrades "$(pkg_spec caddy "${CADDY_VERSION:-}")" + ok "Caddy $(caddy version | awk '{print $1}') установлен" +else + step "11. Caddy — пропущено" +fi + +# ---------- 12. Отключение доступа root ---------- +if [[ $HARDEN_SSH == y ]]; then + step "12. Отключение доступа root" + cat >"$SSHD_HARDENING" <<'EOF' +PermitRootLogin no +PasswordAuthentication yes +KbdInteractiveAuthentication no +PubkeyAuthentication yes +EOF + mkdir -p /run/sshd + sshd -t || { rm -f "$SSHD_HARDENING"; die "sshd -t нашёл ошибку в конфигурации"; } + systemctl restart ssh + passwd -l root >/dev/null + ok "Вход root по SSH запрещён, пароль root заблокирован" +else + step "12. Отключение доступа root — пропущено" +fi + +ok "Базовая подготовка завершена" diff --git a/internal/restore/recipes.go b/internal/restore/recipes.go new file mode 100644 index 0000000..4058931 --- /dev/null +++ b/internal/restore/recipes.go @@ -0,0 +1,240 @@ +package restore + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// recipe ставит бинарник нужной версии под архитектуру arch (amd64/arm64). +type recipe func(ctx context.Context, version, arch string) error + +var recipes = map[string]recipe{ + "nebula": installNebula, + "xui": installXUI, + "sing-box-extended": installSingBoxExtended, +} + +func installNebula(ctx context.Context, version, arch string) error { + v := strings.TrimPrefix(version, "v") + url := fmt.Sprintf("https://github.com/slackhq/nebula/releases/download/v%s/nebula-linux-%s.tar.gz", v, arch) + return fetchTarGz(ctx, url, func(name string) (string, bool) { + switch filepath.Base(name) { + case "nebula", "nebula-cert": + return "/usr/local/bin/" + filepath.Base(name), true + } + return "", false + }) +} + +// installXUI ставит 3x-ui из release-архива нужного тега — без интерактивного install.sh. +// Настройки панели (порт, путь, логин) приходят из восстановленной x-ui.db. +func installXUI(ctx context.Context, version, arch string) error { + v := version + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + url := fmt.Sprintf("https://github.com/MHSanaei/3x-ui/releases/download/%s/x-ui-linux-%s.tar.gz", v, arch) + _ = os.RemoveAll("/usr/local/x-ui") + if err := fetchTarGz(ctx, url, func(name string) (string, bool) { + name = strings.TrimPrefix(name, "./") + if !strings.HasPrefix(name, "x-ui/") && name != "x-ui" { + return "", false + } + return "/usr/local/" + name, true + }); err != nil { + return err + } + for _, p := range []string{"/usr/local/x-ui/x-ui"} { + _ = os.Chmod(p, 0o755) + } + if bins, _ := filepath.Glob("/usr/local/x-ui/bin/xray-linux-*"); len(bins) > 0 { + for _, b := range bins { + _ = os.Chmod(b, 0o755) + } + } + // в разных версиях юнит называется по-разному + var unit string + for _, n := range []string{"x-ui.service.debian", "x-ui.service"} { + if p := "/usr/local/x-ui/" + n; exists(p) { + unit = p + break + } + } + if unit == "" { + return errors.New("в архиве 3x-ui нет x-ui.service") + } + if err := copyFile(unit, "/etc/systemd/system/x-ui.service", 0o644); err != nil { + return err + } + // консольная утилита x-ui того же тега (не критично) + script := fmt.Sprintf("https://raw.githubusercontent.com/MHSanaei/3x-ui/%s/x-ui.sh", v) + if err := download(ctx, script, "/usr/bin/x-ui", 0o755); err != nil { + fmt.Printf(" ! x-ui.sh не скачан: %v\n", err) + } + return nil +} + +func installSingBoxExtended(ctx context.Context, version, arch string) error { + v := strings.TrimPrefix(version, "v") + url := fmt.Sprintf("https://gitea.digitalforest.my/hesoyam/sing-box-extended-mirror/releases/download/v%s/sing-box-%s-linux-%s.tar.gz", v, v, arch) + return fetchTarGz(ctx, url, func(name string) (string, bool) { + if filepath.Base(name) == "sing-box" { + return "/usr/local/bin/sing-box", true + } + return "", false + }) +} + +var httpClient = &http.Client{Timeout: 10 * time.Minute} + +func download(ctx context.Context, url, dest string, mode os.FileMode) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s: %s", url, resp.Status) + } + return writeAtomic(dest, resp.Body, mode) +} + +// fetchTarGz скачивает tar.gz и распаковывает выбранные записи. +func fetchTarGz(ctx context.Context, url string, route func(name string) (string, bool)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s: %s", url, resp.Status) + } + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("%s: %w", url, err) + } + tr := tar.NewReader(gz) + found := 0 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("%s: %w", url, err) + } + if strings.Contains(hdr.Name, "..") { + continue + } + dest, ok := route(hdr.Name) + if !ok { + continue + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := writeAtomic(dest, tr, os.FileMode(hdr.Mode).Perm()|0o400); err != nil { + return err + } + found++ + case tar.TypeSymlink: + _ = os.Remove(dest) + if err := os.Symlink(hdr.Linkname, dest); err != nil { + return err + } + } + } + if found == 0 { + return fmt.Errorf("%s: нужные файлы в архиве не найдены", url) + } + return nil +} + +func writeAtomic(dest string, r io.Reader, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + tmp := dest + ".bm-tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + if _, err := io.Copy(f, r); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Chmod(tmp, mode); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, dest) +} + +func copyFile(src, dest string, mode os.FileMode) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + return writeAtomic(dest, f, mode) +} + +// copyTree копирует дерево src в dest с правами и симлинками (fallback-бинарники из бэкапа). +func copyTree(src, dest string) error { + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dest, rel) + fi, err := os.Lstat(p) + if err != nil { + return err + } + switch { + case fi.IsDir(): + return os.MkdirAll(target, fi.Mode().Perm()|0o700) + case fi.Mode()&os.ModeSymlink != 0: + link, err := os.Readlink(p) + if err != nil { + return err + } + _ = os.Remove(target) + return os.Symlink(link, target) + case fi.Mode().IsRegular(): + return copyFile(p, target, fi.Mode().Perm()) + } + return nil + }) +} + +func exists(p string) bool { + _, err := os.Lstat(p) + return err == nil +} diff --git a/internal/restore/restore.go b/internal/restore/restore.go new file mode 100644 index 0000000..8e75ddd --- /dev/null +++ b/internal/restore/restore.go @@ -0,0 +1,552 @@ +// Package restore восстанавливает сервер из бэкапа: базовая подготовка, установка +// тех же версий софта, раскладка файлов и данных, запуск сервисов. +package restore + +import ( + "bufio" + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/api" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/archive" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx" +) + +//go:embed provision.sh +var provisionScript []byte + +type Options struct { + Server string + Token string + BackupID int64 + User string // пользователь с sudo на новом сервере + SkipProvision bool + SkipHeavy bool + Yes bool // не спрашивать подтверждение + StagingDir string + KeepStaging bool +} + +type restorer struct { + ctx context.Context + opts Options + client *api.Client + m *manifest.Manifest + backupID int64 + staging string + arch string + volumes map[string]string // имя тома → mountpoint + warnings []string + todo []string +} + +func (r *restorer) warn(format string, a ...any) { + msg := fmt.Sprintf(format, a...) + r.warnings = append(r.warnings, msg) + fmt.Println(" ! " + msg) +} + +func step(format string, a ...any) { fmt.Printf("\n==> "+format+"\n", a...) } +func info(format string, a ...any) { fmt.Printf(" "+format+"\n", a...) } + +func Run(ctx context.Context, opts Options) error { + if os.Geteuid() != 0 { + return errors.New("restore нужно запускать от root") + } + if opts.Token == "" { + return errors.New("нужен --token (команда восстановления выдаётся в веб-морде)") + } + r := &restorer{ctx: ctx, opts: opts, client: api.NewClient(opts.Server, opts.Token), volumes: map[string]string{}} + + rm, err := r.client.RestoreManifest(ctx, opts.BackupID) + if err != nil { + return fmt.Errorf("манифест: %w", err) + } + var m manifest.Manifest + if err := json.Unmarshal(rm.Manifest, &m); err != nil { + return fmt.Errorf("манифест: %w", err) + } + r.m, r.backupID = &m, rm.BackupID + r.arch = sysx.DpkgArch(ctx) + + r.summary(rm) + if !opts.Yes && !confirm("Восстановить этот бэкап на ТЕКУЩИЙ сервер? Файлы будут перезаписаны") { + return errors.New("отменено") + } + + r.staging = opts.StagingDir + if r.staging == "" { + r.staging = "/var/tmp/backup-restore" + } + r.staging = filepath.Join(r.staging, fmt.Sprint(rm.BackupID)) + if err := os.MkdirAll(r.staging, 0o700); err != nil { + return err + } + if !opts.KeepStaging { + defer os.RemoveAll(r.staging) // там дампы баз и ключи + } + + if !opts.SkipProvision { + if err := r.provision(); err != nil { + return fmt.Errorf("подготовка сервера: %w", err) + } + } + if err := r.prepareVolumes(); err != nil { + return err + } + + parts := map[string]api.PartInfo{} + for _, p := range rm.Parts { + parts[p.Name] = p + } + main, ok := parts[manifest.MainPart] + if !ok { + return errors.New("в бэкапе нет основной части") + } + step("Распаковка основной части (%s)", human(main.Size)) + if err := r.extractPart(main); err != nil { + return err + } + for _, name := range m.Parts { + if name == manifest.MainPart { + continue + } + p, ok := parts[name] + switch { + case !ok: + r.warn("heavy-часть %s удалена ротацией — данные категории %s останутся пустыми", name, name) + case opts.SkipHeavy: + r.warn("heavy-часть %s пропущена (--skip-heavy)", name) + default: + step("Распаковка heavy-части %s (%s)", name, human(p.Size)) + if err := r.extractPart(p); err != nil { + r.warn("heavy-часть %s: %v", name, err) + } + } + } + // heavy-категории, не попавшие в этот бэкап + for _, c := range m.Categories { + if c.HeavySkipped { + r.warn("%s: heavy-данные (%s) в этот бэкап не входили — выберите бэкап с heavy-частью, если они нужны", c.Name, strings.Join(c.Heavy, ", ")) + } + } + + _ = sysx.Run(ctx, "systemctl", "daemon-reload") + var failed []string + for _, c := range order(m.Categories) { + step("Категория %s (%s)", c.Name, c.Type) + if err := r.restoreCategory(c); err != nil { + r.warn("%s: %v", c.Name, err) + failed = append(failed, c.Name) + } + } + r.finish(failed) + if len(failed) > 0 { + return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", ")) + } + return nil +} + +func (r *restorer) summary(rm *api.RestoreManifest) { + m := r.m + step("Бэкап #%d %s от %s", rm.BackupID, m.Domain, rm.CreatedAt.Local().Format("02.01.2006 15:04")) + info("старый сервер: %s, %s (%s)", m.Hostname, m.OS.PrettyName, m.OS.Arch) + osr := sysx.OSRelease() + info("этот сервер: %s (%s)", osr["PRETTY_NAME"], r.arch) + if m.OS.Arch != "" && r.arch != "" && m.OS.Arch != r.arch { + r.warn("архитектура отличается (%s → %s): fallback-бинарники из бэкапа не подойдут", m.OS.Arch, r.arch) + } + if osr["VERSION_ID"] != "" && m.OS.VersionID != "" && osr["VERSION_ID"] != m.OS.VersionID { + r.warn("версия ОС отличается: %s → %s", m.OS.VersionID, osr["VERSION_ID"]) + } + for _, c := range m.Categories { + line := c.Name + " (" + c.Type + if c.Version != "" { + line += " " + c.Version + } + line += ")" + if c.Compose != nil { + line += " " + c.Compose.Dir + } + info("• %s", line) + } + var parts []string + for _, p := range rm.Parts { + parts = append(parts, fmt.Sprintf("%s %s", p.Name, human(p.Size))) + } + info("части: %s", strings.Join(parts, ", ")) +} + +func confirm(q string) bool { + fmt.Printf("\n%s? [y/N]: ", q) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes", "д", "да": + return true + } + return false +} + +func (r *restorer) hasCategory(name string) bool { + return slices.ContainsFunc(r.m.Categories, func(c manifest.Category) bool { return c.Name == name }) +} + +func yn(b bool) string { + if b { + return "y" + } + return "n" +} + +func (r *restorer) provision() error { + step("Базовая подготовка сервера") + m := r.m + hasCompose := slices.ContainsFunc(m.Categories, func(c manifest.Category) bool { return c.Type == catalog.TypeCompose }) + hostname := m.Hostname + if hostname == "" || hostname == "localhost" { + hostname = m.Domain + } + env := []string{ + "NEW_HOSTNAME=" + hostname, + "INSTALL_BLOCKLIST=" + yn(m.Features.Blocklist), + "HARDEN_SSH=" + yn(m.Features.SSHHardened), + "INSTALL_DOCKER=" + yn(hasCompose || m.Packages["docker-ce"] != ""), + "DOCKER_CE_VERSION=" + m.Packages["docker-ce"], + "DOCKER_CLI_VERSION=" + m.Packages["docker-ce-cli"], + "COMPOSE_VERSION=" + m.Packages["docker-compose-plugin"], + "INSTALL_CADDY=" + yn(m.Packages["caddy"] != "" || r.hasCategory("caddy")), + "CADDY_VERSION=" + m.Packages["caddy"], + "INSTALL_TELEGRAF=" + yn(m.Packages["telegraf"] != "" || r.hasCategory("telegraf")), + "TELEGRAF_VERSION=" + m.Packages["telegraf"], + } + if len(m.SudoUsers) > 0 { + env = append(env, "DEFAULT_USER="+m.SudoUsers[0]) + } + if r.opts.User != "" { + env = append(env, "NEW_USER="+r.opts.User) + } + script := filepath.Join(r.staging, "provision.sh") + if err := os.WriteFile(script, provisionScript, 0o700); err != nil { + return err + } + cmd := exec.CommandContext(r.ctx, "bash", script) + cmd.Env = append(os.Environ(), env...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} + +// prepareVolumes создаёт docker-тома заранее (с метками compose), чтобы распаковать данные прямо в них. +func (r *restorer) prepareVolumes() error { + var need bool + for _, c := range r.m.Categories { + if len(c.Volumes) > 0 { + need = true + } + } + if !need { + return nil + } + if !sysx.HasCommand("docker") { + return errors.New("в бэкапе есть docker-тома, но docker не установлен (запустите без --skip-provision)") + } + step("Docker-тома") + for _, c := range r.m.Categories { + for _, v := range c.Volumes { + if _, err := sysx.Output(r.ctx, "docker", "volume", "inspect", v.Name); err != nil { + args := []string{"volume", "create"} + if v.Key != "" && c.Compose != nil { + args = append(args, "--label", "com.docker.compose.project="+c.Compose.Project, + "--label", "com.docker.compose.volume="+v.Key) + } + if _, err := sysx.Output(r.ctx, "docker", append(args, v.Name)...); err != nil { + return fmt.Errorf("том %s: %w", v.Name, err) + } + } + mp, err := sysx.Output(r.ctx, "docker", "volume", "inspect", "-f", "{{.Mountpoint}}", v.Name) + if err != nil { + return fmt.Errorf("том %s: %w", v.Name, err) + } + r.volumes[v.Name] = mp + info("%s → %s", v.Name, mp) + } + } + return nil +} + +// route раскладывает записи архива: files → /, volumes → mountpoint тома, dumps и bin → staging. +func (r *restorer) route(name string) (string, bool) { + cat, rest, ok := strings.Cut(name, "/") + if !ok || cat == "" { + return "", false + } + kind, rest, ok := strings.Cut(rest, "/") + if !ok || rest == "" { + return "", false + } + switch kind { + case "files": + return "/" + rest, true + case "bin", "dumps": + return filepath.Join(r.staging, cat, kind, filepath.FromSlash(rest)), true + case "volumes": + vol, inner, _ := strings.Cut(rest, "/") + mp, ok := r.volumes[vol] + if !ok { + return filepath.Join(r.staging, cat, "volumes", filepath.FromSlash(rest)), true + } + return filepath.Join(mp, filepath.FromSlash(inner)), true + } + return "", false +} + +func (r *restorer) extractPart(p api.PartInfo) error { + body := &resumableReader{ctx: r.ctx, client: r.client, backupID: r.backupID, part: p.Name, h: sha256.New()} + defer body.Close() + ex := &archive.Extractor{Route: r.route} + if err := ex.Extract(body); err != nil { + return err + } + // дочитываем хвост (zstd мог не запросить последние байты), чтобы сверить сумму + if _, err := io.Copy(io.Discard, body); err != nil { + return err + } + if sum := hex.EncodeToString(body.h.Sum(nil)); sum != p.SHA256 { + return fmt.Errorf("контрольная сумма части %s не совпала", p.Name) + } + for _, w := range ex.Warnings { + r.warn("%s", w) + } + info("распаковано записей: %d", ex.Files) + return nil +} + +// resumableReader читает часть с сервера и при обрыве продолжает с того же байта (Range). +type resumableReader struct { + ctx context.Context + client *api.Client + backupID int64 + part string + body io.ReadCloser + off int64 + retries int + h hash.Hash +} + +func (rr *resumableReader) Read(p []byte) (int, error) { + for { + if rr.body == nil { + b, err := rr.client.DownloadPart(rr.ctx, rr.backupID, rr.part, rr.off) + if err != nil { + if rr.retries++; rr.retries > 5 { + return 0, err + } + time.Sleep(time.Duration(rr.retries) * 5 * time.Second) + continue + } + rr.body = b + } + n, err := rr.body.Read(p) + rr.off += int64(n) + rr.h.Write(p[:n]) + if err == nil || errors.Is(err, io.EOF) { + return n, err + } + rr.body.Close() + rr.body = nil + if rr.retries++; rr.retries > 5 { + return n, err + } + fmt.Printf(" ! обрыв загрузки на %s, продолжаю: %v\n", human(rr.off), err) + if n > 0 { + return n, nil + } + } +} + +func (rr *resumableReader) Close() error { + if rr.body != nil { + return rr.body.Close() + } + return nil +} + +func (r *restorer) restoreCategory(c manifest.Category) error { + for _, d := range c.EnsureDirs { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + switch c.Type { + case catalog.TypeSystemd, catalog.TypeXUI: + if err := r.installBinary(c); err != nil { + return err + } + if err := r.restoreSQLite(c); err != nil { + return err + } + case catalog.TypeCompose: + if err := r.restoreCompose(c); err != nil { + return err + } + } + if c.Name == "system" { + r.fixCrontabs() + } + for _, d := range c.Dumps { + if d.Kind != "cmd" { + continue + } + if d.RestoreCmd == "" { + r.warn("%s: для дампа %s не задан restore_cmd — файл не применён", c.Name, d.Name) + continue + } + file := filepath.Join(r.staging, c.Name, "dumps", d.Name) + if err := sysx.Shell(r.ctx, d.RestoreCmd, "DUMP_FILE="+file); err != nil { + return fmt.Errorf("дамп %s: %w", d.Name, err) + } + } + for _, cmd := range c.PostRestore { + if err := sysx.Shell(r.ctx, cmd); err != nil { + r.warn("%s: post_restore %q: %v", c.Name, cmd, err) + } + } + if c.Service != nil { + return r.applyService(c.Service) + } + return nil +} + +// installBinary ставит версию со старого сервера, при неудаче — копию бинарника из бэкапа. +func (r *restorer) installBinary(c manifest.Category) error { + if c.Install != "" && c.Version != "" { + if rec, ok := recipes[c.Install]; ok { + info("установка %s %s", c.Install, c.Version) + err := rec(r.ctx, c.Version, r.arch) + if err == nil { + return nil + } + r.warn("%s: установка %s %s не удалась: %v", c.Name, c.Install, c.Version, err) + } else { + r.warn("%s: неизвестный recipe %q", c.Name, c.Install) + } + } + if len(c.Binaries) == 0 { + if c.Install != "" { + return fmt.Errorf("бинарник не установлен и копии в бэкапе нет") + } + return nil + } + if r.m.OS.Arch != "" && r.arch != "" && r.m.OS.Arch != r.arch { + return fmt.Errorf("копия бинарника из бэкапа под %s, а сервер %s", r.m.OS.Arch, r.arch) + } + info("использую копию бинарника из бэкапа") + return copyTree(filepath.Join(r.staging, c.Name, "bin"), "/") +} + +func (r *restorer) restoreSQLite(c manifest.Category) error { + for _, d := range c.Dumps { + if d.Kind != "sqlite" || d.Target == "" { + continue + } + if c.Service != nil { + _ = sysx.Run(r.ctx, "systemctl", "stop", c.Service.Unit) + } + src := filepath.Join(r.staging, c.Name, "dumps", d.Name) + for _, suffix := range []string{"-wal", "-shm"} { + _ = os.Remove(d.Target + suffix) + } + if err := copyFile(src, d.Target, 0o600); err != nil { + return fmt.Errorf("база %s: %w", d.Target, err) + } + info("база %s восстановлена", d.Target) + } + return nil +} + +func (r *restorer) applyService(s *manifest.ServiceState) error { + _ = sysx.Run(r.ctx, "systemctl", "daemon-reload") + if s.Enabled { + if err := sysx.Run(r.ctx, "systemctl", "enable", s.Unit); err != nil { + return err + } + } else { + _ = sysx.Run(r.ctx, "systemctl", "disable", s.Unit) + } + if s.Active { + if err := sysx.Run(r.ctx, "systemctl", "restart", s.Unit); err != nil { + return fmt.Errorf("%s не запустился (journalctl -u %s): %w", s.Unit, s.Unit, err) + } + info("%s запущен", s.Unit) + } else { + _ = sysx.Run(r.ctx, "systemctl", "stop", s.Unit) + info("%s оставлен остановленным, как на старом сервере", s.Unit) + } + return nil +} + +// fixCrontabs выставляет crontab-файлам владельца и права, которые требует cron. +func (r *restorer) fixCrontabs() { + files, _ := filepath.Glob("/var/spool/cron/crontabs/*") + for _, f := range files { + user := filepath.Base(f) + if _, err := sysx.Output(r.ctx, "id", "-u", user); err != nil { + r.warn("crontab пользователя %s восстановлен, но такого пользователя нет — cron его не выполнит", user) + continue + } + _ = sysx.Run(r.ctx, "chown", user+":crontab", f) + _ = os.Chmod(f, 0o600) + } + if len(files) > 0 { + _ = sysx.Run(r.ctx, "systemctl", "restart", "cron") + } +} + +func (r *restorer) finish(failed []string) { + step("Итог") + if len(failed) == 0 { + info("Все категории восстановлены.") + } + if len(r.warnings) > 0 { + info("Предупреждения:") + for _, w := range r.warnings { + info(" ! %s", w) + } + } + r.todo = append(r.todo, + fmt.Sprintf("Перевести DNS %s (и другие домены сервера) на IP этого сервера", r.m.Domain), + "Если это lighthouse Nebula — обновить static_host_map на остальных узлах", + "Перезагрузить сервер (iptables-legacy и обновление ядра): sudo reboot", + ) + info("Осталось сделать:") + for _, t := range r.todo { + info(" • %s", t) + } +} + +func human(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d Б", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp]) +} diff --git a/internal/restore/restore_test.go b/internal/restore/restore_test.go new file mode 100644 index 0000000..5ea5755 --- /dev/null +++ b/internal/restore/restore_test.go @@ -0,0 +1,51 @@ +package restore + +import ( + "path/filepath" + "testing" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" +) + +func TestOrder(t *testing.T) { + cats := []manifest.Category{ + {Name: "system"}, + {Name: "stoat-mbot", RestoreAfter: []string{"stoat", "psiphon"}}, + {Name: "pnvpanel"}, + {Name: "stoat"}, + } + var names []string + for _, c := range order(cats) { + names = append(names, c.Name) + } + want := []string{"system", "pnvpanel", "stoat", "stoat-mbot"} + for i := range want { + if names[i] != want[i] { + t.Fatalf("order = %v, want %v", names, want) + } + } + // цикл не зависает + cyc := order([]manifest.Category{{Name: "a", RestoreAfter: []string{"b"}}, {Name: "b", RestoreAfter: []string{"a"}}}) + if len(cyc) != 2 { + t.Fatal("цикл потерял категории") + } +} + +func TestRoute(t *testing.T) { + r := &restorer{staging: "/stage", volumes: map[string]string{"pnvpanel_ticket_uploads": "/var/lib/docker/volumes/pnvpanel_ticket_uploads/_data"}} + cases := map[string]string{ + "pnvpanel/files/opt/PnvPanel/.env": "/opt/PnvPanel/.env", + "pnvpanel/dumps/db.pgdump": filepath.Join("/stage", "pnvpanel", "dumps", "db.pgdump"), + "pnvpanel/volumes/pnvpanel_ticket_uploads/a/b.png": filepath.Join("/var/lib/docker/volumes/pnvpanel_ticket_uploads/_data", "a", "b.png"), + "xui/bin/usr/local/x-ui/x-ui": filepath.Join("/stage", "xui", "bin", "usr", "local", "x-ui", "x-ui"), + } + for name, want := range cases { + got, ok := r.route(name) + if !ok || got != want { + t.Errorf("route(%q) = %q, %v; want %q", name, got, ok, want) + } + } + if _, ok := r.route("manifest.json"); ok { + t.Error("manifest.json не должен распаковываться") + } +} diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..8a72aac --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,395 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/api" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" +) + +type ctxKey int + +const clientKey ctxKey = 1 + +func clientFrom(r *http.Request) *Client { return r.Context().Value(clientKey).(*Client) } + +func jsonResp(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func jsonErr(w http.ResponseWriter, code int, msg string) { + jsonResp(w, code, map[string]string{"error": msg}) +} + +// agentAuth — upload-токен клиента. Даёт только запись своих бэкапов. +func (s *Server) agentAuth(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tok := bearer(r) + if tok == "" { + jsonErr(w, http.StatusUnauthorized, "нужен токен") + return + } + c, err := s.st.ClientByToken(hashToken(tok)) + if err != nil || c.Disabled { + jsonErr(w, http.StatusUnauthorized, "неверный токен или клиент отключён") + return + } + _ = s.st.TouchClient(c.ID) + h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c))) + } +} + +// restoreAuth — временный restore-токен, даёт чтение бэкапов одного клиента. +func (s *Server) restoreAuth(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tok := bearer(r) + id, err := s.st.RestoreTokenClient(hashToken(tok)) + if tok == "" || err != nil { + jsonErr(w, http.StatusUnauthorized, "неверный или просроченный restore-токен") + return + } + c, err := s.st.GetClient(id) + if err != nil { + jsonErr(w, http.StatusUnauthorized, "клиент удалён") + return + } + h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c))) + } +} + +type AgentConfigResponse = api.AgentConfig + +func (s *Server) apiAgentConfig(w http.ResponseWriter, r *http.Request) { + c := clientFrom(r) + due := c.HeavyEveryDays >= 0 && + time.Since(time.Unix(c.LastHeavyAt, 0)) >= time.Duration(c.HeavyEveryDays)*24*time.Hour-time.Hour + jsonResp(w, http.StatusOK, AgentConfigResponse{Domain: c.Domain, ConfigYAML: c.ConfigYAML, HeavyDue: due}) +} + +var partNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) + +func (s *Server) apiCreateBackup(w http.ResponseWriter, r *http.Request) { + c := clientFrom(r) + var m manifest.Manifest + if err := json.NewDecoder(io.LimitReader(r.Body, 16<<20)).Decode(&m); err != nil { + jsonErr(w, http.StatusBadRequest, "манифест: "+err.Error()) + return + } + if !slices.Contains(m.Parts, manifest.MainPart) { + jsonErr(w, http.StatusBadRequest, "в манифесте нет части main") + return + } + for _, p := range m.Parts { + if !partNameRe.MatchString(p) { + jsonErr(w, http.StatusBadRequest, "недопустимое имя части "+p) + return + } + } + m.Domain = c.Domain + now := time.Now() + if m.CreatedAt.IsZero() { + m.CreatedAt = now + } + + // предыдущая незавершённая загрузка этого клиента уже не продолжится + if old, err := s.st.UploadingBefore(now.Unix()+1, c.ID); err == nil { + for _, b := range old { + if !s.isUploading(b.ID) { + s.failBackup(b, "прервана новым бэкапом") + } + } + } + + raw, _ := json.Marshal(m) + id, err := s.st.CreateBackup(c.ID, now.Unix(), string(raw)) + if err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + dir := filepath.Join(c.Domain, fmt.Sprintf("%s-%d", now.Format("20060102-150405"), id)) + if err := os.MkdirAll(filepath.Join(s.backupsDir(), dir), 0o700); err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.st.SetBackupDir(id, dir); err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + log.Printf("бэкап #%d (%s) начат, части %v", id, c.Domain, m.Parts) + jsonResp(w, http.StatusCreated, map[string]int64{"id": id}) +} + +// ownBackup загружает бэкап из пути и проверяет, что он принадлежит клиенту. +func (s *Server) ownBackup(w http.ResponseWriter, r *http.Request) (*Backup, *manifest.Manifest, bool) { + c := clientFrom(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + jsonErr(w, http.StatusBadRequest, "id") + return nil, nil, false + } + b, err := s.st.GetBackup(id) + if err != nil || b.ClientID != c.ID { + jsonErr(w, http.StatusNotFound, "бэкап не найден") + return nil, nil, false + } + var m manifest.Manifest + if err := json.Unmarshal([]byte(b.Manifest), &m); err != nil { + jsonErr(w, http.StatusInternalServerError, "манифест: "+err.Error()) + return nil, nil, false + } + return b, &m, true +} + +func (s *Server) isUploading(id int64) bool { + s.uploadMu.Lock() + defer s.uploadMu.Unlock() + return s.uploads[id] +} + +func (s *Server) apiUploadPart(w http.ResponseWriter, r *http.Request) { + b, m, ok := s.ownBackup(w, r) + if !ok { + return + } + name := r.PathValue("name") + if b.Status != StatusUploading { + jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status) + return + } + if !slices.Contains(m.Parts, name) { + jsonErr(w, http.StatusBadRequest, "части "+name+" нет в манифесте") + return + } + + s.uploadMu.Lock() + if s.uploads[b.ID] { + s.uploadMu.Unlock() + jsonErr(w, http.StatusConflict, "загрузка уже идёт") + return + } + s.uploads[b.ID] = true + s.uploadMu.Unlock() + defer func() { + s.uploadMu.Lock() + delete(s.uploads, b.ID) + s.uploadMu.Unlock() + }() + + tmp := s.partPath(b.Dir, name) + ".part" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + h := sha256.New() + n, err := io.Copy(io.MultiWriter(f, h), http.MaxBytesReader(w, r.Body, s.cfg.MaxUpload)) + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + os.Remove(tmp) + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + jsonErr(w, http.StatusRequestEntityTooLarge, "превышен размер части") + return + } + jsonErr(w, http.StatusBadRequest, "загрузка прервана: "+err.Error()) + return + } + sum := hex.EncodeToString(h.Sum(nil)) + if err := s.st.UpsertPart(Part{BackupID: b.ID, Name: name, Size: n, SHA256: sum, Status: PartUploaded}); err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + jsonResp(w, http.StatusOK, PartInfo{Name: name, Size: n, SHA256: sum}) +} + +type PartInfo = api.PartInfo + +type CompleteRequest = api.CompleteRequest + +func (s *Server) apiComplete(w http.ResponseWriter, r *http.Request) { + c := clientFrom(r) + b, m, ok := s.ownBackup(w, r) + if !ok { + return + } + if b.Status != StatusUploading { + jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status) + return + } + var req CompleteRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + jsonErr(w, http.StatusBadRequest, err.Error()) + return + } + claimed := map[string]PartInfo{} + for _, p := range req.Parts { + claimed[p.Name] = p + } + stored := map[string]Part{} + for _, p := range b.Parts { + stored[p.Name] = p + } + var total int64 + for _, name := range m.Parts { + p, ok := stored[name] + cl := claimed[name] + if !ok { + jsonErr(w, http.StatusBadRequest, "часть "+name+" не загружена") + return + } + if cl.SHA256 != p.SHA256 || cl.Size != p.Size { + s.failBackup(b, fmt.Sprintf("часть %s: контрольная сумма не совпала", name)) + jsonErr(w, http.StatusBadRequest, "контрольная сумма части "+name+" не совпала") + return + } + total += p.Size + } + for _, name := range m.Parts { + final := s.partPath(b.Dir, name) + if err := os.Rename(final+".part", final); err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.st.SetPartStatus(b.ID, name, PartComplete); err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + } + if len(req.Warnings) > 0 { + m.Warnings = append(m.Warnings, req.Warnings...) + raw, _ := json.Marshal(m) + b.Manifest = string(raw) + if err := s.st.SetBackupManifest(b.ID, b.Manifest); err != nil { + log.Printf("backup #%d: %v", b.ID, err) + } + } + _ = os.WriteFile(filepath.Join(s.backupsDir(), b.Dir, manifest.ManifestFile), []byte(b.Manifest), 0o600) + if err := s.st.SetBackupStatus(b.ID, StatusComplete, "", total); err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + heavy := len(m.Parts) > 1 + if err := s.st.MarkClientSuccess(c.ID, b.CreatedAt, heavy); err != nil { + log.Printf("client %d: %v", c.ID, err) + } + log.Printf("бэкап #%d (%s) завершён: %s", b.ID, c.Domain, humanBytes(total)) + if s.st.Setting(settingNotifySuccess) == "1" { + msg := fmt.Sprintf("%s: %s, частей %d", c.Domain, humanBytes(total), len(m.Parts)) + if len(m.Warnings) > 0 { + msg += fmt.Sprintf(", предупреждений %d", len(m.Warnings)) + } + s.notify("Бэкап готов: "+c.Domain, msg, "low", "white_check_mark") + } + if c2, err := s.st.GetClient(c.ID); err == nil { + if err := s.applyRetention(c2); err != nil { + log.Printf("ретеншн %s: %v", c.Domain, err) + } + } + jsonResp(w, http.StatusOK, map[string]any{"id": b.ID, "size": total}) +} + +func (s *Server) apiFail(w http.ResponseWriter, r *http.Request) { + b, _, ok := s.ownBackup(w, r) + if !ok { + return + } + var req struct { + Error string `json:"error"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req) + if req.Error == "" { + req.Error = "агент сообщил об ошибке" + } + if b.Status == StatusUploading { + s.failBackup(b, req.Error) + } + jsonResp(w, http.StatusOK, map[string]string{"status": StatusFailed}) +} + +type RestoreManifestResponse = api.RestoreManifest + +func (s *Server) apiRestoreManifest(w http.ResponseWriter, r *http.Request) { + c := clientFrom(r) + var b *Backup + var err error + if v := r.URL.Query().Get("backup"); v != "" { + id, _ := strconv.ParseInt(v, 10, 64) + b, err = s.st.GetBackup(id) + if err == nil && (b.ClientID != c.ID || b.Status != StatusComplete) { + err = ErrNotFound + } + } else { + b, err = s.st.LatestComplete(c.ID) + } + if err != nil { + jsonErr(w, http.StatusNotFound, "нет подходящего бэкапа") + return + } + resp := RestoreManifestResponse{BackupID: b.ID, CreatedAt: time.Unix(b.CreatedAt, 0), Manifest: json.RawMessage(b.Manifest)} + for _, p := range b.Parts { + if p.Status == PartComplete { + resp.Parts = append(resp.Parts, PartInfo{Name: p.Name, Size: p.Size, SHA256: p.SHA256}) + } + } + // heavy-части из более ранних бэкапов здесь не подмешиваются: restore может запросить + // их отдельно через ?backup=, если понадобится + jsonResp(w, http.StatusOK, resp) +} + +func (s *Server) apiRestorePart(w http.ResponseWriter, r *http.Request) { + c := clientFrom(r) + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + b, err := s.st.GetBackup(id) + if err != nil || b.ClientID != c.ID || b.Status != StatusComplete { + jsonErr(w, http.StatusNotFound, "бэкап не найден") + return + } + s.servePart(w, r, b, r.PathValue("name")) +} + +func (s *Server) servePart(w http.ResponseWriter, r *http.Request, b *Backup, name string) { + found := false + for _, p := range b.Parts { + if p.Name == name && p.Status == PartComplete { + found = true + w.Header().Set("X-Content-Sha256", p.SHA256) + } + } + if !found { + http.Error(w, "часть не найдена", http.StatusNotFound) + return + } + f, err := os.Open(s.partPath(b.Dir, name)) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/zstd") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%d-%s.tar.zst"`, + filepath.Base(filepath.Dir(b.Dir)), b.ID, name)) + http.ServeContent(w, r, "", fi.ModTime(), f) +} diff --git a/internal/server/api_test.go b/internal/server/api_test.go new file mode 100644 index 0000000..cc09bf0 --- /dev/null +++ b/internal/server/api_test.go @@ -0,0 +1,231 @@ +package server + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" +) + +func newTestServer(t *testing.T) (*Server, *httptest.Server) { + t.Helper() + s, err := New(Config{DataDir: t.TempDir(), PublicURL: "https://backup.test"}) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(s.Handler()) + t.Cleanup(func() { ts.Close(); s.Close() }) + return s, ts +} + +func call(t *testing.T, method, url, token string, body io.Reader) *http.Response { + t.Helper() + req, err := http.NewRequest(method, url, body) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +func decode(t *testing.T, resp *http.Response, v any) { + t.Helper() + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatal(err) + } +} + +func sum(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func TestUploadAndRestoreFlow(t *testing.T) { + s, ts := newTestServer(t) + agentTok := newToken() + clientID, err := s.st.CreateClient("nl1", "nl1.example.com", hashToken(agentTok)) + if err != nil { + t.Fatal(err) + } + + // без токена — 401 + if r := call(t, "GET", ts.URL+"/api/v1/agent/config", "", nil); r.StatusCode != 401 { + t.Fatalf("без токена: %d", r.StatusCode) + } + var cfg AgentConfigResponse + r := call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil) + decode(t, r, &cfg) + if cfg.Domain != "nl1.example.com" || !cfg.HeavyDue { + t.Fatalf("config: %+v", cfg) + } + + m := manifest.Manifest{FormatVersion: 1, Hostname: "nl1", Parts: []string{"main", "stoat"}} + raw, _ := json.Marshal(m) + r = call(t, "POST", ts.URL+"/api/v1/backups", agentTok, bytes.NewReader(raw)) + if r.StatusCode != 201 { + t.Fatalf("create: %d", r.StatusCode) + } + var created struct{ ID int64 } + decode(t, r, &created) + + mainData := []byte("main-archive-data") + heavyData := bytes.Repeat([]byte("minio"), 1000) + for name, data := range map[string][]byte{"main": mainData, "stoat": heavyData} { + r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/%s", ts.URL, created.ID, name), agentTok, bytes.NewReader(data)) + if r.StatusCode != 200 { + t.Fatalf("upload %s: %d", name, r.StatusCode) + } + } + // часть вне манифеста + r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/other", ts.URL, created.ID), agentTok, strings.NewReader("x")) + if r.StatusCode != 400 { + t.Fatalf("чужая часть: %d", r.StatusCode) + } + + done := CompleteRequest{Parts: []PartInfo{ + {Name: "main", Size: int64(len(mainData)), SHA256: sum(mainData)}, + {Name: "stoat", Size: int64(len(heavyData)), SHA256: sum(heavyData)}, + }} + raw, _ = json.Marshal(done) + r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), agentTok, bytes.NewReader(raw)) + if r.StatusCode != 200 { + body, _ := io.ReadAll(r.Body) + t.Fatalf("complete: %d %s", r.StatusCode, body) + } + + c, _ := s.st.GetClient(clientID) + if c.LastBackupAt == 0 || c.LastHeavyAt == 0 { + t.Fatalf("клиент не отмечен: %+v", c) + } + r = call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil) + decode(t, r, &cfg) + if cfg.HeavyDue { + t.Fatal("heavy только что загружен") + } + + // upload-токен не даёт читать + if r := call(t, "GET", ts.URL+"/api/v1/restore/manifest", agentTok, nil); r.StatusCode != 401 { + t.Fatalf("агентский токен читает бэкапы: %d", r.StatusCode) + } + + restoreTok := newToken() + if err := s.st.CreateRestoreToken(hashToken(restoreTok), clientID, time.Hour); err != nil { + t.Fatal(err) + } + var rm RestoreManifestResponse + r = call(t, "GET", ts.URL+"/api/v1/restore/manifest", restoreTok, nil) + decode(t, r, &rm) + if rm.BackupID != created.ID || len(rm.Parts) != 2 { + t.Fatalf("restore manifest: %+v", rm) + } + var got manifest.Manifest + if err := json.Unmarshal(rm.Manifest, &got); err != nil || got.Domain != "nl1.example.com" { + t.Fatalf("manifest domain: %q %v", got.Domain, err) + } + + // скачивание с Range (докачка) + req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/restore/%d/parts/main", ts.URL, created.ID), nil) + req.Header.Set("Authorization", "Bearer "+restoreTok) + req.Header.Set("Range", "bytes=5-") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 206 || string(body) != string(mainData[5:]) { + t.Fatalf("range: %d %q", resp.StatusCode, body) + } +} + +func TestChecksumMismatchFails(t *testing.T) { + s, ts := newTestServer(t) + tok := newToken() + if _, err := s.st.CreateClient("x", "x.example.com", hashToken(tok)); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(manifest.Manifest{Parts: []string{"main"}}) + r := call(t, "POST", ts.URL+"/api/v1/backups", tok, bytes.NewReader(raw)) + var created struct{ ID int64 } + decode(t, r, &created) + call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/main", ts.URL, created.ID), tok, strings.NewReader("data")) + raw, _ = json.Marshal(CompleteRequest{Parts: []PartInfo{{Name: "main", Size: 4, SHA256: "bad"}}}) + r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), tok, bytes.NewReader(raw)) + if r.StatusCode != 400 { + t.Fatalf("complete с неверной суммой: %d", r.StatusCode) + } + b, _ := s.st.GetBackup(created.ID) + if b.Status != StatusFailed { + t.Fatalf("статус %s", b.Status) + } +} + +func TestWebLoginAndPages(t *testing.T) { + s, ts := newTestServer(t) + if err := s.st.SetAdmin("admin", "secret-pass"); err != nil { + t.Fatal(err) + } + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(ts.URL + "/") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 303 { + t.Fatalf("без сессии: %d", resp.StatusCode) + } + resp, err = client.PostForm(ts.URL+"/login", map[string][]string{"user": {"admin"}, "password": {"secret-pass"}}) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + var session *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == sessionCookie { + session = c + } + } + if session == nil { + t.Fatal("нет cookie сессии") + } + for _, p := range []string{"/", "/clients/new", "/settings"} { + req, _ := http.NewRequest("GET", ts.URL+p, nil) + req.AddCookie(session) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("%s: %d", p, resp.StatusCode) + } + } + // POST без CSRF отклоняется + req, _ := http.NewRequest("POST", ts.URL+"/clients", strings.NewReader("domain=a.example.com")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(session) + resp, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 403 { + t.Fatalf("без CSRF: %d", resp.StatusCode) + } +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..77a2f2d --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,169 @@ +package server + +import ( + "context" + "crypto/subtle" + "net" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + sessionCookie = "bm_session" + sessionTTL = 7 * 24 * time.Hour + csrfKey ctxKey = 2 +) + +// SetAdmin задаёт логин и пароль единственного администратора и сбрасывает сессии. +func (s *Store) SetAdmin(user, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + if err := s.SetSetting(settingAdminUser, user); err != nil { + return err + } + if err := s.SetSetting(settingAdminHash, string(hash)); err != nil { + return err + } + return s.DeleteAllSessions() +} + +func (s *Store) CheckAdmin(user, password string) bool { + hash := s.Setting(settingAdminHash) + if hash == "" { + return false + } + userOK := subtle.ConstantTimeCompare([]byte(user), []byte(s.Setting(settingAdminUser))) == 1 + passOK := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil + return userOK && passOK +} + +// admin пропускает только авторизованного админа; на POST дополнительно проверяет CSRF. +func (s *Server) admin(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ck, err := r.Cookie(sessionCookie) + var csrf string + if err == nil { + csrf, err = s.st.SessionCSRF(ck.Value) + } + if err != nil { + if r.Method == http.MethodGet { + http.Redirect(w, r, "/login", http.StatusSeeOther) + } else { + http.Error(w, "нужна авторизация", http.StatusUnauthorized) + } + return + } + if r.Method == http.MethodPost { + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrf)) != 1 { + http.Error(w, "неверный CSRF-токен, обновите страницу", http.StatusForbidden) + return + } + } + h(w, r.WithContext(context.WithValue(r.Context(), csrfKey, csrf))) + } +} + +func csrfFrom(r *http.Request) string { + v, _ := r.Context().Value(csrfKey).(string) + return v +} + +func (s *Server) secureCookies() bool { return strings.HasPrefix(s.cfg.PublicURL, "https://") } + +func (s *Server) webLoginPage(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "login", map[string]any{"NoAdmin": s.st.Setting(settingAdminHash) == ""}) +} + +func (s *Server) webLogin(w http.ResponseWriter, r *http.Request) { + ip := s.clientIP(r) + if !s.login.allowed(ip) { + s.render(w, r, "login", map[string]any{"Error": "Слишком много попыток, подождите несколько минут"}) + return + } + if !s.st.CheckAdmin(r.FormValue("user"), r.FormValue("password")) { + s.login.fail(ip) + time.Sleep(700 * time.Millisecond) + s.render(w, r, "login", map[string]any{"Error": "Неверный логин или пароль"}) + return + } + s.login.reset(ip) + tok := newToken() + if err := s.st.CreateSession(tok, newToken(), sessionTTL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, Secure: s.secureCookies(), + SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) webLogout(w http.ResponseWriter, r *http.Request) { + if ck, err := r.Cookie(sessionCookie); err == nil { + _ = s.st.DeleteSession(ck.Value) + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1}) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if s.cfg.TrustProxy { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[len(parts)-1]) + } + } + return host +} + +// loginLimiter: после 5 неудач подряд вход с IP блокируется на 10 минут. +type loginLimiter struct { + mu sync.Mutex + fails map[string]*loginState +} + +type loginState struct { + n int + until time.Time +} + +func newLoginLimiter() *loginLimiter { return &loginLimiter{fails: map[string]*loginState{}} } + +func (l *loginLimiter) allowed(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + st := l.fails[ip] + return st == nil || time.Now().After(st.until) +} + +func (l *loginLimiter) fail(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + st := l.fails[ip] + if st == nil { + st = &loginState{} + l.fails[ip] = st + } + st.n++ + if st.n >= 5 { + st.n = 0 + st.until = time.Now().Add(10 * time.Minute) + } +} + +func (l *loginLimiter) reset(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.fails, ip) +} diff --git a/internal/server/notify.go b/internal/server/notify.go new file mode 100644 index 0000000..87a8e72 --- /dev/null +++ b/internal/server/notify.go @@ -0,0 +1,80 @@ +package server + +import ( + "context" + "fmt" + "log" + "mime" + "net/http" + "strings" + "time" +) + +const ( + settingAdminUser = "admin_user" + settingAdminHash = "admin_hash" + settingNtfyURL = "ntfy_url" + settingNtfyAuth = "ntfy_auth" // токен tk_... или "user:password" + settingNotifySuccess = "notify_success" +) + +// notify отправляет уведомление в ntfy (асинхронно, ошибки только в лог). +func (s *Server) notify(title, msg, priority, tags string) { + url := s.st.Setting(settingNtfyURL) + if url == "" { + return + } + auth := s.st.Setting(settingNtfyAuth) + go func() { + if err := sendNtfy(url, auth, title, msg, priority, tags); err != nil { + log.Printf("ntfy: %v", err) + } + }() +} + +func sendNtfy(url, auth, title, msg, priority, tags string) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(msg)) + if err != nil { + return err + } + // заголовки должны быть ASCII — кириллицу кодируем по RFC 2047, ntfy это понимает + req.Header.Set("Title", mime.BEncoding.Encode("UTF-8", title)) + if priority != "" { + req.Header.Set("Priority", priority) + } + if tags != "" { + req.Header.Set("Tags", tags) + } + switch { + case auth == "": + case strings.Contains(auth, ":"): + u, p, _ := strings.Cut(auth, ":") + req.SetBasicAuth(u, p) + default: + req.Header.Set("Authorization", "Bearer "+auth) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("ответ %s", resp.Status) + } + return nil +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d Б", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp]) +} diff --git a/internal/server/retention.go b/internal/server/retention.go new file mode 100644 index 0000000..2825509 --- /dev/null +++ b/internal/server/retention.go @@ -0,0 +1,112 @@ +package server + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" +) + +// SelectGFS возвращает индексы бэкапов, которые нужно сохранить. +// times отсортированы от новых к старым. Последний бэкап сохраняется всегда, +// дальше — самый новый в каждом из daily последних дней, weekly недель (ISO), monthly месяцев. +func SelectGFS(times []time.Time, daily, weekly, monthly int) map[int]bool { + keep := map[int]bool{} + if len(times) == 0 { + return keep + } + keep[0] = true + bucket := func(n int, key func(time.Time) string) { + seen := map[string]bool{} + for i, t := range times { + if len(seen) >= n { + return + } + k := key(t) + if !seen[k] { + seen[k] = true + keep[i] = true + } + } + } + bucket(daily, func(t time.Time) string { return t.Format("2006-01-02") }) + bucket(weekly, func(t time.Time) string { + y, w := t.ISOWeek() + return fmt.Sprintf("%d-W%02d", y, w) + }) + bucket(monthly, func(t time.Time) string { return t.Format("2006-01") }) + return keep +} + +// applyRetention удаляет лишние бэкапы клиента и старые heavy-части. +func (s *Server) applyRetention(c *Client) error { + backups, err := s.st.ListBackups(c.ID, StatusComplete) + if err != nil { + return err + } + times := make([]time.Time, len(backups)) + for i, b := range backups { + times[i] = time.Unix(b.CreatedAt, 0) + } + keep := SelectGFS(times, c.KeepDaily, c.KeepWeekly, c.KeepMonthly) + + // heavy-части: по каждому имени — только heavy_keep последних. + // Бэкап с сохраняемой heavy-частью не удаляется целиком, даже если GFS его не выбрал. + type excess struct { + b *Backup + name string + } + var drop []excess + heavySeen := map[string]int{} + for i, b := range backups { + for _, p := range b.Parts { + if p.Name == manifest.MainPart || p.Status != PartComplete { + continue + } + heavySeen[p.Name]++ + if heavySeen[p.Name] <= max(c.HeavyKeep, 0) { + keep[i] = true + } else { + drop = append(drop, excess{b, p.Name}) + } + } + } + for _, d := range drop { + if err := s.deletePart(d.b, d.name); err != nil { + return err + } + } + + for i, b := range backups { + if keep[i] { + continue + } + if err := s.deleteBackup(b); err != nil { + return err + } + log.Printf("ретеншн: %s удалён бэкап #%d от %s", c.Domain, b.ID, time.Unix(b.CreatedAt, 0).Format(time.DateTime)) + } + return nil +} + +func (s *Server) deletePart(b *Backup, name string) error { + if err := os.Remove(s.partPath(b.Dir, name)); err != nil && !os.IsNotExist(err) { + return err + } + if err := s.st.SetPartStatus(b.ID, name, PartDeleted); err != nil { + return err + } + return s.st.RecalcTotal(b.ID) +} + +func (s *Server) deleteBackup(b *Backup) error { + if b.Dir != "" { + if err := os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir)); err != nil { + return err + } + } + return s.st.DeleteBackup(b.ID) +} diff --git a/internal/server/retention_test.go b/internal/server/retention_test.go new file mode 100644 index 0000000..d000293 --- /dev/null +++ b/internal/server/retention_test.go @@ -0,0 +1,41 @@ +package server + +import ( + "testing" + "time" +) + +func TestSelectGFS(t *testing.T) { + // 60 дней ежедневных бэкапов + второй бэкап в последний день + now := time.Date(2026, 10, 6, 3, 0, 0, 0, time.Local) + times := []time.Time{now.Add(time.Hour)} + for d := 0; d < 60; d++ { + times = append(times, now.AddDate(0, 0, -d)) + } + keep := SelectGFS(times, 7, 4, 3) + + if !keep[0] { + t.Fatal("последний бэкап должен сохраняться") + } + if keep[1] { + t.Error("второй бэкап того же дня не должен сохраняться") + } + for i := 2; i <= 7; i++ { + if !keep[i] { + t.Errorf("дневной бэкап %d должен сохраняться", i) + } + } + // 7 дневных (вкл. idx 0) + недели/месяцы частично пересекаются; верхняя граница 7+4+3 + if n := len(keep); n < 7 || n > 14 { + t.Errorf("сохранено %d бэкапов", n) + } + if keep[len(times)-1] && len(keep) == len(times) { + t.Error("ничего не удалено") + } + if len(SelectGFS(nil, 7, 4, 6)) != 0 { + t.Error("пустой список") + } + if k := SelectGFS(times, 0, 0, 0); len(k) != 1 || !k[0] { + t.Errorf("при нулевых keep_* остаётся только последний: %v", k) + } +} diff --git a/internal/server/scripts.go b/internal/server/scripts.go new file mode 100644 index 0000000..1278d59 --- /dev/null +++ b/internal/server/scripts.go @@ -0,0 +1,170 @@ +package server + +import ( + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "text/template" +) + +var agentScript = template.Must(template.New("agent.sh").Parse(`#!/usr/bin/env bash +# Установка/обновление агента backup-master. +# curl -fsSL {{.URL}}/agent.sh | sudo bash -s -- --token <токен> [--now] +# Без --token обновляет бинарник и юниты, сохраняя существующий конфиг. +set -Eeuo pipefail +SERVER="{{.URL}}" +TOKEN="" +RUN_NOW=n +while [[ $# -gt 0 ]]; do + case $1 in + --token) TOKEN=$2; shift 2 ;; + --now) RUN_NOW=y; shift ;; + *) echo "Неизвестный аргумент: $1" >&2; exit 1 ;; + esac +done +[[ $EUID -eq 0 ]] || { echo "Запустите от root (sudo)" >&2; exit 1; } +case $(uname -m) in + x86_64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "Архитектура $(uname -m) не поддерживается" >&2; exit 1 ;; +esac +CONF=/etc/backup-agent/agent.yaml +if [[ -z $TOKEN && ! -f $CONF ]]; then + echo "Нужен --token (выдаётся в веб-морде при добавлении клиента)" >&2; exit 1 +fi + +echo "==> Скачиваю backupctl ($ARCH)" +curl -fsSL "$SERVER/dl/backupctl-linux-$ARCH" -o /usr/local/bin/backupctl.new +chmod 755 /usr/local/bin/backupctl.new +mv -f /usr/local/bin/backupctl.new /usr/local/bin/backupctl +/usr/local/bin/backupctl version + +if [[ -n $TOKEN ]]; then + echo "==> Конфиг $CONF" + install -d -m 700 /etc/backup-agent + umask 077 + cat >"$CONF" < systemd-юниты" +cat >/etc/systemd/system/backup-agent.service <<'EOF' +[Unit] +Description=backup-master agent +Wants=network-online.target +After=network-online.target docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/backupctl backup +Nice=10 +IOSchedulingClass=idle +TimeoutStartSec=6h +EOF +cat >/etc/systemd/system/backup-agent.timer <<'EOF' +[Unit] +Description=Ежедневный бэкап backup-master + +[Timer] +OnCalendar=*-*-* 03:00:00 +RandomizedDelaySec=30m +Persistent=true + +[Install] +WantedBy=timers.target +EOF +systemctl daemon-reload +systemctl enable --now backup-agent.timer + +echo "==> Что будет в бэкапе" +/usr/local/bin/backupctl plan || true + +if [[ $RUN_NOW == y ]]; then + echo "==> Первый бэкап (журнал: journalctl -u backup-agent -f)" + systemctl start --no-block backup-agent.service +fi +echo "Готово. Расписание: systemctl list-timers backup-agent.timer" +`)) + +var restoreScript = template.Must(template.New("restore.sh").Parse(`#!/usr/bin/env bash +# Восстановление сервера из backup-master. +# curl -fsSL {{.URL}}/restore.sh | sudo bash -s -- --token [--backup ID] [--skip-heavy] [--skip-provision] +set -Eeuo pipefail +SERVER="{{.URL}}" +[[ $EUID -eq 0 ]] || { echo "Запустите от root (sudo)" >&2; exit 1; } +case $(uname -m) in + x86_64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "Архитектура $(uname -m) не поддерживается" >&2; exit 1 ;; +esac +command -v curl >/dev/null || { apt-get update -q && apt-get install -y -q curl; } +echo "==> Скачиваю backupctl ($ARCH)" +curl -fsSL "$SERVER/dl/backupctl-linux-$ARCH" -o /usr/local/bin/backupctl.new +chmod 755 /usr/local/bin/backupctl.new +mv -f /usr/local/bin/backupctl.new /usr/local/bin/backupctl +# stdin занят пайпом curl — вопросы задаём через терминал +if [[ -r /dev/tty ]]; then + exec /usr/local/bin/backupctl restore --server "$SERVER" "$@" для раздачи агентам + MaxUpload int64 // максимальный размер одной части, байт + // TrustProxy — брать IP клиента из X-Forwarded-For (за Caddy). + TrustProxy bool +} + +type Server struct { + cfg Config + st *Store + pages map[string]*pageTemplate + login *loginLimiter + + uploadMu sync.Mutex + uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка +} + +func New(cfg Config) (*Server, error) { + if cfg.MaxUpload == 0 { + cfg.MaxUpload = 200 << 30 + } + cfg.PublicURL = strings.TrimRight(cfg.PublicURL, "/") + if err := os.MkdirAll(filepath.Join(cfg.DataDir, "backups"), 0o700); err != nil { + return nil, err + } + st, err := OpenStore(filepath.Join(cfg.DataDir, "backup-master.db")) + if err != nil { + return nil, err + } + pages, err := loadPages() + if err != nil { + st.Close() + return nil, err + } + return &Server{cfg: cfg, st: st, pages: pages, login: newLoginLimiter(), uploads: map[int64]bool{}}, nil +} + +func (s *Server) Close() error { return s.st.Close() } + +func (s *Server) Store() *Store { return s.st } + +func (s *Server) backupsDir() string { return filepath.Join(s.cfg.DataDir, "backups") } + +func (s *Server) partPath(dir, name string) string { + return filepath.Join(s.backupsDir(), dir, name+".tar.zst") +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // API агента + mux.HandleFunc("GET /api/v1/agent/config", s.agentAuth(s.apiAgentConfig)) + mux.HandleFunc("POST /api/v1/backups", s.agentAuth(s.apiCreateBackup)) + mux.HandleFunc("PUT /api/v1/backups/{id}/parts/{name}", s.agentAuth(s.apiUploadPart)) + mux.HandleFunc("POST /api/v1/backups/{id}/complete", s.agentAuth(s.apiComplete)) + mux.HandleFunc("POST /api/v1/backups/{id}/fail", s.agentAuth(s.apiFail)) + + // API восстановления + mux.HandleFunc("GET /api/v1/restore/manifest", s.restoreAuth(s.apiRestoreManifest)) + mux.HandleFunc("GET /api/v1/restore/{id}/parts/{name}", s.restoreAuth(s.apiRestorePart)) + + // публичное + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) }) + mux.HandleFunc("GET /agent.sh", s.serveAgentScript) + mux.HandleFunc("GET /restore.sh", s.serveRestoreScript) + mux.HandleFunc("GET /dl/{file}", s.serveBinary) + mux.Handle("GET /static/", http.FileServerFS(staticFS)) + + // веб-морда + mux.HandleFunc("GET /login", s.webLoginPage) + mux.HandleFunc("POST /login", s.webLogin) + mux.HandleFunc("POST /logout", s.admin(s.webLogout)) + mux.HandleFunc("GET /{$}", s.admin(s.webClients)) + mux.HandleFunc("GET /clients/new", s.admin(s.webClientNew)) + mux.HandleFunc("POST /clients", s.admin(s.webClientCreate)) + mux.HandleFunc("GET /clients/{id}", s.admin(s.webClient)) + mux.HandleFunc("POST /clients/{id}/settings", s.admin(s.webClientSettings)) + mux.HandleFunc("POST /clients/{id}/token", s.admin(s.webClientRotateToken)) + mux.HandleFunc("POST /clients/{id}/restore-token", s.admin(s.webClientRestoreToken)) + mux.HandleFunc("POST /clients/{id}/delete", s.admin(s.webClientDelete)) + mux.HandleFunc("GET /backups/{id}", s.admin(s.webBackup)) + mux.HandleFunc("GET /backups/{id}/parts/{name}", s.admin(s.webBackupDownload)) + mux.HandleFunc("POST /backups/{id}/delete", s.admin(s.webBackupDelete)) + mux.HandleFunc("GET /settings", s.admin(s.webSettings)) + mux.HandleFunc("POST /settings", s.admin(s.webSettingsSave)) + + return logRequests(mux) +} + +// Run запускает HTTP-сервер и фоновые задачи до отмены ctx. +func (s *Server) Run(ctx context.Context) error { + if s.st.Setting(settingAdminHash) == "" { + log.Printf("ВНИМАНИЕ: админ не задан, выполните: backupctl admin-passwd <логин>") + } + srv := &http.Server{ + Addr: s.cfg.Listen, + Handler: s.Handler(), + ReadHeaderTimeout: 15 * time.Second, + IdleTimeout: 2 * time.Minute, + } + go s.background(ctx) + errc := make(chan error, 1) + go func() { + log.Printf("backup-master слушает %s (public url %s)", s.cfg.Listen, s.cfg.PublicURL) + errc <- srv.ListenAndServe() + }() + select { + case err := <-errc: + return err + case <-ctx.Done(): + shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shCtx) + } +} + +func (s *Server) background(ctx context.Context) { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for { + s.tick() + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +func (s *Server) tick() { + if err := s.st.Cleanup(); err != nil { + log.Printf("cleanup: %v", err) + } + // зависшие загрузки + stuck, err := s.st.UploadingBefore(time.Now().Add(-6*time.Hour).Unix(), 0) + if err != nil { + log.Printf("stuck uploads: %v", err) + } + for _, b := range stuck { + s.failBackup(b, "загрузка не завершилась за 6 часов") + } + // клиенты без свежих бэкапов + clients, err := s.st.ListClients() + if err != nil { + log.Printf("clients: %v", err) + return + } + now := time.Now() + for _, c := range clients { + if c.Disabled || c.StaleNotified || c.StaleHours <= 0 { + continue + } + ref := c.LastBackupAt + if ref == 0 { + ref = c.CreatedAt + } + if now.Sub(time.Unix(ref, 0)) > time.Duration(c.StaleHours)*time.Hour { + msg := fmt.Sprintf("%s: нет успешного бэкапа больше %d ч", c.Domain, c.StaleHours) + if c.LastBackupAt == 0 { + msg = fmt.Sprintf("%s: от клиента ещё не пришло ни одного бэкапа", c.Domain) + } + s.notify("Бэкап просрочен: "+c.Domain, msg, "high", "warning") + _ = s.st.SetStaleNotified(c.ID) + } + } +} + +func (s *Server) failBackup(b *Backup, msg string) { + if b.Dir != "" { + _ = os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir)) + } + if err := s.st.SetBackupStatus(b.ID, StatusFailed, msg, 0); err != nil { + log.Printf("backup #%d: %v", b.ID, err) + } + _ = s.st.MarkClientError(b.ClientID, msg) + domain := fmt.Sprintf("клиент #%d", b.ClientID) + if c, err := s.st.GetClient(b.ClientID); err == nil { + domain = c.Domain + } + log.Printf("бэкап #%d (%s) провален: %s", b.ID, domain, msg) + s.notify("Бэкап не удался: "+domain, msg, "high", "x") +} + +// --- токены --- + +const tokenPrefix = "bm_" + +func newToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return tokenPrefix + base64.RawURLEncoding.EncodeToString(b) +} + +func hashToken(t string) string { + h := sha256.Sum256([]byte(t)) + return hex.EncodeToString(h[:]) +} + +func bearer(r *http.Request) string { + v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok { + return "" + } + return strings.TrimSpace(v) +} + +// --- логирование --- + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func logRequests(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: 200} + h.ServeHTTP(sw, r) + if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" { + return + } + log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond)) + }) +} diff --git a/internal/server/static/style.css b/internal/server/static/style.css new file mode 100644 index 0000000..f7a8b01 --- /dev/null +++ b/internal/server/static/style.css @@ -0,0 +1,127 @@ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --text: #1c2128; + --muted: #6b7280; + --border: #dfe3e8; + --accent: #2f6fde; + --accent-text: #ffffff; + --ok: #1f8a4c; + --ok-bg: #e3f4ea; + --warn: #9a6400; + --warn-bg: #fdf1d8; + --fail: #c0362c; + --fail-bg: #fbe5e3; + --code-bg: #eef1f5; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #14171c; + --panel: #1c2027; + --text: #e4e7eb; + --muted: #9aa3ae; + --border: #2e343d; + --accent: #5b8ff0; + --ok: #4cc27f; + --ok-bg: #18342a; + --warn: #e0ad4a; + --warn-bg: #3a2f17; + --fail: #f07167; + --fail-bg: #3d1f1d; + --code-bg: #232831; + } +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; } +code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; } + +.top { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 10px 24px; background: var(--panel); border-bottom: 1px solid var(--border); +} +.brand { font-weight: 600; color: var(--text); } +.top nav { display: flex; align-items: center; gap: 18px; } +.top nav a { color: var(--muted); } +.top nav a.active { color: var(--text); font-weight: 500; } + +main { max-width: 1200px; margin: 0 auto; padding: 24px; } +h1 { font-size: 24px; margin: 0 0 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +h2 { font-size: 18px; margin: 32px 0 12px; } +.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; } +.muted { color: var(--muted); } +.small { font-size: 13px; } +.right { text-align: right; } +.empty { color: var(--muted); padding: 24px 0; } +.hint { color: var(--muted); font-size: 13px; margin: 4px 0 12px; } + +.table-wrap { overflow-x: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } +th { font-size: 13px; font-weight: 500; color: var(--muted); } +tr:last-child td { border-bottom: none; } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } + +.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; white-space: nowrap; } +.badge.ok { background: var(--ok-bg); color: var(--ok); } +.badge.stale, .badge.never { background: var(--warn-bg); color: var(--warn); } +.badge.fail { background: var(--fail-bg); color: var(--fail); } +.badge.off { background: var(--code-bg); color: var(--muted); } +.ok-text { color: var(--ok); } + +.facts { display: flex; flex-wrap: wrap; gap: 12px 32px; margin: 16px 0; } +.facts dt { font-size: 12px; color: var(--muted); } +.facts dd { margin: 0; } + +.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } +.card.narrow, .login { max-width: 520px; } +.card h2:first-of-type { margin-top: 0; } +label { display: block; margin-bottom: 12px; font-size: 14px; } +label.check { display: flex; align-items: center; gap: 8px; } +input, textarea { + display: block; width: 100%; margin-top: 4px; padding: 7px 10px; + background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: inherit; +} +input[type=checkbox] { display: inline; width: auto; margin: 0; } +textarea { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0 16px; } + +button, .button { + display: inline-block; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border); + background: var(--panel); color: var(--text); font: inherit; cursor: pointer; +} +button:hover, .button:hover { text-decoration: none; filter: brightness(0.97); } +button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button.link { background: none; border: none; padding: 0; color: var(--muted); } +button.danger { background: var(--fail-bg); border-color: var(--fail-bg); color: var(--fail); } +button.link.danger { background: none; } +form.inline { display: inline; } + +.error { background: var(--fail-bg); color: var(--fail); padding: 10px 14px; border-radius: 6px; } +.warnings { background: var(--warn-bg); color: var(--warn); padding: 10px 14px; border-radius: 6px; margin: 12px 0; } +.warnings ul { margin: 8px 0 0; padding-left: 20px; font-size: 13px; } +.danger-zone { margin-top: 32px; color: var(--muted); } +.danger-zone form { margin-top: 12px; max-width: 420px; } +.danger-zone input { margin-bottom: 8px; } + +.cmd { display: flex; gap: 8px; align-items: flex-start; margin: 16px 0; } +.cmd pre { flex: 1; margin: 0; padding: 12px; background: var(--code-bg); border-radius: 6px; white-space: pre-wrap; word-break: break-all; } +pre.json { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-x: auto; } + +.login { margin: 10vh auto; } +.login form { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } + +@media (max-width: 640px) { + main { padding: 16px; } + .top { padding: 10px 16px; } + .cmd { flex-direction: column; } +} diff --git a/internal/server/store.go b/internal/server/store.go new file mode 100644 index 0000000..673bd90 --- /dev/null +++ b/internal/server/store.go @@ -0,0 +1,442 @@ +package server + +import ( + "database/sql" + "errors" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +const schema = ` +CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + domain TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL UNIQUE, + config_yaml TEXT NOT NULL DEFAULT '', + keep_daily INTEGER NOT NULL DEFAULT 7, + keep_weekly INTEGER NOT NULL DEFAULT 4, + keep_monthly INTEGER NOT NULL DEFAULT 6, + heavy_keep INTEGER NOT NULL DEFAULT 1, + heavy_every_days INTEGER NOT NULL DEFAULT 7, + stale_hours INTEGER NOT NULL DEFAULT 26, + created_at INTEGER NOT NULL, + last_backup_at INTEGER NOT NULL DEFAULT 0, + last_heavy_at INTEGER NOT NULL DEFAULT 0, + last_seen_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_error_at INTEGER NOT NULL DEFAULT 0, + stale_notified INTEGER NOT NULL DEFAULT 0, + disabled INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + status TEXT NOT NULL, + dir TEXT NOT NULL DEFAULT '', + manifest TEXT NOT NULL, + total_size INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS backups_client ON backups(client_id, created_at); +CREATE TABLE IF NOT EXISTS parts ( + backup_id INTEGER NOT NULL REFERENCES backups(id) ON DELETE CASCADE, + name TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + sha256 TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + PRIMARY KEY (backup_id, name) +); +CREATE TABLE IF NOT EXISTS restore_tokens ( + token_hash TEXT PRIMARY KEY, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + csrf TEXT NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +` + +const ( + StatusUploading = "uploading" + StatusComplete = "complete" + StatusFailed = "failed" + + PartUploaded = "uploaded" + PartComplete = "complete" + PartDeleted = "deleted" // heavy-часть удалена ротацией +) + +var ErrNotFound = errors.New("не найдено") + +type Store struct { + db *sql.DB +} + +func OpenStore(path string) (*Store, error) { + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + // одно соединение: исключает SQLITE_BUSY, нагрузка у сервиса мизерная + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("миграция: %w", err) + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +type Client struct { + ID int64 + Name string + Domain string + TokenHash string + ConfigYAML string + KeepDaily int + KeepWeekly int + KeepMonthly int + HeavyKeep int + HeavyEveryDays int + StaleHours int + CreatedAt int64 + LastBackupAt int64 + LastHeavyAt int64 + LastSeenAt int64 + LastError string + LastErrorAt int64 + StaleNotified bool + Disabled bool +} + +const clientCols = `id, name, domain, token_hash, config_yaml, keep_daily, keep_weekly, keep_monthly, + heavy_keep, heavy_every_days, stale_hours, created_at, last_backup_at, last_heavy_at, last_seen_at, + last_error, last_error_at, stale_notified, disabled` + +func scanClient(row interface{ Scan(...any) error }) (*Client, error) { + var c Client + err := row.Scan(&c.ID, &c.Name, &c.Domain, &c.TokenHash, &c.ConfigYAML, &c.KeepDaily, &c.KeepWeekly, + &c.KeepMonthly, &c.HeavyKeep, &c.HeavyEveryDays, &c.StaleHours, &c.CreatedAt, &c.LastBackupAt, + &c.LastHeavyAt, &c.LastSeenAt, &c.LastError, &c.LastErrorAt, &c.StaleNotified, &c.Disabled) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return &c, err +} + +func (s *Store) CreateClient(name, domain, tokenHash string) (int64, error) { + res, err := s.db.Exec(`INSERT INTO clients (name, domain, token_hash, created_at) VALUES (?, ?, ?, ?)`, + name, domain, tokenHash, time.Now().Unix()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) GetClient(id int64) (*Client, error) { + return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE id = ?`, id)) +} + +func (s *Store) ClientByToken(hash string) (*Client, error) { + return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE token_hash = ?`, hash)) +} + +func (s *Store) ListClients() ([]*Client, error) { + rows, err := s.db.Query(`SELECT ` + clientCols + ` FROM clients ORDER BY domain`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Client + for rows.Next() { + c, err := scanClient(rows) + if err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Store) UpdateClientSettings(c *Client) error { + _, err := s.db.Exec(`UPDATE clients SET name = ?, config_yaml = ?, keep_daily = ?, keep_weekly = ?, + keep_monthly = ?, heavy_keep = ?, heavy_every_days = ?, stale_hours = ?, disabled = ? WHERE id = ?`, + c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays, + c.StaleHours, c.Disabled, c.ID) + return err +} + +func (s *Store) SetClientToken(id int64, hash string) error { + _, err := s.db.Exec(`UPDATE clients SET token_hash = ? WHERE id = ?`, hash, id) + return err +} + +func (s *Store) DeleteClient(id int64) error { + _, err := s.db.Exec(`DELETE FROM clients WHERE id = ?`, id) + return err +} + +func (s *Store) TouchClient(id int64) error { + _, err := s.db.Exec(`UPDATE clients SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), id) + return err +} + +func (s *Store) MarkClientSuccess(id int64, at int64, heavy bool) error { + q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0` + if heavy { + q += `, last_heavy_at = ?1` + } + _, err := s.db.Exec(q+` WHERE id = ?2`, at, id) + return err +} + +func (s *Store) MarkClientError(id int64, msg string) error { + _, err := s.db.Exec(`UPDATE clients SET last_error = ?, last_error_at = ? WHERE id = ?`, msg, time.Now().Unix(), id) + return err +} + +func (s *Store) SetStaleNotified(id int64) error { + _, err := s.db.Exec(`UPDATE clients SET stale_notified = 1 WHERE id = ?`, id) + return err +} + +type Backup struct { + ID int64 + ClientID int64 + CreatedAt int64 + Status string + Dir string + Manifest string + TotalSize int64 + Error string + Parts []Part +} + +type Part struct { + BackupID int64 + Name string + Size int64 + SHA256 string + Status string +} + +func (s *Store) CreateBackup(clientID int64, createdAt int64, manifest string) (int64, error) { + res, err := s.db.Exec(`INSERT INTO backups (client_id, created_at, status, manifest) VALUES (?, ?, ?, ?)`, + clientID, createdAt, StatusUploading, manifest) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) SetBackupDir(id int64, dir string) error { + _, err := s.db.Exec(`UPDATE backups SET dir = ? WHERE id = ?`, dir, id) + return err +} + +func (s *Store) SetBackupStatus(id int64, status, errMsg string, totalSize int64) error { + _, err := s.db.Exec(`UPDATE backups SET status = ?, error = ?, total_size = ? WHERE id = ?`, status, errMsg, totalSize, id) + return err +} + +func (s *Store) SetBackupManifest(id int64, manifest string) error { + _, err := s.db.Exec(`UPDATE backups SET manifest = ? WHERE id = ?`, manifest, id) + return err +} + +func (s *Store) DeleteBackup(id int64) error { + _, err := s.db.Exec(`DELETE FROM backups WHERE id = ?`, id) + return err +} + +func (s *Store) GetBackup(id int64) (*Backup, error) { + var b Backup + err := s.db.QueryRow(`SELECT id, client_id, created_at, status, dir, manifest, total_size, error FROM backups WHERE id = ?`, id). + Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.Manifest, &b.TotalSize, &b.Error) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + b.Parts, err = s.parts(id) + return &b, err +} + +func (s *Store) parts(backupID int64) ([]Part, error) { + rows, err := s.db.Query(`SELECT backup_id, name, size, sha256, status FROM parts WHERE backup_id = ? ORDER BY name = 'main' DESC, name`, backupID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Part + for rows.Next() { + var p Part + if err := rows.Scan(&p.BackupID, &p.Name, &p.Size, &p.SHA256, &p.Status); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ListBackups возвращает бэкапы клиента от новых к старым (без манифеста), с частями. +func (s *Store) ListBackups(clientID int64, status string) ([]*Backup, error) { + q := `SELECT id, client_id, created_at, status, dir, total_size, error FROM backups WHERE client_id = ?` + args := []any{clientID} + if status != "" { + q += ` AND status = ?` + args = append(args, status) + } + rows, err := s.db.Query(q+` ORDER BY created_at DESC, id DESC`, args...) + if err != nil { + return nil, err + } + var out []*Backup + for rows.Next() { + var b Backup + if err := rows.Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.TotalSize, &b.Error); err != nil { + rows.Close() + return nil, err + } + out = append(out, &b) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + for _, b := range out { + if b.Parts, err = s.parts(b.ID); err != nil { + return nil, err + } + } + return out, nil +} + +// LatestComplete возвращает последний успешный бэкап клиента. +func (s *Store) LatestComplete(clientID int64) (*Backup, error) { + var id int64 + err := s.db.QueryRow(`SELECT id FROM backups WHERE client_id = ? AND status = ? ORDER BY created_at DESC, id DESC LIMIT 1`, + clientID, StatusComplete).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return s.GetBackup(id) +} + +// UploadingBefore возвращает незавершённые загрузки старше ts (по всем клиентам или одному). +func (s *Store) UploadingBefore(ts int64, clientID int64) ([]*Backup, error) { + q := `SELECT id, client_id, dir FROM backups WHERE status = ? AND created_at < ?` + args := []any{StatusUploading, ts} + if clientID != 0 { + q += ` AND client_id = ?` + args = append(args, clientID) + } + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Backup + for rows.Next() { + var b Backup + if err := rows.Scan(&b.ID, &b.ClientID, &b.Dir); err != nil { + return nil, err + } + out = append(out, &b) + } + return out, rows.Err() +} + +func (s *Store) UpsertPart(p Part) error { + _, err := s.db.Exec(`INSERT INTO parts (backup_id, name, size, sha256, status) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (backup_id, name) DO UPDATE SET size = excluded.size, sha256 = excluded.sha256, status = excluded.status`, + p.BackupID, p.Name, p.Size, p.SHA256, p.Status) + return err +} + +func (s *Store) SetPartStatus(backupID int64, name, status string) error { + _, err := s.db.Exec(`UPDATE parts SET status = ? WHERE backup_id = ? AND name = ?`, status, backupID, name) + return err +} + +func (s *Store) RecalcTotal(backupID int64) error { + _, err := s.db.Exec(`UPDATE backups SET total_size = (SELECT COALESCE(SUM(size), 0) FROM parts + WHERE backup_id = ?1 AND status = ?2) WHERE id = ?1`, backupID, PartComplete) + return err +} + +func (s *Store) CreateRestoreToken(hash string, clientID int64, ttl time.Duration) error { + now := time.Now() + _, err := s.db.Exec(`INSERT INTO restore_tokens (token_hash, client_id, created_at, expires_at) VALUES (?, ?, ?, ?)`, + hash, clientID, now.Unix(), now.Add(ttl).Unix()) + return err +} + +func (s *Store) RestoreTokenClient(hash string) (int64, error) { + var id int64 + err := s.db.QueryRow(`SELECT client_id FROM restore_tokens WHERE token_hash = ? AND expires_at > ?`, hash, time.Now().Unix()).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } + return id, err +} + +func (s *Store) CreateSession(token, csrf string, ttl time.Duration) error { + _, err := s.db.Exec(`INSERT INTO sessions (token, csrf, expires_at) VALUES (?, ?, ?)`, token, csrf, time.Now().Add(ttl).Unix()) + return err +} + +func (s *Store) SessionCSRF(token string) (string, error) { + var csrf string + err := s.db.QueryRow(`SELECT csrf FROM sessions WHERE token = ? AND expires_at > ?`, token, time.Now().Unix()).Scan(&csrf) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return csrf, err +} + +func (s *Store) DeleteSession(token string) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) + return err +} + +func (s *Store) DeleteAllSessions() error { + _, err := s.db.Exec(`DELETE FROM sessions`) + return err +} + +func (s *Store) Cleanup() error { + now := time.Now().Unix() + if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, now); err != nil { + return err + } + _, err := s.db.Exec(`DELETE FROM restore_tokens WHERE expires_at <= ?`, now) + return err +} + +func (s *Store) Setting(key string) string { + var v string + _ = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v) + return v +} + +func (s *Store) SetSetting(key, value string) error { + _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value) + return err +} diff --git a/internal/server/templates/backup.html b/internal/server/templates/backup.html new file mode 100644 index 0000000..997c308 --- /dev/null +++ b/internal/server/templates/backup.html @@ -0,0 +1,30 @@ +{{define "title"}}Бэкап #{{.Backup.ID}} — backup-master{{end}} +{{define "content"}} +{{$b := .Backup}} +

← {{.Client.Domain}}

+
+

Бэкап #{{.Backup.ID}} {{.Backup.Status}}

+ {{if eq .Backup.Status "complete"}} +
+ + + +
+ {{end}} +
+{{with .Backup.Error}}

{{.}}

{{end}} +
+
Создан
{{ts .Backup.CreatedAt}}
+
Размер
{{bytes .Backup.TotalSize}}
+
Агент
{{.M.AgentVersion}}
+
Части
{{range .Backup.Parts}}{{if eq .Status "complete"}}{{.Name}} {{bytes .Size}}
{{else}}{{.Name}}: {{.Status}}
{{end}}{{end}}
+
+{{if .M.Warnings}} +
+ Предупреждения ({{len .M.Warnings}}) +
    {{range .M.Warnings}}
  • {{.}}
  • {{end}}
+
+{{end}} +

Манифест

+
{{.Raw}}
+{{end}} diff --git a/internal/server/templates/client.html b/internal/server/templates/client.html new file mode 100644 index 0000000..68cfd58 --- /dev/null +++ b/internal/server/templates/client.html @@ -0,0 +1,132 @@ +{{define "title"}}{{.Client.Domain}} — backup-master{{end}} +{{define "content"}} +{{$csrf := .CSRF}} +
+
+

{{.Client.Domain}} {{.StatusText}}

+ {{if ne .Client.Name .Client.Domain}}

{{.Client.Name}}

{{end}} +
+
+
+ + +
+
+ + +
+
+
+{{with .Error}}

{{.}}

{{end}} +{{if .Client.LastError}}

Последняя ошибка ({{ts .Client.LastErrorAt}}): {{.Client.LastError}}

{{end}} + +
+
Последний бэкап
{{ago .Client.LastBackupAt}}
+
Heavy-части
{{ago .Client.LastHeavyAt}}
+
Агент на связи
{{ago .Client.LastSeenAt}}
+ {{with .Latest}}
Система
{{.Hostname}}{{with .OS.PrettyName}} · {{.}}{{end}}{{with .OS.Arch}} · {{.}}{{end}}
{{end}} +
+ +{{with .Latest}} +

Что бэкапится по последнему бэкапу

+
+ + + + {{range .Categories}} + + + + + + + + {{else}} + + {{end}} + +
КатегорияТипВерсияСервисСодержимое
{{.Name}}{{if .Auto}} авто{{end}}{{.Type}}{{.Version}}{{with .Compose}}{{with .Git}}git {{printf "%.8s" .Commit}}{{if .Dirty}}*{{end}}{{end}}{{end}}{{with .Service}}{{.Unit}} {{if .Active}}active{{else}}inactive{{end}}{{if not .Enabled}} (disabled){{end}}{{end}} + {{if .Paths}}{{join .Paths ", "}}{{end}} + {{range .Dumps}}
дамп {{.Kind}}{{with .Service}} ({{.}}){{end}}{{end}} + {{range .Volumes}}
том {{.Name}}{{end}} + {{if .Heavy}}
heavy: {{join .Heavy ", "}}{{if .HeavySkipped}} (не в этот раз){{end}}{{end}} +
Ни одной категории не найдено.
+
+{{if .Packages}} +

Пакеты: {{range $k, $v := .Packages}}{{$k}}={{$v}}   {{end}}

+{{end}} +{{if .Warnings}} +
+ Предупреждения ({{len .Warnings}}) +
    {{range .Warnings}}
  • {{.}}
  • {{end}}
+
+{{end}} +{{end}} + +

История

+{{if .Backups}} +
+ + + + {{range .Backups}} + {{$b := .}} + + + + + + + + + {{end}} + +
#ДатаСтатусРазмерЧасти
{{.ID}}{{ts .CreatedAt}}{{.Status}}{{with .Error}} {{.}}{{end}}{{if .TotalSize}}{{bytes .TotalSize}}{{end}}{{range .Parts}}{{if eq .Status "complete"}}{{.Name}} ({{bytes .Size}}) {{else if eq .Status "deleted"}}{{.Name}} удалена {{end}}{{end}} +
+ + +
+
+
+{{else}} +

Бэкапов ещё нет.

+{{end}} + +

Настройки

+
+ + +
+ + + + + + +
+ + +

Категория с тем же name, что во встроенном каталоге, заменяет её целиком. Поля: type (files|systemd|xui|compose), paths, exclude, detect, service, version_cmd, install, keep_binaries, ensure_dirs, post_restore, dumps, dir, skip_services, heavy_paths, restore_after.

+ +
+ +
+ Удаление клиента +
+ +

Будут удалены все бэкапы клиента. Введите домен для подтверждения:

+ + +
+
+{{end}} diff --git a/internal/server/templates/client_new.html b/internal/server/templates/client_new.html new file mode 100644 index 0000000..20cded8 --- /dev/null +++ b/internal/server/templates/client_new.html @@ -0,0 +1,12 @@ +{{define "title"}}Новый клиент — backup-master{{end}} +{{define "content"}} +

Новый клиент

+{{with .Error}}

{{.}}

{{end}} +
+ + +

По домену бэкап находится при восстановлении. Он же станет hostname нового сервера, если на старом hostname не задан.

+ + +
+{{end}} diff --git a/internal/server/templates/clients.html b/internal/server/templates/clients.html new file mode 100644 index 0000000..738847e --- /dev/null +++ b/internal/server/templates/clients.html @@ -0,0 +1,28 @@ +{{define "title"}}Клиенты — backup-master{{end}} +{{define "content"}} +
+

Клиенты

+ Добавить клиента +
+{{if .Clients}} +
+ + + + {{range .Clients}} + + + + + + + + + {{end}} + +
ДоменНазваниеСтатусПоследний бэкапРазмерАгент на связи
{{.Domain}}{{if ne .Name .Domain}}{{.Name}}{{end}}{{.StatusText}}{{if and (eq .StatusClass "fail") .LastError}} {{.LastError}}{{end}}{{ago .LastBackupAt}}{{if .LastSize}}{{bytes .LastSize}}{{end}}{{ago .LastSeenAt}}
+
+{{else}} +

Клиентов пока нет. Добавьте первый — получите команду установки агента.

+{{end}} +{{end}} diff --git a/internal/server/templates/layout.html b/internal/server/templates/layout.html new file mode 100644 index 0000000..90c19a8 --- /dev/null +++ b/internal/server/templates/layout.html @@ -0,0 +1,27 @@ + + + + + +{{block "title" .}}backup-master{{end}} + + + +{{if ne .Page "login"}} +
+ backup-master + +
+{{end}} +
+{{template "content" .}} +
+ + diff --git a/internal/server/templates/login.html b/internal/server/templates/login.html new file mode 100644 index 0000000..913ae8a --- /dev/null +++ b/internal/server/templates/login.html @@ -0,0 +1,15 @@ +{{define "title"}}Вход — backup-master{{end}} +{{define "content"}} + +{{end}} diff --git a/internal/server/templates/settings.html b/internal/server/templates/settings.html new file mode 100644 index 0000000..3e92060 --- /dev/null +++ b/internal/server/templates/settings.html @@ -0,0 +1,17 @@ +{{define "title"}}Настройки — backup-master{{end}} +{{define "content"}} +

Настройки

+{{if .Saved}}

Сохранено.

{{end}} +
+ +

Уведомления ntfy

+ + + {{if .NtfyAuthSet}}{{end}} + + +

Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда.

+ +
+

Логин и пароль администратора меняются на сервере: backupctl admin-passwd <логин>

+{{end}} diff --git a/internal/server/templates/token.html b/internal/server/templates/token.html new file mode 100644 index 0000000..67c898f --- /dev/null +++ b/internal/server/templates/token.html @@ -0,0 +1,10 @@ +{{define "title"}}{{.Title}} — backup-master{{end}} +{{define "content"}} +

{{.Title}}

+

{{.Note}}

+
+
{{.Cmd}}
+ +
+

← к клиенту {{.Client.Domain}}

+{{end}} diff --git a/internal/server/web.go b/internal/server/web.go new file mode 100644 index 0000000..e5a6683 --- /dev/null +++ b/internal/server/web.go @@ -0,0 +1,404 @@ +package server + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" + "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static +var staticRoot embed.FS + +// URL /static/x совпадает с путём static/x внутри embed.FS +var staticFS fs.FS = staticRoot + +type pageTemplate = template.Template + +var funcs = template.FuncMap{ + "ts": func(unix int64) string { + if unix == 0 { + return "—" + } + return time.Unix(unix, 0).Format("02.01.2006 15:04") + }, + "ago": func(unix int64) string { + if unix == 0 { + return "никогда" + } + d := time.Since(time.Unix(unix, 0)) + switch { + case d < time.Minute: + return "только что" + case d < time.Hour: + return fmt.Sprintf("%d мин назад", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%d ч назад", int(d.Hours())) + default: + return fmt.Sprintf("%d дн назад", int(d.Hours()/24)) + } + }, + "bytes": humanBytes, + "join": strings.Join, +} + +func loadPages() (map[string]*pageTemplate, error) { + names, err := fs.Glob(templatesFS, "templates/*.html") + if err != nil { + return nil, err + } + pages := map[string]*pageTemplate{} + for _, n := range names { + base := strings.TrimSuffix(strings.TrimPrefix(n, "templates/"), ".html") + if base == "layout" { + continue + } + t, err := template.New("layout.html").Funcs(funcs).ParseFS(templatesFS, "templates/layout.html", n) + if err != nil { + return nil, fmt.Errorf("шаблон %s: %w", n, err) + } + pages[base] = t + } + return pages, nil +} + +func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) { + t, ok := s.pages[page] + if !ok { + http.Error(w, "нет шаблона "+page, http.StatusInternalServerError) + return + } + if data == nil { + data = map[string]any{} + } + data["CSRF"] = csrfFrom(r) + data["Page"] = page + data["PublicURL"] = s.cfg.PublicURL + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + log.Printf("render %s: %v", page, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "same-origin") + _, _ = buf.WriteTo(w) +} + +// clientStatus — сводный статус клиента для списка. +func clientStatus(c *Client) (string, string) { + switch { + case c.Disabled: + return "off", "отключён" + case c.LastErrorAt > c.LastBackupAt: + return "fail", "ошибка" + case c.LastBackupAt == 0: + return "never", "нет бэкапов" + case time.Since(time.Unix(c.LastBackupAt, 0)) > time.Duration(c.StaleHours)*time.Hour: + return "stale", "просрочен" + default: + return "ok", "ok" + } +} + +type clientRow struct { + *Client + StatusClass, StatusText string + LastSize int64 +} + +func (s *Server) webClients(w http.ResponseWriter, r *http.Request) { + clients, err := s.st.ListClients() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var rows []clientRow + for _, c := range clients { + row := clientRow{Client: c} + row.StatusClass, row.StatusText = clientStatus(c) + if b, err := s.st.LatestComplete(c.ID); err == nil { + row.LastSize = b.TotalSize + } + rows = append(rows, row) + } + s.render(w, r, "clients", map[string]any{"Clients": rows}) +} + +func (s *Server) webClientNew(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "client_new", nil) +} + +var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) + +func (s *Server) webClientCreate(w http.ResponseWriter, r *http.Request) { + domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain"))) + name := strings.TrimSpace(r.FormValue("name")) + if !domainRe.MatchString(domain) { + s.render(w, r, "client_new", map[string]any{"Error": "Недопустимый домен", "Domain": domain, "Name": name}) + return + } + if name == "" { + name = domain + } + tok := newToken() + id, err := s.st.CreateClient(name, domain, hashToken(tok)) + if err != nil { + msg := err.Error() + if strings.Contains(msg, "UNIQUE") { + msg = "Клиент с таким доменом уже есть" + } + s.render(w, r, "client_new", map[string]any{"Error": msg, "Domain": domain, "Name": name}) + return + } + c, _ := s.st.GetClient(id) + s.showAgentToken(w, r, c, tok) +} + +func (s *Server) showAgentToken(w http.ResponseWriter, r *http.Request, c *Client, tok string) { + s.render(w, r, "token", map[string]any{ + "Client": c, + "Title": "Токен агента", + "Note": "Токен показывается один раз. Выполните на сервере клиента:", + "Cmd": fmt.Sprintf("curl -fsSL %s/agent.sh | sudo bash -s -- --token %s --now", s.cfg.PublicURL, tok), + }) +} + +func (s *Server) clientFromPath(w http.ResponseWriter, r *http.Request) (*Client, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return nil, false + } + c, err := s.st.GetClient(id) + if err != nil { + http.NotFound(w, r) + return nil, false + } + return c, true +} + +func (s *Server) webClient(w http.ResponseWriter, r *http.Request) { + c, ok := s.clientFromPath(w, r) + if !ok { + return + } + s.renderClient(w, r, c, "") +} + +func (s *Server) renderClient(w http.ResponseWriter, r *http.Request, c *Client, formErr string) { + backups, err := s.st.ListBackups(c.ID, "") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + data := map[string]any{"Client": c, "Backups": backups, "Error": formErr} + data["StatusClass"], data["StatusText"] = clientStatus(c) + if latest, err := s.st.LatestComplete(c.ID); err == nil { + var m manifest.Manifest + if json.Unmarshal([]byte(latest.Manifest), &m) == nil { + data["Latest"] = &m + data["LatestID"] = latest.ID + } + } + s.render(w, r, "client", data) +} + +func formInt(r *http.Request, key string, def int) int { + v, err := strconv.Atoi(strings.TrimSpace(r.FormValue(key))) + if err != nil { + return def + } + return v +} + +func (s *Server) webClientSettings(w http.ResponseWriter, r *http.Request) { + c, ok := s.clientFromPath(w, r) + if !ok { + return + } + c.Name = strings.TrimSpace(r.FormValue("name")) + c.ConfigYAML = strings.ReplaceAll(r.FormValue("config_yaml"), "\r\n", "\n") + c.KeepDaily = formInt(r, "keep_daily", c.KeepDaily) + c.KeepWeekly = formInt(r, "keep_weekly", c.KeepWeekly) + c.KeepMonthly = formInt(r, "keep_monthly", c.KeepMonthly) + c.HeavyKeep = formInt(r, "heavy_keep", c.HeavyKeep) + c.HeavyEveryDays = formInt(r, "heavy_every_days", c.HeavyEveryDays) + c.StaleHours = formInt(r, "stale_hours", c.StaleHours) + c.Disabled = r.FormValue("disabled") == "1" + if strings.TrimSpace(c.ConfigYAML) != "" { + base, err := catalog.Default() + if err == nil { + var over *catalog.Config + over, err = catalog.Parse([]byte(c.ConfigYAML)) + if err == nil { + err = catalog.Merge(base, over).Validate() + } + } + if err != nil { + s.renderClient(w, r, c, "Конфиг не сохранён: "+err.Error()) + return + } + } + if err := s.st.UpdateClientSettings(c); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/clients/%d", c.ID), http.StatusSeeOther) +} + +func (s *Server) webClientRotateToken(w http.ResponseWriter, r *http.Request) { + c, ok := s.clientFromPath(w, r) + if !ok { + return + } + tok := newToken() + if err := s.st.SetClientToken(c.ID, hashToken(tok)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.render(w, r, "token", map[string]any{ + "Client": c, + "Title": "Новый токен агента", + "Note": "Старый токен больше не работает. Обновите конфиг агента на сервере клиента:", + "Cmd": fmt.Sprintf("curl -fsSL %s/agent.sh | sudo bash -s -- --token %s", s.cfg.PublicURL, tok), + }) +} + +func (s *Server) webClientRestoreToken(w http.ResponseWriter, r *http.Request) { + c, ok := s.clientFromPath(w, r) + if !ok { + return + } + tok := newToken() + if err := s.st.CreateRestoreToken(hashToken(tok), c.ID, 24*time.Hour); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + cmd := fmt.Sprintf("curl -fsSL %s/restore.sh | sudo bash -s -- --token %s", s.cfg.PublicURL, tok) + if b := r.FormValue("backup"); b != "" { + cmd += " --backup " + b + } + s.render(w, r, "token", map[string]any{ + "Client": c, + "Title": "Восстановление " + c.Domain, + "Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo):", + "Cmd": cmd, + }) +} + +func (s *Server) webClientDelete(w http.ResponseWriter, r *http.Request) { + c, ok := s.clientFromPath(w, r) + if !ok { + return + } + if r.FormValue("confirm") != c.Domain { + s.renderClient(w, r, c, "Для удаления введите домен клиента") + return + } + backups, _ := s.st.ListBackups(c.ID, "") + for _, b := range backups { + if err := s.deleteBackup(b); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if err := s.st.DeleteClient(c.ID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) backupFromPath(w http.ResponseWriter, r *http.Request) (*Backup, *Client, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return nil, nil, false + } + b, err := s.st.GetBackup(id) + if err != nil { + http.NotFound(w, r) + return nil, nil, false + } + c, err := s.st.GetClient(b.ClientID) + if err != nil { + http.NotFound(w, r) + return nil, nil, false + } + return b, c, true +} + +func (s *Server) webBackup(w http.ResponseWriter, r *http.Request) { + b, c, ok := s.backupFromPath(w, r) + if !ok { + return + } + var m manifest.Manifest + _ = json.Unmarshal([]byte(b.Manifest), &m) + var pretty bytes.Buffer + _ = json.Indent(&pretty, []byte(b.Manifest), "", " ") + s.render(w, r, "backup", map[string]any{"Backup": b, "Client": c, "M": &m, "Raw": pretty.String()}) +} + +func (s *Server) webBackupDownload(w http.ResponseWriter, r *http.Request) { + b, _, ok := s.backupFromPath(w, r) + if !ok { + return + } + s.servePart(w, r, b, r.PathValue("name")) +} + +func (s *Server) webBackupDelete(w http.ResponseWriter, r *http.Request) { + b, c, ok := s.backupFromPath(w, r) + if !ok { + return + } + if err := s.deleteBackup(b); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/clients/%d", c.ID), http.StatusSeeOther) +} + +func (s *Server) webSettings(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "settings", map[string]any{ + "NtfyURL": s.st.Setting(settingNtfyURL), + "NtfyAuthSet": s.st.Setting(settingNtfyAuth) != "", + "NotifySuccess": s.st.Setting(settingNotifySuccess) == "1", + "Saved": r.URL.Query().Get("saved") != "", + }) +} + +func (s *Server) webSettingsSave(w http.ResponseWriter, r *http.Request) { + _ = s.st.SetSetting(settingNtfyURL, strings.TrimSpace(r.FormValue("ntfy_url"))) + if r.FormValue("ntfy_auth_clear") == "1" { + _ = s.st.SetSetting(settingNtfyAuth, "") + } else if v := strings.TrimSpace(r.FormValue("ntfy_auth")); v != "" { + _ = s.st.SetSetting(settingNtfyAuth, v) + } + ns := "0" + if r.FormValue("notify_success") == "1" { + ns = "1" + } + _ = s.st.SetSetting(settingNotifySuccess, ns) + if r.FormValue("test") == "1" { + s.notify("backup-master: тест", "Тестовое уведомление", "default", "test_tube") + } + http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) +} diff --git a/internal/sysx/sysx.go b/internal/sysx/sysx.go new file mode 100644 index 0000000..e9e3b51 --- /dev/null +++ b/internal/sysx/sysx.go @@ -0,0 +1,164 @@ +// Package sysx — обёртки над системными командами и файлами Linux. +package sysx + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// Output выполняет команду и возвращает stdout без хвостовых пробелов. +func Output(ctx context.Context, name string, args ...string) (string, error) { + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = strings.TrimSpace(stdout.String()) + } + return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg) + } + return strings.TrimSpace(stdout.String()), nil +} + +// ToFile выполняет команду, направляя stdout в файл path. +func ToFile(ctx context.Context, path string, name string, args ...string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = f + cmd.Stderr = &stderr + runErr := cmd.Run() + closeErr := f.Close() + if runErr != nil { + return fmt.Errorf("%s: %w: %s", name, runErr, strings.TrimSpace(stderr.String())) + } + return closeErr +} + +// FromFile выполняет команду, подавая файл path на stdin. +func FromFile(ctx context.Context, path string, name string, args ...string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return Stream(ctx, f, name, args...) +} + +// Stream выполняет команду с заданным stdin, вывод — в консоль. +func Stream(ctx context.Context, stdin io.Reader, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} + +// Run выполняет команду с выводом в консоль. +func Run(ctx context.Context, name string, args ...string) error { + return Stream(ctx, nil, name, args...) +} + +// Shell выполняет строку через bash -c с выводом в консоль. +func Shell(ctx context.Context, script string, env ...string) error { + cmd := exec.CommandContext(ctx, "bash", "-c", script) + cmd.Env = append(os.Environ(), env...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("bash -c %q: %w", script, err) + } + return nil +} + +func Exists(p string) bool { + _, err := os.Lstat(p) + return err == nil +} + +func HasCommand(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// OSRelease читает /etc/os-release. +func OSRelease() map[string]string { + out := map[string]string{} + f, err := os.Open("/etc/os-release") + if err != nil { + return out + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + k, v, ok := strings.Cut(sc.Text(), "=") + if !ok { + continue + } + out[k] = strings.Trim(v, `"'`) + } + return out +} + +// DpkgVersion возвращает установленную версию пакета или "". +func DpkgVersion(ctx context.Context, pkg string) string { + out, err := Output(ctx, "dpkg-query", "-W", "-f=${db:Status-Status} ${Version}", pkg) + if err != nil { + return "" + } + status, ver, _ := strings.Cut(out, " ") + if status != "installed" { + return "" + } + return ver +} + +func DpkgArch(ctx context.Context) string { + out, err := Output(ctx, "dpkg", "--print-architecture") + if err != nil { + return "" + } + return out +} + +// UnitState возвращает (enabled, active) для systemd-юнита. +func UnitState(ctx context.Context, unit string) (bool, bool) { + en, _ := Output(ctx, "systemctl", "is-enabled", unit) + ac, _ := Output(ctx, "systemctl", "is-active", unit) + return en == "enabled", ac == "active" +} + +// UnitLoaded сообщает, известен ли systemd такой юнит. +func UnitLoaded(ctx context.Context, unit string) bool { + out, err := Output(ctx, "systemctl", "show", "-p", "LoadState", "--value", unit) + return err == nil && out == "loaded" +} + +// GroupMembers возвращает членов группы из /etc/group. +func GroupMembers(group string) []string { + data, err := os.ReadFile("/etc/group") + if err != nil { + return nil + } + for _, line := range strings.Split(string(data), "\n") { + f := strings.Split(line, ":") + if len(f) == 4 && f[0] == group && f[3] != "" { + return strings.Split(f[3], ",") + } + } + return nil +}