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