backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl: - server: API для агентов и restore, веб-морда (один админ), SQLite, ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh; - backup/plan: сбор по каталогу категорий (files/systemd/xui/compose), горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома, digest образов, проверка crontab, потоковая отправка tar.zst; - restore: провижининг на основе vps_installer с теми же версиями пакетов, recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose с заливкой дампов и закреплением образов по digest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
/dist/
|
||||||
|
*.exe
|
||||||
@@ -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
|
||||||
@@ -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/<домен>/<дата>-<id>/`: `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).
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// backupctl — сервер, агент и восстановление backup-master в одном бинарнике.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/agent"
|
||||||
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/restore"
|
||||||
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version задаётся при сборке: -ldflags "-X main.version=..."
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const usage = `backupctl %s
|
||||||
|
|
||||||
|
Сервер бэкапов:
|
||||||
|
backupctl server [флаги] API + веб-морда
|
||||||
|
backupctl admin-passwd <логин> задать логин/пароль администратора
|
||||||
|
|
||||||
|
Клиент:
|
||||||
|
backupctl backup [--config ...] собрать и отправить бэкап
|
||||||
|
backupctl plan [--config ...] показать, что попадёт в бэкап
|
||||||
|
|
||||||
|
Новый сервер:
|
||||||
|
backupctl restore --server URL --token TOKEN [--backup ID] [--skip-heavy] [--skip-provision]
|
||||||
|
|
||||||
|
backupctl version
|
||||||
|
Флаги подкоманды: backupctl <команда> -h
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags)
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, usage, version)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
cmd, args := os.Args[1], os.Args[2:]
|
||||||
|
var err error
|
||||||
|
switch cmd {
|
||||||
|
case "server":
|
||||||
|
err = runServer(ctx, args)
|
||||||
|
case "admin-passwd":
|
||||||
|
err = runAdminPasswd(args)
|
||||||
|
case "backup":
|
||||||
|
err = runBackup(ctx, args)
|
||||||
|
case "plan":
|
||||||
|
err = runPlan(ctx, args)
|
||||||
|
case "restore":
|
||||||
|
err = runRestore(ctx, args)
|
||||||
|
case "version", "--version", "-v":
|
||||||
|
fmt.Println("backupctl", version)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
fmt.Printf(usage, version)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "неизвестная команда %q\n\n"+usage, cmd, version)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ошибка: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverFlags(fs *flag.FlagSet) *server.Config {
|
||||||
|
cfg := &server.Config{}
|
||||||
|
fs.StringVar(&cfg.Listen, "listen", envOr("BM_LISTEN", "127.0.0.1:8080"), "адрес HTTP (env BM_LISTEN)")
|
||||||
|
fs.StringVar(&cfg.DataDir, "data", envOr("BM_DATA", "/var/lib/backup-master"), "каталог данных (env BM_DATA)")
|
||||||
|
fs.StringVar(&cfg.PublicURL, "public-url", os.Getenv("BM_PUBLIC_URL"), "внешний адрес, например https://backup.example.com (env BM_PUBLIC_URL)")
|
||||||
|
fs.StringVar(&cfg.DistDir, "dist", os.Getenv("BM_DIST"), "каталог с backupctl-linux-<arch> для раздачи агентам (env BM_DIST)")
|
||||||
|
fs.BoolVar(&cfg.TrustProxy, "trust-proxy", os.Getenv("BM_TRUST_PROXY") == "1", "брать IP клиента из X-Forwarded-For (env BM_TRUST_PROXY=1)")
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||||
|
cfg := serverFlags(fs)
|
||||||
|
maxGB := fs.Int64("max-upload-gb", 200, "максимальный размер одной части бэкапа, ГБ")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
cfg.MaxUpload = *maxGB << 30
|
||||||
|
if cfg.PublicURL == "" {
|
||||||
|
log.Printf("ВНИМАНИЕ: --public-url не задан, адрес в командах установки берётся из запроса")
|
||||||
|
}
|
||||||
|
s, err := server.New(*cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
err = s.Run(ctx)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAdminPasswd(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("admin-passwd", flag.ExitOnError)
|
||||||
|
data := fs.String("data", envOr("BM_DATA", "/var/lib/backup-master"), "каталог данных сервера (env BM_DATA)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
if fs.NArg() != 1 {
|
||||||
|
return errors.New("использование: backupctl admin-passwd [--data DIR] <логин>")
|
||||||
|
}
|
||||||
|
user := fs.Arg(0)
|
||||||
|
pass, err := readPassword("Пароль: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
again, err := readPassword("Повторите: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if again != pass {
|
||||||
|
return errors.New("пароли не совпадают")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pass) < 10 {
|
||||||
|
return errors.New("пароль короче 10 символов")
|
||||||
|
}
|
||||||
|
s, err := server.New(server.Config{DataDir: *data})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.Store().SetAdmin(user, pass); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Администратор сохранён, все сессии сброшены.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPassword читает пароль без эха с терминала или строку из stdin (для скриптов).
|
||||||
|
func readPassword(prompt string) (string, error) {
|
||||||
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
fmt.Fprint(os.Stderr, prompt)
|
||||||
|
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if err != nil && line == "" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimRight(line, "\r\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentFlags(fs *flag.FlagSet) *agent.Options {
|
||||||
|
o := &agent.Options{AgentVersion: version}
|
||||||
|
fs.StringVar(&o.ConfigPath, "config", agent.DefaultConfigPath, "конфиг агента")
|
||||||
|
fs.BoolVar(&o.ForceHeavy, "heavy", false, "включить heavy-части независимо от расписания")
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBackup(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
||||||
|
o := agentFlags(fs)
|
||||||
|
fs.StringVar(&o.Output, "output", "", "записать части в каталог вместо отправки на сервер (отладка)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
return agent.Backup(ctx, *o)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPlan(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("plan", flag.ExitOnError)
|
||||||
|
o := agentFlags(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
return agent.PrintPlan(ctx, *o, os.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRestore(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("restore", flag.ExitOnError)
|
||||||
|
var o restore.Options
|
||||||
|
fs.StringVar(&o.Server, "server", "", "адрес сервера бэкапов")
|
||||||
|
fs.StringVar(&o.Token, "token", "", "restore-токен из веб-морды")
|
||||||
|
fs.Int64Var(&o.BackupID, "backup", 0, "номер бэкапа (по умолчанию последний успешный)")
|
||||||
|
fs.StringVar(&o.User, "user", "", "пользователь с sudo на новом сервере (по умолчанию спросит)")
|
||||||
|
fs.BoolVar(&o.SkipProvision, "skip-provision", false, "не выполнять базовую подготовку (повторный запуск)")
|
||||||
|
fs.BoolVar(&o.SkipHeavy, "skip-heavy", false, "не скачивать heavy-части (например, MinIO Stoat)")
|
||||||
|
fs.BoolVar(&o.Yes, "yes", false, "не спрашивать подтверждение")
|
||||||
|
fs.StringVar(&o.StagingDir, "staging", "", "каталог для временных файлов (по умолчанию /var/tmp/backup-restore)")
|
||||||
|
fs.BoolVar(&o.KeepStaging, "keep-staging", false, "не удалять временные файлы (дампы) после восстановления")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
if o.Server == "" {
|
||||||
|
return errors.New("нужен --server")
|
||||||
|
}
|
||||||
|
return restore.Run(ctx, o)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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`. Пути внутри архива:
|
||||||
|
|
||||||
|
- `<cat>/files/<абсолютный путь>` — восстанавливается в `/`;
|
||||||
|
- `<cat>/dumps/<файл>` — дампы БД и снимок x-ui.db;
|
||||||
|
- `<cat>/volumes/<том>/…` — именованные docker-тома;
|
||||||
|
- `<cat>/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 <db>` → ожидание 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`.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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") != ""}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
func lock(string) (func(), error) { return func() {}, nil }
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("категория не заменена")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"` // абсолютные пути в <cat>/files/
|
||||||
|
Binaries []string `json:"binaries,omitempty"` // абсолютные пути в <cat>/bin/ (fallback)
|
||||||
|
Dumps []Dump `json:"dumps,omitempty"`
|
||||||
|
Volumes []Volume `json:"volumes,omitempty"`
|
||||||
|
Heavy []string `json:"heavy,omitempty"` // пути, ушедшие в отдельную часть <cat>
|
||||||
|
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 — файл в <cat>/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-том, содержимое в <cat>/volumes/<name>/.
|
||||||
|
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"
|
||||||
@@ -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("таймаут ожидания")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <<EOF
|
||||||
|
[DEFAULT]
|
||||||
|
bantime = 1h
|
||||||
|
findtime = 10m
|
||||||
|
maxretry = 5
|
||||||
|
# свои IP, чтобы не забанить себя
|
||||||
|
ignoreip = $IGNORE_IP
|
||||||
|
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
backend = systemd
|
||||||
|
EOF
|
||||||
|
systemctl enable fail2ban >/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 "Базовая подготовка завершена"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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 не должен распаковываться")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" <<EOF
|
||||||
|
server: $SERVER
|
||||||
|
token: $TOKEN
|
||||||
|
spool_dir: /var/tmp/backup-agent
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 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 <restore-токен> [--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" "$@" </dev/tty
|
||||||
|
fi
|
||||||
|
exec /usr/local/bin/backupctl restore --server "$SERVER" "$@"
|
||||||
|
`))
|
||||||
|
|
||||||
|
func (s *Server) publicURL(r *http.Request) string {
|
||||||
|
if s.cfg.PublicURL != "" {
|
||||||
|
return s.cfg.PublicURL
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveScript(w http.ResponseWriter, r *http.Request, t *template.Template) {
|
||||||
|
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||||
|
if err := t.Execute(w, map[string]string{"URL": s.publicURL(r)}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveAgentScript(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.serveScript(w, r, agentScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveRestoreScript(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.serveScript(w, r, restoreScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
var binNameRe = regexp.MustCompile(`^backupctl-linux-(amd64|arm64)$`)
|
||||||
|
|
||||||
|
// serveBinary раздаёт backupctl для агентов: из --dist, а для своей архитектуры — сам себя.
|
||||||
|
func (s *Server) serveBinary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("file")
|
||||||
|
m := binNameRe.FindStringSubmatch(name)
|
||||||
|
if m == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.cfg.DistDir != "" {
|
||||||
|
p := filepath.Join(s.cfg.DistDir, name)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
http.ServeFile(w, r, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "linux" && runtime.GOARCH == m[1] {
|
||||||
|
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
http.ServeFile(w, r, self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "бинарник "+name+" не найден: положите его в --dist", http.StatusNotFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// Package server — сервер бэкапов: API для агентов и restore, веб-морда, фоновые задачи.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Listen string
|
||||||
|
DataDir string
|
||||||
|
PublicURL string // внешний адрес, подставляется в команды установки
|
||||||
|
DistDir string // бинарники backupctl-linux-<arch> для раздачи агентам
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "title"}}Бэкап #{{.Backup.ID}} — backup-master{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{$b := .Backup}}
|
||||||
|
<p><a href="/clients/{{.Client.ID}}">← {{.Client.Domain}}</a></p>
|
||||||
|
<div class="head">
|
||||||
|
<h1>Бэкап #{{.Backup.ID}} <span class="badge {{if eq .Backup.Status "complete"}}ok{{else if eq .Backup.Status "failed"}}fail{{else}}stale{{end}}">{{.Backup.Status}}</span></h1>
|
||||||
|
{{if eq .Backup.Status "complete"}}
|
||||||
|
<form method="post" action="/clients/{{.Client.ID}}/restore-token" class="inline">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<input type="hidden" name="backup" value="{{.Backup.ID}}">
|
||||||
|
<button class="primary">Восстановить из этого бэкапа</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{with .Backup.Error}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Создан</dt><dd>{{ts .Backup.CreatedAt}}</dd></div>
|
||||||
|
<div><dt>Размер</dt><dd>{{bytes .Backup.TotalSize}}</dd></div>
|
||||||
|
<div><dt>Агент</dt><dd>{{.M.AgentVersion}}</dd></div>
|
||||||
|
<div><dt>Части</dt><dd>{{range .Backup.Parts}}{{if eq .Status "complete"}}<a href="/backups/{{$b.ID}}/parts/{{.Name}}">{{.Name}}</a> {{bytes .Size}}<br>{{else}}<span class="muted">{{.Name}}: {{.Status}}</span><br>{{end}}{{end}}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{{if .M.Warnings}}
|
||||||
|
<details class="warnings" open>
|
||||||
|
<summary>Предупреждения ({{len .M.Warnings}})</summary>
|
||||||
|
<ul>{{range .M.Warnings}}<li>{{.}}</li>{{end}}</ul>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
<h2>Манифест</h2>
|
||||||
|
<pre class="json">{{.Raw}}</pre>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{{define "title"}}{{.Client.Domain}} — backup-master{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{$csrf := .CSRF}}
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1>{{.Client.Domain}} <span class="badge {{.StatusClass}}">{{.StatusText}}</span></h1>
|
||||||
|
{{if ne .Client.Name .Client.Domain}}<p class="muted">{{.Client.Name}}</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/clients/{{.Client.ID}}/restore-token" class="inline">
|
||||||
|
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||||
|
<button class="primary" {{if not .Latest}}disabled title="Нет успешных бэкапов"{{end}}>Команда восстановления</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/clients/{{.Client.ID}}/token" class="inline" onsubmit="return confirm('Старый токен перестанет работать. Продолжить?')">
|
||||||
|
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||||
|
<button>Новый токен агента</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{with .Error}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
{{if .Client.LastError}}<p class="error">Последняя ошибка ({{ts .Client.LastErrorAt}}): {{.Client.LastError}}</p>{{end}}
|
||||||
|
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Последний бэкап</dt><dd title="{{ts .Client.LastBackupAt}}">{{ago .Client.LastBackupAt}}</dd></div>
|
||||||
|
<div><dt>Heavy-части</dt><dd title="{{ts .Client.LastHeavyAt}}">{{ago .Client.LastHeavyAt}}</dd></div>
|
||||||
|
<div><dt>Агент на связи</dt><dd title="{{ts .Client.LastSeenAt}}">{{ago .Client.LastSeenAt}}</dd></div>
|
||||||
|
{{with .Latest}}<div><dt>Система</dt><dd>{{.Hostname}}{{with .OS.PrettyName}} · {{.}}{{end}}{{with .OS.Arch}} · {{.}}{{end}}</dd></div>{{end}}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{{with .Latest}}
|
||||||
|
<h2>Что бэкапится <span class="muted small">по последнему бэкапу</span></h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Категория</th><th>Тип</th><th>Версия</th><th>Сервис</th><th>Содержимое</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Categories}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}{{if .Auto}} <span class="badge stale" title="Проект найден автоматически и не описан в каталоге — бэкапится с настройками по умолчанию">авто</span>{{end}}</td>
|
||||||
|
<td>{{.Type}}</td>
|
||||||
|
<td>{{.Version}}{{with .Compose}}{{with .Git}}<span class="muted small" title="{{.Remote}}">git {{printf "%.8s" .Commit}}{{if .Dirty}}*{{end}}</span>{{end}}{{end}}</td>
|
||||||
|
<td>{{with .Service}}{{.Unit}} {{if .Active}}<span class="ok-text">active</span>{{else}}<span class="muted">inactive</span>{{end}}{{if not .Enabled}} <span class="muted">(disabled)</span>{{end}}{{end}}</td>
|
||||||
|
<td class="small">
|
||||||
|
{{if .Paths}}{{join .Paths ", "}}{{end}}
|
||||||
|
{{range .Dumps}}<br>дамп {{.Kind}}{{with .Service}} ({{.}}){{end}}{{end}}
|
||||||
|
{{range .Volumes}}<br>том {{.Name}}{{end}}
|
||||||
|
{{if .Heavy}}<br>heavy: {{join .Heavy ", "}}{{if .HeavySkipped}} <span class="muted">(не в этот раз)</span>{{end}}{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5" class="muted">Ни одной категории не найдено.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{if .Packages}}
|
||||||
|
<p class="small muted">Пакеты: {{range $k, $v := .Packages}}{{$k}}={{$v}} {{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Warnings}}
|
||||||
|
<details class="warnings" open>
|
||||||
|
<summary>Предупреждения ({{len .Warnings}})</summary>
|
||||||
|
<ul>{{range .Warnings}}<li>{{.}}</li>{{end}}</ul>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>История</h2>
|
||||||
|
{{if .Backups}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>#</th><th>Дата</th><th>Статус</th><th class="num">Размер</th><th>Части</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Backups}}
|
||||||
|
{{$b := .}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/backups/{{.ID}}">{{.ID}}</a></td>
|
||||||
|
<td>{{ts .CreatedAt}}</td>
|
||||||
|
<td><span class="badge {{if eq .Status "complete"}}ok{{else if eq .Status "failed"}}fail{{else}}stale{{end}}">{{.Status}}</span>{{with .Error}} <span class="small muted">{{.}}</span>{{end}}</td>
|
||||||
|
<td class="num">{{if .TotalSize}}{{bytes .TotalSize}}{{end}}</td>
|
||||||
|
<td class="small">{{range .Parts}}{{if eq .Status "complete"}}<a href="/backups/{{$b.ID}}/parts/{{.Name}}">{{.Name}}</a> ({{bytes .Size}}) {{else if eq .Status "deleted"}}<span class="muted">{{.Name}} удалена</span> {{end}}{{end}}</td>
|
||||||
|
<td class="right">
|
||||||
|
<form method="post" action="/backups/{{.ID}}/delete" class="inline" onsubmit="return confirm('Удалить бэкап #{{.ID}}?')">
|
||||||
|
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||||
|
<button class="link danger">удалить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Бэкапов ещё нет.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Настройки</h2>
|
||||||
|
<form method="post" action="/clients/{{.Client.ID}}/settings" class="card">
|
||||||
|
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||||
|
<label>Название <input name="name" value="{{.Client.Name}}"></label>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Дневных копий <input type="number" min="0" name="keep_daily" value="{{.Client.KeepDaily}}"></label>
|
||||||
|
<label>Недельных <input type="number" min="0" name="keep_weekly" value="{{.Client.KeepWeekly}}"></label>
|
||||||
|
<label>Месячных <input type="number" min="0" name="keep_monthly" value="{{.Client.KeepMonthly}}"></label>
|
||||||
|
<label>Тревога через, ч <input type="number" min="0" name="stale_hours" value="{{.Client.StaleHours}}"></label>
|
||||||
|
<label>Heavy раз в, дней <input type="number" min="0" name="heavy_every_days" value="{{.Client.HeavyEveryDays}}"></label>
|
||||||
|
<label>Хранить heavy, шт <input type="number" min="0" name="heavy_keep" value="{{.Client.HeavyKeep}}"></label>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input type="checkbox" name="disabled" value="1" {{if .Client.Disabled}}checked{{end}}> Клиент отключён (агент получает отказ)</label>
|
||||||
|
<label>Переопределения каталога (YAML)
|
||||||
|
<textarea name="config_yaml" rows="12" spellcheck="false" placeholder="categories:
|
||||||
|
- {name: scripts, disabled: true} # отключить встроенную категорию
|
||||||
|
- name: extra # добавить свою
|
||||||
|
type: files
|
||||||
|
paths: [/srv/extra]
|
||||||
|
dumps:
|
||||||
|
- name: full_dump.sql
|
||||||
|
cmd: mysqldump --all-databases
|
||||||
|
restore_cmd: mysql < "$DUMP_FILE"">{{.Client.ConfigYAML}}</textarea>
|
||||||
|
</label>
|
||||||
|
<p class="hint">Категория с тем же <code>name</code>, что во встроенном каталоге, заменяет её целиком. Поля: 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.</p>
|
||||||
|
<button class="primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<details class="danger-zone">
|
||||||
|
<summary>Удаление клиента</summary>
|
||||||
|
<form method="post" action="/clients/{{.Client.ID}}/delete">
|
||||||
|
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||||
|
<p>Будут удалены все бэкапы клиента. Введите домен для подтверждения:</p>
|
||||||
|
<input name="confirm" placeholder="{{.Client.Domain}}">
|
||||||
|
<button class="danger">Удалить клиента и бэкапы</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{define "title"}}Новый клиент — backup-master{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Новый клиент</h1>
|
||||||
|
{{with .Error}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
<form method="post" action="/clients" class="card narrow">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<label>Домен <input name="domain" value="{{.Domain}}" placeholder="nl1.example.com" required autofocus></label>
|
||||||
|
<p class="hint">По домену бэкап находится при восстановлении. Он же станет hostname нового сервера, если на старом hostname не задан.</p>
|
||||||
|
<label>Название (необязательно) <input name="name" value="{{.Name}}"></label>
|
||||||
|
<button class="primary">Создать и получить токен</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "title"}}Клиенты — backup-master{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="head">
|
||||||
|
<h1>Клиенты</h1>
|
||||||
|
<a class="button primary" href="/clients/new">Добавить клиента</a>
|
||||||
|
</div>
|
||||||
|
{{if .Clients}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Домен</th><th>Название</th><th>Статус</th><th>Последний бэкап</th><th class="num">Размер</th><th>Агент на связи</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Clients}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/clients/{{.ID}}">{{.Domain}}</a></td>
|
||||||
|
<td>{{if ne .Name .Domain}}{{.Name}}{{end}}</td>
|
||||||
|
<td><span class="badge {{.StatusClass}}">{{.StatusText}}</span>{{if and (eq .StatusClass "fail") .LastError}} <span class="muted small" title="{{.LastError}}">{{.LastError}}</span>{{end}}</td>
|
||||||
|
<td title="{{ts .LastBackupAt}}">{{ago .LastBackupAt}}</td>
|
||||||
|
<td class="num">{{if .LastSize}}{{bytes .LastSize}}{{end}}</td>
|
||||||
|
<td title="{{ts .LastSeenAt}}">{{ago .LastSeenAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Клиентов пока нет. Добавьте первый — получите команду установки агента.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{block "title" .}}backup-master{{end}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{if ne .Page "login"}}
|
||||||
|
<header class="top">
|
||||||
|
<a class="brand" href="/">backup-master</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/" {{if or (eq .Page "clients") (eq .Page "client") (eq .Page "backup")}}class="active"{{end}}>Клиенты</a>
|
||||||
|
<a href="/settings" {{if eq .Page "settings"}}class="active"{{end}}>Настройки</a>
|
||||||
|
<form method="post" action="/logout" class="inline">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<button class="link">Выйти</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
|
<main>
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "title"}}Вход — backup-master{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="login">
|
||||||
|
<h1>backup-master</h1>
|
||||||
|
{{if .NoAdmin}}
|
||||||
|
<p class="error">Администратор не задан. На сервере выполните:<br><code>backupctl admin-passwd <логин></code></p>
|
||||||
|
{{end}}
|
||||||
|
{{with .Error}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Логин <input name="user" autocomplete="username" required autofocus></label>
|
||||||
|
<label>Пароль <input name="password" type="password" autocomplete="current-password" required></label>
|
||||||
|
<button class="primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "title"}}Настройки — backup-master{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Настройки</h1>
|
||||||
|
{{if .Saved}}<p class="ok-text">Сохранено.</p>{{end}}
|
||||||
|
<form method="post" action="/settings" class="card narrow">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<h2>Уведомления ntfy</h2>
|
||||||
|
<label>URL топика <input name="ntfy_url" value="{{.NtfyURL}}" placeholder="https://ntfy.example.com/backups"></label>
|
||||||
|
<label>Авторизация <input name="ntfy_auth" type="password" autocomplete="off" placeholder="{{if .NtfyAuthSet}}задана — оставьте пустым, чтобы не менять{{else}}tk_... или user:password{{end}}"></label>
|
||||||
|
{{if .NtfyAuthSet}}<label class="check"><input type="checkbox" name="ntfy_auth_clear" value="1"> Удалить авторизацию</label>{{end}}
|
||||||
|
<label class="check"><input type="checkbox" name="notify_success" value="1" {{if .NotifySuccess}}checked{{end}}> Сообщать и об успешных бэкапах</label>
|
||||||
|
<label class="check"><input type="checkbox" name="test" value="1"> Отправить тестовое уведомление после сохранения</label>
|
||||||
|
<p class="hint">Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда.</p>
|
||||||
|
<button class="primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">Логин и пароль администратора меняются на сервере: <code>backupctl admin-passwd <логин></code></p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{define "title"}}{{.Title}} — backup-master{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
<p>{{.Note}}</p>
|
||||||
|
<div class="cmd">
|
||||||
|
<pre id="cmd">{{.Cmd}}</pre>
|
||||||
|
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('cmd').textContent).then(()=>{this.textContent='Скопировано'})">Копировать</button>
|
||||||
|
</div>
|
||||||
|
<p><a href="/clients/{{.Client.ID}}">← к клиенту {{.Client.Domain}}</a></p>
|
||||||
|
{{end}}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user