Files
backup-master/internal/server/scripts.go
T
mrleo1nidandClaude Opus 5.5 488abc2d34
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>
2026-10-17 15:27:31 +03:00

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)
}