package server import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "time" "gitea.hsrv.site/mrleo1nid/backup-master/internal/api" "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" "gitea.hsrv.site/mrleo1nid/backup-master/internal/version" "gopkg.in/yaml.v3" ) 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 resp := AgentConfigResponse{Domain: c.Domain, ConfigYAML: c.ConfigYAML, HeavyDue: due, ServerVersion: s.cfg.Version} agentVer := r.URL.Query().Get("version") if agentVer != "" && agentVer != c.AgentVersion { _ = s.st.SetAgentVersion(c.ID, agentVer) } // итоговый каталог: встроенный каталог сервера + переопределения клиента if cat, err := s.clientCatalog(c); err != nil { log.Printf("каталог %s: %v", c.Domain, err) } else { resp.CatalogYAML = cat } arch := r.URL.Query().Get("arch") if c.AutoUpdate && agentVer != "" && version.ShouldUpdate(s.cfg.Version, agentVer) { if p, ok := s.binaryPath(arch); ok { if sum, err := s.binarySHA256(p); err == nil { resp.Update = &api.AgentUpdate{Version: s.cfg.Version, Path: "/dl/backupctl-linux-" + arch, SHA256: sum} } else { log.Printf("sha256 %s: %v", p, err) } } } jsonResp(w, http.StatusOK, resp) } func (s *Server) clientCatalog(c *Client) (string, error) { cat, err := catalog.Default() if err != nil { return "", err } if strings.TrimSpace(c.ConfigYAML) != "" { over, err := catalog.Parse([]byte(c.ConfigYAML)) if err != nil { return "", err } cat = catalog.Merge(cat, over) } if err := cat.Validate(); err != nil { return "", err } data, err := yaml.Marshal(cat) return string(data), err } 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(notification{Title: "Бэкап готов: " + c.Domain, Msg: msg, Priority: "low", Tags: "white_check_mark", Link: s.clientLink(c.ID)}) } 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) } // apiTakeover делает восстановленный сервер основным: выпускает клиенту новый // агентский токен. Старый токен перестаёт работать, поэтому агент старого сервера, // если тот ещё жив, получает отказ и больше не шлёт бэкапы. func (s *Server) apiTakeover(w http.ResponseWriter, r *http.Request) { c := clientFrom(r) tok := newToken() if err := s.st.SetClientToken(c.ID, hashToken(tok)); err != nil { jsonErr(w, http.StatusInternalServerError, err.Error()) return } log.Printf("%s: бэкапы переведены на восстановленный сервер, старый токен отозван", c.Domain) s.notify(notification{ Title: "Сервер восстановлен: " + c.Domain, Msg: "Бэкапы теперь присылает восстановленный сервер, токен старого отозван.", Tags: "white_check_mark", Link: s.clientLink(c.ID), }) jsonResp(w, http.StatusOK, map[string]string{"token": tok}) }