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,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)
|
||||
}
|
||||
Reference in new issue
Block a user