Один бинарник 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>
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
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)
|
|
}
|