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

+80
View File
@@ -0,0 +1,80 @@
package server
import (
"context"
"fmt"
"log"
"mime"
"net/http"
"strings"
"time"
)
const (
settingAdminUser = "admin_user"
settingAdminHash = "admin_hash"
settingNtfyURL = "ntfy_url"
settingNtfyAuth = "ntfy_auth" // токен tk_... или "user:password"
settingNotifySuccess = "notify_success"
)
// notify отправляет уведомление в ntfy (асинхронно, ошибки только в лог).
func (s *Server) notify(title, msg, priority, tags string) {
url := s.st.Setting(settingNtfyURL)
if url == "" {
return
}
auth := s.st.Setting(settingNtfyAuth)
go func() {
if err := sendNtfy(url, auth, title, msg, priority, tags); err != nil {
log.Printf("ntfy: %v", err)
}
}()
}
func sendNtfy(url, auth, title, msg, priority, tags string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(msg))
if err != nil {
return err
}
// заголовки должны быть ASCII — кириллицу кодируем по RFC 2047, ntfy это понимает
req.Header.Set("Title", mime.BEncoding.Encode("UTF-8", title))
if priority != "" {
req.Header.Set("Priority", priority)
}
if tags != "" {
req.Header.Set("Tags", tags)
}
switch {
case auth == "":
case strings.Contains(auth, ":"):
u, p, _ := strings.Cut(auth, ":")
req.SetBasicAuth(u, p)
default:
req.Header.Set("Authorization", "Bearer "+auth)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("ответ %s", resp.Status)
}
return nil
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d Б", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp])
}