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

+395
View File
@@ -0,0 +1,395 @@
package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
type ctxKey int
const clientKey ctxKey = 1
func clientFrom(r *http.Request) *Client { return r.Context().Value(clientKey).(*Client) }
func jsonResp(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
jsonResp(w, code, map[string]string{"error": msg})
}
// agentAuth — upload-токен клиента. Даёт только запись своих бэкапов.
func (s *Server) agentAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
jsonErr(w, http.StatusUnauthorized, "нужен токен")
return
}
c, err := s.st.ClientByToken(hashToken(tok))
if err != nil || c.Disabled {
jsonErr(w, http.StatusUnauthorized, "неверный токен или клиент отключён")
return
}
_ = s.st.TouchClient(c.ID)
h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c)))
}
}
// restoreAuth — временный restore-токен, даёт чтение бэкапов одного клиента.
func (s *Server) restoreAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
id, err := s.st.RestoreTokenClient(hashToken(tok))
if tok == "" || err != nil {
jsonErr(w, http.StatusUnauthorized, "неверный или просроченный restore-токен")
return
}
c, err := s.st.GetClient(id)
if err != nil {
jsonErr(w, http.StatusUnauthorized, "клиент удалён")
return
}
h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c)))
}
}
type AgentConfigResponse = api.AgentConfig
func (s *Server) apiAgentConfig(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
due := c.HeavyEveryDays >= 0 &&
time.Since(time.Unix(c.LastHeavyAt, 0)) >= time.Duration(c.HeavyEveryDays)*24*time.Hour-time.Hour
jsonResp(w, http.StatusOK, AgentConfigResponse{Domain: c.Domain, ConfigYAML: c.ConfigYAML, HeavyDue: due})
}
var partNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
func (s *Server) apiCreateBackup(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
var m manifest.Manifest
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<20)).Decode(&m); err != nil {
jsonErr(w, http.StatusBadRequest, "манифест: "+err.Error())
return
}
if !slices.Contains(m.Parts, manifest.MainPart) {
jsonErr(w, http.StatusBadRequest, "в манифесте нет части main")
return
}
for _, p := range m.Parts {
if !partNameRe.MatchString(p) {
jsonErr(w, http.StatusBadRequest, "недопустимое имя части "+p)
return
}
}
m.Domain = c.Domain
now := time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
// предыдущая незавершённая загрузка этого клиента уже не продолжится
if old, err := s.st.UploadingBefore(now.Unix()+1, c.ID); err == nil {
for _, b := range old {
if !s.isUploading(b.ID) {
s.failBackup(b, "прервана новым бэкапом")
}
}
}
raw, _ := json.Marshal(m)
id, err := s.st.CreateBackup(c.ID, now.Unix(), string(raw))
if err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
dir := filepath.Join(c.Domain, fmt.Sprintf("%s-%d", now.Format("20060102-150405"), id))
if err := os.MkdirAll(filepath.Join(s.backupsDir(), dir), 0o700); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.st.SetBackupDir(id, dir); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
log.Printf("бэкап #%d (%s) начат, части %v", id, c.Domain, m.Parts)
jsonResp(w, http.StatusCreated, map[string]int64{"id": id})
}
// ownBackup загружает бэкап из пути и проверяет, что он принадлежит клиенту.
func (s *Server) ownBackup(w http.ResponseWriter, r *http.Request) (*Backup, *manifest.Manifest, bool) {
c := clientFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonErr(w, http.StatusBadRequest, "id")
return nil, nil, false
}
b, err := s.st.GetBackup(id)
if err != nil || b.ClientID != c.ID {
jsonErr(w, http.StatusNotFound, "бэкап не найден")
return nil, nil, false
}
var m manifest.Manifest
if err := json.Unmarshal([]byte(b.Manifest), &m); err != nil {
jsonErr(w, http.StatusInternalServerError, "манифест: "+err.Error())
return nil, nil, false
}
return b, &m, true
}
func (s *Server) isUploading(id int64) bool {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
return s.uploads[id]
}
func (s *Server) apiUploadPart(w http.ResponseWriter, r *http.Request) {
b, m, ok := s.ownBackup(w, r)
if !ok {
return
}
name := r.PathValue("name")
if b.Status != StatusUploading {
jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status)
return
}
if !slices.Contains(m.Parts, name) {
jsonErr(w, http.StatusBadRequest, "части "+name+" нет в манифесте")
return
}
s.uploadMu.Lock()
if s.uploads[b.ID] {
s.uploadMu.Unlock()
jsonErr(w, http.StatusConflict, "загрузка уже идёт")
return
}
s.uploads[b.ID] = true
s.uploadMu.Unlock()
defer func() {
s.uploadMu.Lock()
delete(s.uploads, b.ID)
s.uploadMu.Unlock()
}()
tmp := s.partPath(b.Dir, name) + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), http.MaxBytesReader(w, r.Body, s.cfg.MaxUpload))
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(tmp)
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
jsonErr(w, http.StatusRequestEntityTooLarge, "превышен размер части")
return
}
jsonErr(w, http.StatusBadRequest, "загрузка прервана: "+err.Error())
return
}
sum := hex.EncodeToString(h.Sum(nil))
if err := s.st.UpsertPart(Part{BackupID: b.ID, Name: name, Size: n, SHA256: sum, Status: PartUploaded}); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
jsonResp(w, http.StatusOK, PartInfo{Name: name, Size: n, SHA256: sum})
}
type PartInfo = api.PartInfo
type CompleteRequest = api.CompleteRequest
func (s *Server) apiComplete(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
b, m, ok := s.ownBackup(w, r)
if !ok {
return
}
if b.Status != StatusUploading {
jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status)
return
}
var req CompleteRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
claimed := map[string]PartInfo{}
for _, p := range req.Parts {
claimed[p.Name] = p
}
stored := map[string]Part{}
for _, p := range b.Parts {
stored[p.Name] = p
}
var total int64
for _, name := range m.Parts {
p, ok := stored[name]
cl := claimed[name]
if !ok {
jsonErr(w, http.StatusBadRequest, "часть "+name+" не загружена")
return
}
if cl.SHA256 != p.SHA256 || cl.Size != p.Size {
s.failBackup(b, fmt.Sprintf("часть %s: контрольная сумма не совпала", name))
jsonErr(w, http.StatusBadRequest, "контрольная сумма части "+name+" не совпала")
return
}
total += p.Size
}
for _, name := range m.Parts {
final := s.partPath(b.Dir, name)
if err := os.Rename(final+".part", final); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.st.SetPartStatus(b.ID, name, PartComplete); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
}
if len(req.Warnings) > 0 {
m.Warnings = append(m.Warnings, req.Warnings...)
raw, _ := json.Marshal(m)
b.Manifest = string(raw)
if err := s.st.SetBackupManifest(b.ID, b.Manifest); err != nil {
log.Printf("backup #%d: %v", b.ID, err)
}
}
_ = os.WriteFile(filepath.Join(s.backupsDir(), b.Dir, manifest.ManifestFile), []byte(b.Manifest), 0o600)
if err := s.st.SetBackupStatus(b.ID, StatusComplete, "", total); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
heavy := len(m.Parts) > 1
if err := s.st.MarkClientSuccess(c.ID, b.CreatedAt, heavy); err != nil {
log.Printf("client %d: %v", c.ID, err)
}
log.Printf("бэкап #%d (%s) завершён: %s", b.ID, c.Domain, humanBytes(total))
if s.st.Setting(settingNotifySuccess) == "1" {
msg := fmt.Sprintf("%s: %s, частей %d", c.Domain, humanBytes(total), len(m.Parts))
if len(m.Warnings) > 0 {
msg += fmt.Sprintf(", предупреждений %d", len(m.Warnings))
}
s.notify("Бэкап готов: "+c.Domain, msg, "low", "white_check_mark")
}
if c2, err := s.st.GetClient(c.ID); err == nil {
if err := s.applyRetention(c2); err != nil {
log.Printf("ретеншн %s: %v", c.Domain, err)
}
}
jsonResp(w, http.StatusOK, map[string]any{"id": b.ID, "size": total})
}
func (s *Server) apiFail(w http.ResponseWriter, r *http.Request) {
b, _, ok := s.ownBackup(w, r)
if !ok {
return
}
var req struct {
Error string `json:"error"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req)
if req.Error == "" {
req.Error = "агент сообщил об ошибке"
}
if b.Status == StatusUploading {
s.failBackup(b, req.Error)
}
jsonResp(w, http.StatusOK, map[string]string{"status": StatusFailed})
}
type RestoreManifestResponse = api.RestoreManifest
func (s *Server) apiRestoreManifest(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
var b *Backup
var err error
if v := r.URL.Query().Get("backup"); v != "" {
id, _ := strconv.ParseInt(v, 10, 64)
b, err = s.st.GetBackup(id)
if err == nil && (b.ClientID != c.ID || b.Status != StatusComplete) {
err = ErrNotFound
}
} else {
b, err = s.st.LatestComplete(c.ID)
}
if err != nil {
jsonErr(w, http.StatusNotFound, "нет подходящего бэкапа")
return
}
resp := RestoreManifestResponse{BackupID: b.ID, CreatedAt: time.Unix(b.CreatedAt, 0), Manifest: json.RawMessage(b.Manifest)}
for _, p := range b.Parts {
if p.Status == PartComplete {
resp.Parts = append(resp.Parts, PartInfo{Name: p.Name, Size: p.Size, SHA256: p.SHA256})
}
}
// heavy-части из более ранних бэкапов здесь не подмешиваются: restore может запросить
// их отдельно через ?backup=, если понадобится
jsonResp(w, http.StatusOK, resp)
}
func (s *Server) apiRestorePart(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
b, err := s.st.GetBackup(id)
if err != nil || b.ClientID != c.ID || b.Status != StatusComplete {
jsonErr(w, http.StatusNotFound, "бэкап не найден")
return
}
s.servePart(w, r, b, r.PathValue("name"))
}
func (s *Server) servePart(w http.ResponseWriter, r *http.Request, b *Backup, name string) {
found := false
for _, p := range b.Parts {
if p.Name == name && p.Status == PartComplete {
found = true
w.Header().Set("X-Content-Sha256", p.SHA256)
}
}
if !found {
http.Error(w, "часть не найдена", http.StatusNotFound)
return
}
f, err := os.Open(s.partPath(b.Dir, name))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zstd")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%d-%s.tar.zst"`,
filepath.Base(filepath.Dir(b.Dir)), b.ID, name))
http.ServeContent(w, r, "", fi.ModTime(), f)
}
+231
View File
@@ -0,0 +1,231 @@
package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
func newTestServer(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
s, err := New(Config{DataDir: t.TempDir(), PublicURL: "https://backup.test"})
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() { ts.Close(); s.Close() })
return s, ts
}
func call(t *testing.T, method, url, token string, body io.Reader) *http.Response {
t.Helper()
req, err := http.NewRequest(method, url, body)
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
func decode(t *testing.T, resp *http.Response, v any) {
t.Helper()
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatal(err)
}
}
func sum(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func TestUploadAndRestoreFlow(t *testing.T) {
s, ts := newTestServer(t)
agentTok := newToken()
clientID, err := s.st.CreateClient("nl1", "nl1.example.com", hashToken(agentTok))
if err != nil {
t.Fatal(err)
}
// без токена — 401
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", "", nil); r.StatusCode != 401 {
t.Fatalf("без токена: %d", r.StatusCode)
}
var cfg AgentConfigResponse
r := call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil)
decode(t, r, &cfg)
if cfg.Domain != "nl1.example.com" || !cfg.HeavyDue {
t.Fatalf("config: %+v", cfg)
}
m := manifest.Manifest{FormatVersion: 1, Hostname: "nl1", Parts: []string{"main", "stoat"}}
raw, _ := json.Marshal(m)
r = call(t, "POST", ts.URL+"/api/v1/backups", agentTok, bytes.NewReader(raw))
if r.StatusCode != 201 {
t.Fatalf("create: %d", r.StatusCode)
}
var created struct{ ID int64 }
decode(t, r, &created)
mainData := []byte("main-archive-data")
heavyData := bytes.Repeat([]byte("minio"), 1000)
for name, data := range map[string][]byte{"main": mainData, "stoat": heavyData} {
r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/%s", ts.URL, created.ID, name), agentTok, bytes.NewReader(data))
if r.StatusCode != 200 {
t.Fatalf("upload %s: %d", name, r.StatusCode)
}
}
// часть вне манифеста
r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/other", ts.URL, created.ID), agentTok, strings.NewReader("x"))
if r.StatusCode != 400 {
t.Fatalf("чужая часть: %d", r.StatusCode)
}
done := CompleteRequest{Parts: []PartInfo{
{Name: "main", Size: int64(len(mainData)), SHA256: sum(mainData)},
{Name: "stoat", Size: int64(len(heavyData)), SHA256: sum(heavyData)},
}}
raw, _ = json.Marshal(done)
r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), agentTok, bytes.NewReader(raw))
if r.StatusCode != 200 {
body, _ := io.ReadAll(r.Body)
t.Fatalf("complete: %d %s", r.StatusCode, body)
}
c, _ := s.st.GetClient(clientID)
if c.LastBackupAt == 0 || c.LastHeavyAt == 0 {
t.Fatalf("клиент не отмечен: %+v", c)
}
r = call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil)
decode(t, r, &cfg)
if cfg.HeavyDue {
t.Fatal("heavy только что загружен")
}
// upload-токен не даёт читать
if r := call(t, "GET", ts.URL+"/api/v1/restore/manifest", agentTok, nil); r.StatusCode != 401 {
t.Fatalf("агентский токен читает бэкапы: %d", r.StatusCode)
}
restoreTok := newToken()
if err := s.st.CreateRestoreToken(hashToken(restoreTok), clientID, time.Hour); err != nil {
t.Fatal(err)
}
var rm RestoreManifestResponse
r = call(t, "GET", ts.URL+"/api/v1/restore/manifest", restoreTok, nil)
decode(t, r, &rm)
if rm.BackupID != created.ID || len(rm.Parts) != 2 {
t.Fatalf("restore manifest: %+v", rm)
}
var got manifest.Manifest
if err := json.Unmarshal(rm.Manifest, &got); err != nil || got.Domain != "nl1.example.com" {
t.Fatalf("manifest domain: %q %v", got.Domain, err)
}
// скачивание с Range (докачка)
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/restore/%d/parts/main", ts.URL, created.ID), nil)
req.Header.Set("Authorization", "Bearer "+restoreTok)
req.Header.Set("Range", "bytes=5-")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 206 || string(body) != string(mainData[5:]) {
t.Fatalf("range: %d %q", resp.StatusCode, body)
}
}
func TestChecksumMismatchFails(t *testing.T) {
s, ts := newTestServer(t)
tok := newToken()
if _, err := s.st.CreateClient("x", "x.example.com", hashToken(tok)); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(manifest.Manifest{Parts: []string{"main"}})
r := call(t, "POST", ts.URL+"/api/v1/backups", tok, bytes.NewReader(raw))
var created struct{ ID int64 }
decode(t, r, &created)
call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/main", ts.URL, created.ID), tok, strings.NewReader("data"))
raw, _ = json.Marshal(CompleteRequest{Parts: []PartInfo{{Name: "main", Size: 4, SHA256: "bad"}}})
r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), tok, bytes.NewReader(raw))
if r.StatusCode != 400 {
t.Fatalf("complete с неверной суммой: %d", r.StatusCode)
}
b, _ := s.st.GetBackup(created.ID)
if b.Status != StatusFailed {
t.Fatalf("статус %s", b.Status)
}
}
func TestWebLoginAndPages(t *testing.T) {
s, ts := newTestServer(t)
if err := s.st.SetAdmin("admin", "secret-pass"); err != nil {
t.Fatal(err)
}
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(ts.URL + "/")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 303 {
t.Fatalf("без сессии: %d", resp.StatusCode)
}
resp, err = client.PostForm(ts.URL+"/login", map[string][]string{"user": {"admin"}, "password": {"secret-pass"}})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
var session *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == sessionCookie {
session = c
}
}
if session == nil {
t.Fatal("нет cookie сессии")
}
for _, p := range []string{"/", "/clients/new", "/settings"} {
req, _ := http.NewRequest("GET", ts.URL+p, nil)
req.AddCookie(session)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("%s: %d", p, resp.StatusCode)
}
}
// POST без CSRF отклоняется
req, _ := http.NewRequest("POST", ts.URL+"/clients", strings.NewReader("domain=a.example.com"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(session)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 403 {
t.Fatalf("без CSRF: %d", resp.StatusCode)
}
}
+169
View File
@@ -0,0 +1,169 @@
package server
import (
"context"
"crypto/subtle"
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "bm_session"
sessionTTL = 7 * 24 * time.Hour
csrfKey ctxKey = 2
)
// SetAdmin задаёт логин и пароль единственного администратора и сбрасывает сессии.
func (s *Store) SetAdmin(user, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := s.SetSetting(settingAdminUser, user); err != nil {
return err
}
if err := s.SetSetting(settingAdminHash, string(hash)); err != nil {
return err
}
return s.DeleteAllSessions()
}
func (s *Store) CheckAdmin(user, password string) bool {
hash := s.Setting(settingAdminHash)
if hash == "" {
return false
}
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(s.Setting(settingAdminUser))) == 1
passOK := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
return userOK && passOK
}
// admin пропускает только авторизованного админа; на POST дополнительно проверяет CSRF.
func (s *Server) admin(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ck, err := r.Cookie(sessionCookie)
var csrf string
if err == nil {
csrf, err = s.st.SessionCSRF(ck.Value)
}
if err != nil {
if r.Method == http.MethodGet {
http.Redirect(w, r, "/login", http.StatusSeeOther)
} else {
http.Error(w, "нужна авторизация", http.StatusUnauthorized)
}
return
}
if r.Method == http.MethodPost {
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrf)) != 1 {
http.Error(w, "неверный CSRF-токен, обновите страницу", http.StatusForbidden)
return
}
}
h(w, r.WithContext(context.WithValue(r.Context(), csrfKey, csrf)))
}
}
func csrfFrom(r *http.Request) string {
v, _ := r.Context().Value(csrfKey).(string)
return v
}
func (s *Server) secureCookies() bool { return strings.HasPrefix(s.cfg.PublicURL, "https://") }
func (s *Server) webLoginPage(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "login", map[string]any{"NoAdmin": s.st.Setting(settingAdminHash) == ""})
}
func (s *Server) webLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if !s.login.allowed(ip) {
s.render(w, r, "login", map[string]any{"Error": "Слишком много попыток, подождите несколько минут"})
return
}
if !s.st.CheckAdmin(r.FormValue("user"), r.FormValue("password")) {
s.login.fail(ip)
time.Sleep(700 * time.Millisecond)
s.render(w, r, "login", map[string]any{"Error": "Неверный логин или пароль"})
return
}
s.login.reset(ip)
tok := newToken()
if err := s.st.CreateSession(tok, newToken(), sessionTTL); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, Secure: s.secureCookies(),
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) webLogout(w http.ResponseWriter, r *http.Request) {
if ck, err := r.Cookie(sessionCookie); err == nil {
_ = s.st.DeleteSession(ck.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if s.cfg.TrustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
}
return host
}
// loginLimiter: после 5 неудач подряд вход с IP блокируется на 10 минут.
type loginLimiter struct {
mu sync.Mutex
fails map[string]*loginState
}
type loginState struct {
n int
until time.Time
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{fails: map[string]*loginState{}} }
func (l *loginLimiter) allowed(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
st := l.fails[ip]
return st == nil || time.Now().After(st.until)
}
func (l *loginLimiter) fail(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
st := l.fails[ip]
if st == nil {
st = &loginState{}
l.fails[ip] = st
}
st.n++
if st.n >= 5 {
st.n = 0
st.until = time.Now().Add(10 * time.Minute)
}
}
func (l *loginLimiter) reset(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.fails, ip)
}
+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])
}
+112
View File
@@ -0,0 +1,112 @@
package server
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
// SelectGFS возвращает индексы бэкапов, которые нужно сохранить.
// times отсортированы от новых к старым. Последний бэкап сохраняется всегда,
// дальше — самый новый в каждом из daily последних дней, weekly недель (ISO), monthly месяцев.
func SelectGFS(times []time.Time, daily, weekly, monthly int) map[int]bool {
keep := map[int]bool{}
if len(times) == 0 {
return keep
}
keep[0] = true
bucket := func(n int, key func(time.Time) string) {
seen := map[string]bool{}
for i, t := range times {
if len(seen) >= n {
return
}
k := key(t)
if !seen[k] {
seen[k] = true
keep[i] = true
}
}
}
bucket(daily, func(t time.Time) string { return t.Format("2006-01-02") })
bucket(weekly, func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
})
bucket(monthly, func(t time.Time) string { return t.Format("2006-01") })
return keep
}
// applyRetention удаляет лишние бэкапы клиента и старые heavy-части.
func (s *Server) applyRetention(c *Client) error {
backups, err := s.st.ListBackups(c.ID, StatusComplete)
if err != nil {
return err
}
times := make([]time.Time, len(backups))
for i, b := range backups {
times[i] = time.Unix(b.CreatedAt, 0)
}
keep := SelectGFS(times, c.KeepDaily, c.KeepWeekly, c.KeepMonthly)
// heavy-части: по каждому имени — только heavy_keep последних.
// Бэкап с сохраняемой heavy-частью не удаляется целиком, даже если GFS его не выбрал.
type excess struct {
b *Backup
name string
}
var drop []excess
heavySeen := map[string]int{}
for i, b := range backups {
for _, p := range b.Parts {
if p.Name == manifest.MainPart || p.Status != PartComplete {
continue
}
heavySeen[p.Name]++
if heavySeen[p.Name] <= max(c.HeavyKeep, 0) {
keep[i] = true
} else {
drop = append(drop, excess{b, p.Name})
}
}
}
for _, d := range drop {
if err := s.deletePart(d.b, d.name); err != nil {
return err
}
}
for i, b := range backups {
if keep[i] {
continue
}
if err := s.deleteBackup(b); err != nil {
return err
}
log.Printf("ретеншн: %s удалён бэкап #%d от %s", c.Domain, b.ID, time.Unix(b.CreatedAt, 0).Format(time.DateTime))
}
return nil
}
func (s *Server) deletePart(b *Backup, name string) error {
if err := os.Remove(s.partPath(b.Dir, name)); err != nil && !os.IsNotExist(err) {
return err
}
if err := s.st.SetPartStatus(b.ID, name, PartDeleted); err != nil {
return err
}
return s.st.RecalcTotal(b.ID)
}
func (s *Server) deleteBackup(b *Backup) error {
if b.Dir != "" {
if err := os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir)); err != nil {
return err
}
}
return s.st.DeleteBackup(b.ID)
}
+41
View File
@@ -0,0 +1,41 @@
package server
import (
"testing"
"time"
)
func TestSelectGFS(t *testing.T) {
// 60 дней ежедневных бэкапов + второй бэкап в последний день
now := time.Date(2026, 10, 6, 3, 0, 0, 0, time.Local)
times := []time.Time{now.Add(time.Hour)}
for d := 0; d < 60; d++ {
times = append(times, now.AddDate(0, 0, -d))
}
keep := SelectGFS(times, 7, 4, 3)
if !keep[0] {
t.Fatal("последний бэкап должен сохраняться")
}
if keep[1] {
t.Error("второй бэкап того же дня не должен сохраняться")
}
for i := 2; i <= 7; i++ {
if !keep[i] {
t.Errorf("дневной бэкап %d должен сохраняться", i)
}
}
// 7 дневных (вкл. idx 0) + недели/месяцы частично пересекаются; верхняя граница 7+4+3
if n := len(keep); n < 7 || n > 14 {
t.Errorf("сохранено %d бэкапов", n)
}
if keep[len(times)-1] && len(keep) == len(times) {
t.Error("ничего не удалено")
}
if len(SelectGFS(nil, 7, 4, 6)) != 0 {
t.Error("пустой список")
}
if k := SelectGFS(times, 0, 0, 0); len(k) != 1 || !k[0] {
t.Errorf("при нулевых keep_* остаётся только последний: %v", k)
}
}
+170
View File
@@ -0,0 +1,170 @@
package server
import (
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"text/template"
)
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)$`)
// serveBinary раздаёт backupctl для агентов: из --dist, а для своей архитектуры — сам себя.
func (s *Server) serveBinary(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
m := binNameRe.FindStringSubmatch(name)
if m == nil {
http.NotFound(w, r)
return
}
if s.cfg.DistDir != "" {
p := filepath.Join(s.cfg.DistDir, name)
if _, err := os.Stat(p); err == nil {
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, p)
return
}
}
if runtime.GOOS == "linux" && runtime.GOARCH == m[1] {
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, self)
return
}
}
http.Error(w, "бинарник "+name+" не найден: положите его в --dist", http.StatusNotFound)
}
+255
View File
@@ -0,0 +1,255 @@
// Package server — сервер бэкапов: API для агентов и restore, веб-морда, фоновые задачи.
package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type Config struct {
Listen string
DataDir string
PublicURL string // внешний адрес, подставляется в команды установки
DistDir string // бинарники backupctl-linux-<arch> для раздачи агентам
MaxUpload int64 // максимальный размер одной части, байт
// TrustProxy — брать IP клиента из X-Forwarded-For (за Caddy).
TrustProxy bool
}
type Server struct {
cfg Config
st *Store
pages map[string]*pageTemplate
login *loginLimiter
uploadMu sync.Mutex
uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка
}
func New(cfg Config) (*Server, error) {
if cfg.MaxUpload == 0 {
cfg.MaxUpload = 200 << 30
}
cfg.PublicURL = strings.TrimRight(cfg.PublicURL, "/")
if err := os.MkdirAll(filepath.Join(cfg.DataDir, "backups"), 0o700); err != nil {
return nil, err
}
st, err := OpenStore(filepath.Join(cfg.DataDir, "backup-master.db"))
if err != nil {
return nil, err
}
pages, err := loadPages()
if err != nil {
st.Close()
return nil, err
}
return &Server{cfg: cfg, st: st, pages: pages, login: newLoginLimiter(), uploads: map[int64]bool{}}, nil
}
func (s *Server) Close() error { return s.st.Close() }
func (s *Server) Store() *Store { return s.st }
func (s *Server) backupsDir() string { return filepath.Join(s.cfg.DataDir, "backups") }
func (s *Server) partPath(dir, name string) string {
return filepath.Join(s.backupsDir(), dir, name+".tar.zst")
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// API агента
mux.HandleFunc("GET /api/v1/agent/config", s.agentAuth(s.apiAgentConfig))
mux.HandleFunc("POST /api/v1/backups", s.agentAuth(s.apiCreateBackup))
mux.HandleFunc("PUT /api/v1/backups/{id}/parts/{name}", s.agentAuth(s.apiUploadPart))
mux.HandleFunc("POST /api/v1/backups/{id}/complete", s.agentAuth(s.apiComplete))
mux.HandleFunc("POST /api/v1/backups/{id}/fail", s.agentAuth(s.apiFail))
// API восстановления
mux.HandleFunc("GET /api/v1/restore/manifest", s.restoreAuth(s.apiRestoreManifest))
mux.HandleFunc("GET /api/v1/restore/{id}/parts/{name}", s.restoreAuth(s.apiRestorePart))
// публичное
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
mux.HandleFunc("GET /agent.sh", s.serveAgentScript)
mux.HandleFunc("GET /restore.sh", s.serveRestoreScript)
mux.HandleFunc("GET /dl/{file}", s.serveBinary)
mux.Handle("GET /static/", http.FileServerFS(staticFS))
// веб-морда
mux.HandleFunc("GET /login", s.webLoginPage)
mux.HandleFunc("POST /login", s.webLogin)
mux.HandleFunc("POST /logout", s.admin(s.webLogout))
mux.HandleFunc("GET /{$}", s.admin(s.webClients))
mux.HandleFunc("GET /clients/new", s.admin(s.webClientNew))
mux.HandleFunc("POST /clients", s.admin(s.webClientCreate))
mux.HandleFunc("GET /clients/{id}", s.admin(s.webClient))
mux.HandleFunc("POST /clients/{id}/settings", s.admin(s.webClientSettings))
mux.HandleFunc("POST /clients/{id}/token", s.admin(s.webClientRotateToken))
mux.HandleFunc("POST /clients/{id}/restore-token", s.admin(s.webClientRestoreToken))
mux.HandleFunc("POST /clients/{id}/delete", s.admin(s.webClientDelete))
mux.HandleFunc("GET /backups/{id}", s.admin(s.webBackup))
mux.HandleFunc("GET /backups/{id}/parts/{name}", s.admin(s.webBackupDownload))
mux.HandleFunc("POST /backups/{id}/delete", s.admin(s.webBackupDelete))
mux.HandleFunc("GET /settings", s.admin(s.webSettings))
mux.HandleFunc("POST /settings", s.admin(s.webSettingsSave))
return logRequests(mux)
}
// Run запускает HTTP-сервер и фоновые задачи до отмены ctx.
func (s *Server) Run(ctx context.Context) error {
if s.st.Setting(settingAdminHash) == "" {
log.Printf("ВНИМАНИЕ: админ не задан, выполните: backupctl admin-passwd <логин>")
}
srv := &http.Server{
Addr: s.cfg.Listen,
Handler: s.Handler(),
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 2 * time.Minute,
}
go s.background(ctx)
errc := make(chan error, 1)
go func() {
log.Printf("backup-master слушает %s (public url %s)", s.cfg.Listen, s.cfg.PublicURL)
errc <- srv.ListenAndServe()
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shCtx)
}
}
func (s *Server) background(ctx context.Context) {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
s.tick()
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (s *Server) tick() {
if err := s.st.Cleanup(); err != nil {
log.Printf("cleanup: %v", err)
}
// зависшие загрузки
stuck, err := s.st.UploadingBefore(time.Now().Add(-6*time.Hour).Unix(), 0)
if err != nil {
log.Printf("stuck uploads: %v", err)
}
for _, b := range stuck {
s.failBackup(b, "загрузка не завершилась за 6 часов")
}
// клиенты без свежих бэкапов
clients, err := s.st.ListClients()
if err != nil {
log.Printf("clients: %v", err)
return
}
now := time.Now()
for _, c := range clients {
if c.Disabled || c.StaleNotified || c.StaleHours <= 0 {
continue
}
ref := c.LastBackupAt
if ref == 0 {
ref = c.CreatedAt
}
if now.Sub(time.Unix(ref, 0)) > time.Duration(c.StaleHours)*time.Hour {
msg := fmt.Sprintf("%s: нет успешного бэкапа больше %d ч", c.Domain, c.StaleHours)
if c.LastBackupAt == 0 {
msg = fmt.Sprintf("%s: от клиента ещё не пришло ни одного бэкапа", c.Domain)
}
s.notify("Бэкап просрочен: "+c.Domain, msg, "high", "warning")
_ = s.st.SetStaleNotified(c.ID)
}
}
}
func (s *Server) failBackup(b *Backup, msg string) {
if b.Dir != "" {
_ = os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir))
}
if err := s.st.SetBackupStatus(b.ID, StatusFailed, msg, 0); err != nil {
log.Printf("backup #%d: %v", b.ID, err)
}
_ = s.st.MarkClientError(b.ClientID, msg)
domain := fmt.Sprintf("клиент #%d", b.ClientID)
if c, err := s.st.GetClient(b.ClientID); err == nil {
domain = c.Domain
}
log.Printf("бэкап #%d (%s) провален: %s", b.ID, domain, msg)
s.notify("Бэкап не удался: "+domain, msg, "high", "x")
}
// --- токены ---
const tokenPrefix = "bm_"
func newToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b)
}
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
}
func bearer(r *http.Request) string {
v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
return ""
}
return strings.TrimSpace(v)
}
// --- логирование ---
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func logRequests(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
h.ServeHTTP(sw, r)
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
return
}
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
})
}
+127
View File
@@ -0,0 +1,127 @@
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1c2128;
--muted: #6b7280;
--border: #dfe3e8;
--accent: #2f6fde;
--accent-text: #ffffff;
--ok: #1f8a4c;
--ok-bg: #e3f4ea;
--warn: #9a6400;
--warn-bg: #fdf1d8;
--fail: #c0362c;
--fail-bg: #fbe5e3;
--code-bg: #eef1f5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171c;
--panel: #1c2027;
--text: #e4e7eb;
--muted: #9aa3ae;
--border: #2e343d;
--accent: #5b8ff0;
--ok: #4cc27f;
--ok-bg: #18342a;
--warn: #e0ad4a;
--warn-bg: #3a2f17;
--fail: #f07167;
--fail-bg: #3d1f1d;
--code-bg: #232831;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
.top {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
}
.brand { font-weight: 600; color: var(--text); }
.top nav { display: flex; align-items: center; gap: 18px; }
.top nav a { color: var(--muted); }
.top nav a.active { color: var(--text); font-weight: 500; }
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 24px; margin: 0 0 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h2 { font-size: 18px; margin: 32px 0 12px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
.right { text-align: right; }
.empty { color: var(--muted); padding: 24px 0; }
.hint { color: var(--muted); font-size: 13px; margin: 4px 0 12px; }
.table-wrap { overflow-x: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-size: 13px; font-weight: 500; color: var(--muted); }
tr:last-child td { border-bottom: none; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.badge.ok { background: var(--ok-bg); color: var(--ok); }
.badge.stale, .badge.never { background: var(--warn-bg); color: var(--warn); }
.badge.fail { background: var(--fail-bg); color: var(--fail); }
.badge.off { background: var(--code-bg); color: var(--muted); }
.ok-text { color: var(--ok); }
.facts { display: flex; flex-wrap: wrap; gap: 12px 32px; margin: 16px 0; }
.facts dt { font-size: 12px; color: var(--muted); }
.facts dd { margin: 0; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; }
.card.narrow, .login { max-width: 520px; }
.card h2:first-of-type { margin-top: 0; }
label { display: block; margin-bottom: 12px; font-size: 14px; }
label.check { display: flex; align-items: center; gap: 8px; }
input, textarea {
display: block; width: 100%; margin-top: 4px; padding: 7px 10px;
background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: inherit;
}
input[type=checkbox] { display: inline; width: auto; margin: 0; }
textarea { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0 16px; }
button, .button {
display: inline-block; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--panel); color: var(--text); font: inherit; cursor: pointer;
}
button:hover, .button:hover { text-decoration: none; filter: brightness(0.97); }
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.link { background: none; border: none; padding: 0; color: var(--muted); }
button.danger { background: var(--fail-bg); border-color: var(--fail-bg); color: var(--fail); }
button.link.danger { background: none; }
form.inline { display: inline; }
.error { background: var(--fail-bg); color: var(--fail); padding: 10px 14px; border-radius: 6px; }
.warnings { background: var(--warn-bg); color: var(--warn); padding: 10px 14px; border-radius: 6px; margin: 12px 0; }
.warnings ul { margin: 8px 0 0; padding-left: 20px; font-size: 13px; }
.danger-zone { margin-top: 32px; color: var(--muted); }
.danger-zone form { margin-top: 12px; max-width: 420px; }
.danger-zone input { margin-bottom: 8px; }
.cmd { display: flex; gap: 8px; align-items: flex-start; margin: 16px 0; }
.cmd pre { flex: 1; margin: 0; padding: 12px; background: var(--code-bg); border-radius: 6px; white-space: pre-wrap; word-break: break-all; }
pre.json { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-x: auto; }
.login { margin: 10vh auto; }
.login form { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; }
@media (max-width: 640px) {
main { padding: 16px; }
.top { padding: 10px 16px; }
.cmd { flex-direction: column; }
}
+442
View File
@@ -0,0 +1,442 @@
package server
import (
"database/sql"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
domain TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
config_yaml TEXT NOT NULL DEFAULT '',
keep_daily INTEGER NOT NULL DEFAULT 7,
keep_weekly INTEGER NOT NULL DEFAULT 4,
keep_monthly INTEGER NOT NULL DEFAULT 6,
heavy_keep INTEGER NOT NULL DEFAULT 1,
heavy_every_days INTEGER NOT NULL DEFAULT 7,
stale_hours INTEGER NOT NULL DEFAULT 26,
created_at INTEGER NOT NULL,
last_backup_at INTEGER NOT NULL DEFAULT 0,
last_heavy_at INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_error_at INTEGER NOT NULL DEFAULT 0,
stale_notified INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
status TEXT NOT NULL,
dir TEXT NOT NULL DEFAULT '',
manifest TEXT NOT NULL,
total_size INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS backups_client ON backups(client_id, created_at);
CREATE TABLE IF NOT EXISTS parts (
backup_id INTEGER NOT NULL REFERENCES backups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
PRIMARY KEY (backup_id, name)
);
CREATE TABLE IF NOT EXISTS restore_tokens (
token_hash TEXT PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
csrf TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
const (
StatusUploading = "uploading"
StatusComplete = "complete"
StatusFailed = "failed"
PartUploaded = "uploaded"
PartComplete = "complete"
PartDeleted = "deleted" // heavy-часть удалена ротацией
)
var ErrNotFound = errors.New("не найдено")
type Store struct {
db *sql.DB
}
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
// одно соединение: исключает SQLITE_BUSY, нагрузка у сервиса мизерная
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("миграция: %w", err)
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
type Client struct {
ID int64
Name string
Domain string
TokenHash string
ConfigYAML string
KeepDaily int
KeepWeekly int
KeepMonthly int
HeavyKeep int
HeavyEveryDays int
StaleHours int
CreatedAt int64
LastBackupAt int64
LastHeavyAt int64
LastSeenAt int64
LastError string
LastErrorAt int64
StaleNotified bool
Disabled bool
}
const clientCols = `id, name, domain, token_hash, config_yaml, keep_daily, keep_weekly, keep_monthly,
heavy_keep, heavy_every_days, stale_hours, created_at, last_backup_at, last_heavy_at, last_seen_at,
last_error, last_error_at, stale_notified, disabled`
func scanClient(row interface{ Scan(...any) error }) (*Client, error) {
var c Client
err := row.Scan(&c.ID, &c.Name, &c.Domain, &c.TokenHash, &c.ConfigYAML, &c.KeepDaily, &c.KeepWeekly,
&c.KeepMonthly, &c.HeavyKeep, &c.HeavyEveryDays, &c.StaleHours, &c.CreatedAt, &c.LastBackupAt,
&c.LastHeavyAt, &c.LastSeenAt, &c.LastError, &c.LastErrorAt, &c.StaleNotified, &c.Disabled)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &c, err
}
func (s *Store) CreateClient(name, domain, tokenHash string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO clients (name, domain, token_hash, created_at) VALUES (?, ?, ?, ?)`,
name, domain, tokenHash, time.Now().Unix())
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) GetClient(id int64) (*Client, error) {
return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE id = ?`, id))
}
func (s *Store) ClientByToken(hash string) (*Client, error) {
return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE token_hash = ?`, hash))
}
func (s *Store) ListClients() ([]*Client, error) {
rows, err := s.db.Query(`SELECT ` + clientCols + ` FROM clients ORDER BY domain`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Client
for rows.Next() {
c, err := scanClient(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) UpdateClientSettings(c *Client) error {
_, err := s.db.Exec(`UPDATE clients SET name = ?, config_yaml = ?, keep_daily = ?, keep_weekly = ?,
keep_monthly = ?, heavy_keep = ?, heavy_every_days = ?, stale_hours = ?, disabled = ? WHERE id = ?`,
c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays,
c.StaleHours, c.Disabled, c.ID)
return err
}
func (s *Store) SetClientToken(id int64, hash string) error {
_, err := s.db.Exec(`UPDATE clients SET token_hash = ? WHERE id = ?`, hash, id)
return err
}
func (s *Store) DeleteClient(id int64) error {
_, err := s.db.Exec(`DELETE FROM clients WHERE id = ?`, id)
return err
}
func (s *Store) TouchClient(id int64) error {
_, err := s.db.Exec(`UPDATE clients SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), id)
return err
}
func (s *Store) MarkClientSuccess(id int64, at int64, heavy bool) error {
q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0`
if heavy {
q += `, last_heavy_at = ?1`
}
_, err := s.db.Exec(q+` WHERE id = ?2`, at, id)
return err
}
func (s *Store) MarkClientError(id int64, msg string) error {
_, err := s.db.Exec(`UPDATE clients SET last_error = ?, last_error_at = ? WHERE id = ?`, msg, time.Now().Unix(), id)
return err
}
func (s *Store) SetStaleNotified(id int64) error {
_, err := s.db.Exec(`UPDATE clients SET stale_notified = 1 WHERE id = ?`, id)
return err
}
type Backup struct {
ID int64
ClientID int64
CreatedAt int64
Status string
Dir string
Manifest string
TotalSize int64
Error string
Parts []Part
}
type Part struct {
BackupID int64
Name string
Size int64
SHA256 string
Status string
}
func (s *Store) CreateBackup(clientID int64, createdAt int64, manifest string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO backups (client_id, created_at, status, manifest) VALUES (?, ?, ?, ?)`,
clientID, createdAt, StatusUploading, manifest)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) SetBackupDir(id int64, dir string) error {
_, err := s.db.Exec(`UPDATE backups SET dir = ? WHERE id = ?`, dir, id)
return err
}
func (s *Store) SetBackupStatus(id int64, status, errMsg string, totalSize int64) error {
_, err := s.db.Exec(`UPDATE backups SET status = ?, error = ?, total_size = ? WHERE id = ?`, status, errMsg, totalSize, id)
return err
}
func (s *Store) SetBackupManifest(id int64, manifest string) error {
_, err := s.db.Exec(`UPDATE backups SET manifest = ? WHERE id = ?`, manifest, id)
return err
}
func (s *Store) DeleteBackup(id int64) error {
_, err := s.db.Exec(`DELETE FROM backups WHERE id = ?`, id)
return err
}
func (s *Store) GetBackup(id int64) (*Backup, error) {
var b Backup
err := s.db.QueryRow(`SELECT id, client_id, created_at, status, dir, manifest, total_size, error FROM backups WHERE id = ?`, id).
Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.Manifest, &b.TotalSize, &b.Error)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
b.Parts, err = s.parts(id)
return &b, err
}
func (s *Store) parts(backupID int64) ([]Part, error) {
rows, err := s.db.Query(`SELECT backup_id, name, size, sha256, status FROM parts WHERE backup_id = ? ORDER BY name = 'main' DESC, name`, backupID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Part
for rows.Next() {
var p Part
if err := rows.Scan(&p.BackupID, &p.Name, &p.Size, &p.SHA256, &p.Status); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ListBackups возвращает бэкапы клиента от новых к старым (без манифеста), с частями.
func (s *Store) ListBackups(clientID int64, status string) ([]*Backup, error) {
q := `SELECT id, client_id, created_at, status, dir, total_size, error FROM backups WHERE client_id = ?`
args := []any{clientID}
if status != "" {
q += ` AND status = ?`
args = append(args, status)
}
rows, err := s.db.Query(q+` ORDER BY created_at DESC, id DESC`, args...)
if err != nil {
return nil, err
}
var out []*Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.TotalSize, &b.Error); err != nil {
rows.Close()
return nil, err
}
out = append(out, &b)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, b := range out {
if b.Parts, err = s.parts(b.ID); err != nil {
return nil, err
}
}
return out, nil
}
// LatestComplete возвращает последний успешный бэкап клиента.
func (s *Store) LatestComplete(clientID int64) (*Backup, error) {
var id int64
err := s.db.QueryRow(`SELECT id FROM backups WHERE client_id = ? AND status = ? ORDER BY created_at DESC, id DESC LIMIT 1`,
clientID, StatusComplete).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return s.GetBackup(id)
}
// UploadingBefore возвращает незавершённые загрузки старше ts (по всем клиентам или одному).
func (s *Store) UploadingBefore(ts int64, clientID int64) ([]*Backup, error) {
q := `SELECT id, client_id, dir FROM backups WHERE status = ? AND created_at < ?`
args := []any{StatusUploading, ts}
if clientID != 0 {
q += ` AND client_id = ?`
args = append(args, clientID)
}
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.ClientID, &b.Dir); err != nil {
return nil, err
}
out = append(out, &b)
}
return out, rows.Err()
}
func (s *Store) UpsertPart(p Part) error {
_, err := s.db.Exec(`INSERT INTO parts (backup_id, name, size, sha256, status) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (backup_id, name) DO UPDATE SET size = excluded.size, sha256 = excluded.sha256, status = excluded.status`,
p.BackupID, p.Name, p.Size, p.SHA256, p.Status)
return err
}
func (s *Store) SetPartStatus(backupID int64, name, status string) error {
_, err := s.db.Exec(`UPDATE parts SET status = ? WHERE backup_id = ? AND name = ?`, status, backupID, name)
return err
}
func (s *Store) RecalcTotal(backupID int64) error {
_, err := s.db.Exec(`UPDATE backups SET total_size = (SELECT COALESCE(SUM(size), 0) FROM parts
WHERE backup_id = ?1 AND status = ?2) WHERE id = ?1`, backupID, PartComplete)
return err
}
func (s *Store) CreateRestoreToken(hash string, clientID int64, ttl time.Duration) error {
now := time.Now()
_, err := s.db.Exec(`INSERT INTO restore_tokens (token_hash, client_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
hash, clientID, now.Unix(), now.Add(ttl).Unix())
return err
}
func (s *Store) RestoreTokenClient(hash string) (int64, error) {
var id int64
err := s.db.QueryRow(`SELECT client_id FROM restore_tokens WHERE token_hash = ? AND expires_at > ?`, hash, time.Now().Unix()).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrNotFound
}
return id, err
}
func (s *Store) CreateSession(token, csrf string, ttl time.Duration) error {
_, err := s.db.Exec(`INSERT INTO sessions (token, csrf, expires_at) VALUES (?, ?, ?)`, token, csrf, time.Now().Add(ttl).Unix())
return err
}
func (s *Store) SessionCSRF(token string) (string, error) {
var csrf string
err := s.db.QueryRow(`SELECT csrf FROM sessions WHERE token = ? AND expires_at > ?`, token, time.Now().Unix()).Scan(&csrf)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return csrf, err
}
func (s *Store) DeleteSession(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func (s *Store) DeleteAllSessions() error {
_, err := s.db.Exec(`DELETE FROM sessions`)
return err
}
func (s *Store) Cleanup() error {
now := time.Now().Unix()
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, now); err != nil {
return err
}
_, err := s.db.Exec(`DELETE FROM restore_tokens WHERE expires_at <= ?`, now)
return err
}
func (s *Store) Setting(key string) string {
var v string
_ = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
return v
}
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
+30
View File
@@ -0,0 +1,30 @@
{{define "title"}}Бэкап #{{.Backup.ID}} — backup-master{{end}}
{{define "content"}}
{{$b := .Backup}}
<p><a href="/clients/{{.Client.ID}}">← {{.Client.Domain}}</a></p>
<div class="head">
<h1>Бэкап #{{.Backup.ID}} <span class="badge {{if eq .Backup.Status "complete"}}ok{{else if eq .Backup.Status "failed"}}fail{{else}}stale{{end}}">{{.Backup.Status}}</span></h1>
{{if eq .Backup.Status "complete"}}
<form method="post" action="/clients/{{.Client.ID}}/restore-token" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="backup" value="{{.Backup.ID}}">
<button class="primary">Восстановить из этого бэкапа</button>
</form>
{{end}}
</div>
{{with .Backup.Error}}<p class="error">{{.}}</p>{{end}}
<dl class="facts">
<div><dt>Создан</dt><dd>{{ts .Backup.CreatedAt}}</dd></div>
<div><dt>Размер</dt><dd>{{bytes .Backup.TotalSize}}</dd></div>
<div><dt>Агент</dt><dd>{{.M.AgentVersion}}</dd></div>
<div><dt>Части</dt><dd>{{range .Backup.Parts}}{{if eq .Status "complete"}}<a href="/backups/{{$b.ID}}/parts/{{.Name}}">{{.Name}}</a> {{bytes .Size}}<br>{{else}}<span class="muted">{{.Name}}: {{.Status}}</span><br>{{end}}{{end}}</dd></div>
</dl>
{{if .M.Warnings}}
<details class="warnings" open>
<summary>Предупреждения ({{len .M.Warnings}})</summary>
<ul>{{range .M.Warnings}}<li>{{.}}</li>{{end}}</ul>
</details>
{{end}}
<h2>Манифест</h2>
<pre class="json">{{.Raw}}</pre>
{{end}}
+132
View File
@@ -0,0 +1,132 @@
{{define "title"}}{{.Client.Domain}} — backup-master{{end}}
{{define "content"}}
{{$csrf := .CSRF}}
<div class="head">
<div>
<h1>{{.Client.Domain}} <span class="badge {{.StatusClass}}">{{.StatusText}}</span></h1>
{{if ne .Client.Name .Client.Domain}}<p class="muted">{{.Client.Name}}</p>{{end}}
</div>
<div class="actions">
<form method="post" action="/clients/{{.Client.ID}}/restore-token" class="inline">
<input type="hidden" name="csrf" value="{{$csrf}}">
<button class="primary" {{if not .Latest}}disabled title="Нет успешных бэкапов"{{end}}>Команда восстановления</button>
</form>
<form method="post" action="/clients/{{.Client.ID}}/token" class="inline" onsubmit="return confirm('Старый токен перестанет работать. Продолжить?')">
<input type="hidden" name="csrf" value="{{$csrf}}">
<button>Новый токен агента</button>
</form>
</div>
</div>
{{with .Error}}<p class="error">{{.}}</p>{{end}}
{{if .Client.LastError}}<p class="error">Последняя ошибка ({{ts .Client.LastErrorAt}}): {{.Client.LastError}}</p>{{end}}
<dl class="facts">
<div><dt>Последний бэкап</dt><dd title="{{ts .Client.LastBackupAt}}">{{ago .Client.LastBackupAt}}</dd></div>
<div><dt>Heavy-части</dt><dd title="{{ts .Client.LastHeavyAt}}">{{ago .Client.LastHeavyAt}}</dd></div>
<div><dt>Агент на связи</dt><dd title="{{ts .Client.LastSeenAt}}">{{ago .Client.LastSeenAt}}</dd></div>
{{with .Latest}}<div><dt>Система</dt><dd>{{.Hostname}}{{with .OS.PrettyName}} · {{.}}{{end}}{{with .OS.Arch}} · {{.}}{{end}}</dd></div>{{end}}
</dl>
{{with .Latest}}
<h2>Что бэкапится <span class="muted small">по последнему бэкапу</span></h2>
<div class="table-wrap">
<table>
<thead><tr><th>Категория</th><th>Тип</th><th>Версия</th><th>Сервис</th><th>Содержимое</th></tr></thead>
<tbody>
{{range .Categories}}
<tr>
<td>{{.Name}}{{if .Auto}} <span class="badge stale" title="Проект найден автоматически и не описан в каталоге — бэкапится с настройками по умолчанию">авто</span>{{end}}</td>
<td>{{.Type}}</td>
<td>{{.Version}}{{with .Compose}}{{with .Git}}<span class="muted small" title="{{.Remote}}">git {{printf "%.8s" .Commit}}{{if .Dirty}}*{{end}}</span>{{end}}{{end}}</td>
<td>{{with .Service}}{{.Unit}} {{if .Active}}<span class="ok-text">active</span>{{else}}<span class="muted">inactive</span>{{end}}{{if not .Enabled}} <span class="muted">(disabled)</span>{{end}}{{end}}</td>
<td class="small">
{{if .Paths}}{{join .Paths ", "}}{{end}}
{{range .Dumps}}<br>дамп {{.Kind}}{{with .Service}} ({{.}}){{end}}{{end}}
{{range .Volumes}}<br>том {{.Name}}{{end}}
{{if .Heavy}}<br>heavy: {{join .Heavy ", "}}{{if .HeavySkipped}} <span class="muted">(не в этот раз)</span>{{end}}{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ни одной категории не найдено.</td></tr>
{{end}}
</tbody>
</table>
</div>
{{if .Packages}}
<p class="small muted">Пакеты: {{range $k, $v := .Packages}}{{$k}}={{$v}} &nbsp; {{end}}</p>
{{end}}
{{if .Warnings}}
<details class="warnings" open>
<summary>Предупреждения ({{len .Warnings}})</summary>
<ul>{{range .Warnings}}<li>{{.}}</li>{{end}}</ul>
</details>
{{end}}
{{end}}
<h2>История</h2>
{{if .Backups}}
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>Дата</th><th>Статус</th><th class="num">Размер</th><th>Части</th><th></th></tr></thead>
<tbody>
{{range .Backups}}
{{$b := .}}
<tr>
<td><a href="/backups/{{.ID}}">{{.ID}}</a></td>
<td>{{ts .CreatedAt}}</td>
<td><span class="badge {{if eq .Status "complete"}}ok{{else if eq .Status "failed"}}fail{{else}}stale{{end}}">{{.Status}}</span>{{with .Error}} <span class="small muted">{{.}}</span>{{end}}</td>
<td class="num">{{if .TotalSize}}{{bytes .TotalSize}}{{end}}</td>
<td class="small">{{range .Parts}}{{if eq .Status "complete"}}<a href="/backups/{{$b.ID}}/parts/{{.Name}}">{{.Name}}</a> ({{bytes .Size}}) {{else if eq .Status "deleted"}}<span class="muted">{{.Name}} удалена</span> {{end}}{{end}}</td>
<td class="right">
<form method="post" action="/backups/{{.ID}}/delete" class="inline" onsubmit="return confirm('Удалить бэкап #{{.ID}}?')">
<input type="hidden" name="csrf" value="{{$csrf}}">
<button class="link danger">удалить</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">Бэкапов ещё нет.</p>
{{end}}
<h2>Настройки</h2>
<form method="post" action="/clients/{{.Client.ID}}/settings" class="card">
<input type="hidden" name="csrf" value="{{$csrf}}">
<label>Название <input name="name" value="{{.Client.Name}}"></label>
<div class="grid">
<label>Дневных копий <input type="number" min="0" name="keep_daily" value="{{.Client.KeepDaily}}"></label>
<label>Недельных <input type="number" min="0" name="keep_weekly" value="{{.Client.KeepWeekly}}"></label>
<label>Месячных <input type="number" min="0" name="keep_monthly" value="{{.Client.KeepMonthly}}"></label>
<label>Тревога через, ч <input type="number" min="0" name="stale_hours" value="{{.Client.StaleHours}}"></label>
<label>Heavy раз в, дней <input type="number" min="0" name="heavy_every_days" value="{{.Client.HeavyEveryDays}}"></label>
<label>Хранить heavy, шт <input type="number" min="0" name="heavy_keep" value="{{.Client.HeavyKeep}}"></label>
</div>
<label class="check"><input type="checkbox" name="disabled" value="1" {{if .Client.Disabled}}checked{{end}}> Клиент отключён (агент получает отказ)</label>
<label>Переопределения каталога (YAML)
<textarea name="config_yaml" rows="12" spellcheck="false" placeholder="categories:
- {name: scripts, disabled: true} # отключить встроенную категорию
- name: extra # добавить свою
type: files
paths: [/srv/extra]
dumps:
- name: full_dump.sql
cmd: mysqldump --all-databases
restore_cmd: mysql &lt; &quot;$DUMP_FILE&quot;">{{.Client.ConfigYAML}}</textarea>
</label>
<p class="hint">Категория с тем же <code>name</code>, что во встроенном каталоге, заменяет её целиком. Поля: type (files|systemd|xui|compose), paths, exclude, detect, service, version_cmd, install, keep_binaries, ensure_dirs, post_restore, dumps, dir, skip_services, heavy_paths, restore_after.</p>
<button class="primary">Сохранить</button>
</form>
<details class="danger-zone">
<summary>Удаление клиента</summary>
<form method="post" action="/clients/{{.Client.ID}}/delete">
<input type="hidden" name="csrf" value="{{$csrf}}">
<p>Будут удалены все бэкапы клиента. Введите домен для подтверждения:</p>
<input name="confirm" placeholder="{{.Client.Domain}}">
<button class="danger">Удалить клиента и бэкапы</button>
</form>
</details>
{{end}}
+12
View File
@@ -0,0 +1,12 @@
{{define "title"}}Новый клиент — backup-master{{end}}
{{define "content"}}
<h1>Новый клиент</h1>
{{with .Error}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/clients" class="card narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Домен <input name="domain" value="{{.Domain}}" placeholder="nl1.example.com" required autofocus></label>
<p class="hint">По домену бэкап находится при восстановлении. Он же станет hostname нового сервера, если на старом hostname не задан.</p>
<label>Название (необязательно) <input name="name" value="{{.Name}}"></label>
<button class="primary">Создать и получить токен</button>
</form>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "title"}}Клиенты — backup-master{{end}}
{{define "content"}}
<div class="head">
<h1>Клиенты</h1>
<a class="button primary" href="/clients/new">Добавить клиента</a>
</div>
{{if .Clients}}
<div class="table-wrap">
<table>
<thead><tr><th>Домен</th><th>Название</th><th>Статус</th><th>Последний бэкап</th><th class="num">Размер</th><th>Агент на связи</th></tr></thead>
<tbody>
{{range .Clients}}
<tr>
<td><a href="/clients/{{.ID}}">{{.Domain}}</a></td>
<td>{{if ne .Name .Domain}}{{.Name}}{{end}}</td>
<td><span class="badge {{.StatusClass}}">{{.StatusText}}</span>{{if and (eq .StatusClass "fail") .LastError}} <span class="muted small" title="{{.LastError}}">{{.LastError}}</span>{{end}}</td>
<td title="{{ts .LastBackupAt}}">{{ago .LastBackupAt}}</td>
<td class="num">{{if .LastSize}}{{bytes .LastSize}}{{end}}</td>
<td title="{{ts .LastSeenAt}}">{{ago .LastSeenAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">Клиентов пока нет. Добавьте первый — получите команду установки агента.</p>
{{end}}
{{end}}
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}backup-master{{end}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{{if ne .Page "login"}}
<header class="top">
<a class="brand" href="/">backup-master</a>
<nav>
<a href="/" {{if or (eq .Page "clients") (eq .Page "client") (eq .Page "backup")}}class="active"{{end}}>Клиенты</a>
<a href="/settings" {{if eq .Page "settings"}}class="active"{{end}}>Настройки</a>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="link">Выйти</button>
</form>
</nav>
</header>
{{end}}
<main>
{{template "content" .}}
</main>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{{define "title"}}Вход — backup-master{{end}}
{{define "content"}}
<div class="login">
<h1>backup-master</h1>
{{if .NoAdmin}}
<p class="error">Администратор не задан. На сервере выполните:<br><code>backupctl admin-passwd &lt;логин&gt;</code></p>
{{end}}
{{with .Error}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/login">
<label>Логин <input name="user" autocomplete="username" required autofocus></label>
<label>Пароль <input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary">Войти</button>
</form>
</div>
{{end}}
+17
View File
@@ -0,0 +1,17 @@
{{define "title"}}Настройки — backup-master{{end}}
{{define "content"}}
<h1>Настройки</h1>
{{if .Saved}}<p class="ok-text">Сохранено.</p>{{end}}
<form method="post" action="/settings" class="card narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<h2>Уведомления ntfy</h2>
<label>URL топика <input name="ntfy_url" value="{{.NtfyURL}}" placeholder="https://ntfy.example.com/backups"></label>
<label>Авторизация <input name="ntfy_auth" type="password" autocomplete="off" placeholder="{{if .NtfyAuthSet}}задана — оставьте пустым, чтобы не менять{{else}}tk_... или user:password{{end}}"></label>
{{if .NtfyAuthSet}}<label class="check"><input type="checkbox" name="ntfy_auth_clear" value="1"> Удалить авторизацию</label>{{end}}
<label class="check"><input type="checkbox" name="notify_success" value="1" {{if .NotifySuccess}}checked{{end}}> Сообщать и об успешных бэкапах</label>
<label class="check"><input type="checkbox" name="test" value="1"> Отправить тестовое уведомление после сохранения</label>
<p class="hint">Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда.</p>
<button class="primary">Сохранить</button>
</form>
<p class="hint">Логин и пароль администратора меняются на сервере: <code>backupctl admin-passwd &lt;логин&gt;</code></p>
{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "title"}}{{.Title}} — backup-master{{end}}
{{define "content"}}
<h1>{{.Title}}</h1>
<p>{{.Note}}</p>
<div class="cmd">
<pre id="cmd">{{.Cmd}}</pre>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('cmd').textContent).then(()=>{this.textContent='Скопировано'})">Копировать</button>
</div>
<p><a href="/clients/{{.Client.ID}}">← к клиенту {{.Client.Domain}}</a></p>
{{end}}
+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)
}