CI / test (push) Successful in 1m29s
- сервер отдаёт агенту итоговый каталог (встроенный каталог сервера + переопределения клиента); если каталог не подходит версии агента — встроенный каталог агента и предупреждение - самообновление перед бэкапом: версия сервера новее → скачивание с того же сервера (только /dl/...), проверка sha256 и запуска новой версии, подмена с откатом, перезапуск через exec; при ошибке бэкап идёт текущей версией - галочка «Автообновление» у клиента (по умолчанию включена), версия агента в списке и карточке, отставшие подсвечены; версия сервера в шапке - миграция существующей базы: колонки auto_update и agent_version - backupctl backup --no-update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
6.4 KiB
Go
219 lines
6.4 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
)
|
|
|
|
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)$`)
|
|
|
|
// binaryPath возвращает файл backupctl для архитектуры: из --dist, а для своей — сам себя.
|
|
func (s *Server) binaryPath(arch string) (string, bool) {
|
|
name := "backupctl-linux-" + arch
|
|
if !binNameRe.MatchString(name) {
|
|
return "", false
|
|
}
|
|
if s.cfg.DistDir != "" {
|
|
p := filepath.Join(s.cfg.DistDir, name)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p, true
|
|
}
|
|
}
|
|
if runtime.GOOS == "linux" && runtime.GOARCH == arch {
|
|
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
|
|
return self, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
type binarySum struct {
|
|
size int64
|
|
modTime time.Time
|
|
sum string
|
|
}
|
|
|
|
// binarySHA256 считает sha256 файла с кэшем по размеру и времени изменения.
|
|
func (s *Server) binarySHA256(p string) (string, error) {
|
|
fi, err := os.Stat(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.shaMu.Lock()
|
|
c, ok := s.shaCache[p]
|
|
s.shaMu.Unlock()
|
|
if ok && c.size == fi.Size() && c.modTime.Equal(fi.ModTime()) {
|
|
return c.sum, nil
|
|
}
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
sum := hex.EncodeToString(h.Sum(nil))
|
|
s.shaMu.Lock()
|
|
s.shaCache[p] = binarySum{fi.Size(), fi.ModTime(), sum}
|
|
s.shaMu.Unlock()
|
|
return sum, nil
|
|
}
|
|
|
|
// serveBinary раздаёт backupctl для агентов.
|
|
func (s *Server) serveBinary(w http.ResponseWriter, r *http.Request) {
|
|
m := binNameRe.FindStringSubmatch(r.PathValue("file"))
|
|
if m == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
p, ok := s.binaryPath(m[1])
|
|
if !ok {
|
|
http.Error(w, "бинарник "+m[0]+" не найден: положите его в --dist", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
http.ServeFile(w, r, p)
|
|
}
|