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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-06 21:52:40 +03:00
commit 1d44d067e9
53 files changed
+7278

No files matched your search

+404
View File
@@ -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)
}