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,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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}} {{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 < "$DUMP_FILE"">{{.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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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 <логин></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}}
|
||||
@@ -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 <логин></code></p>
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -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