package server import ( "crypto/subtle" "encoding/json" "fmt" "gitea.lerkolabs.com/lerko/uptop/internal/importer" "gitea.lerkolabs.com/lerko/uptop/internal/metrics" "gitea.lerkolabs.com/lerko/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/store" "html/template" "log" "net/http" "sort" "strings" "time" ) func checkSecret(got, want string) bool { return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 } var statusTpl = template.Must(template.New("status").Parse(` {{.Title}}

{{.Title}}

Powered by uptop
`)) type ServerConfig struct { Port int EnableStatus bool Title string ClusterKey string // Shared Secret for Security } func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { if cfg.ClusterKey == "" { fmt.Println("WARNING: No UPTOP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") } mux := http.NewServeMux() // 1. Push Heartbeat mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { http.Error(w, "Missing token", http.StatusBadRequest) return } if eng.RecordHeartbeat(token) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } else { http.Error(w, "Invalid Token", http.StatusNotFound) } }) // 2. Health Check (For Cluster Follower) mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }) // 3. Config Export mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized) return } data, err := s.ExportData() if err != nil { log.Printf("Export failed: %v", err) http.Error(w, "Export failed", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(data) //nolint:errcheck }) // 4. Config Import mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var data models.Backup if err := json.NewDecoder(r.Body).Decode(&data); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if err := s.ImportData(data); err != nil { log.Printf("Import failed: %v", err) http.Error(w, "Import failed", http.StatusInternalServerError) return } _, _ = w.Write([]byte("Import Successful")) }) // 5. Kuma Import mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var kb importer.KumaBackup if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { log.Printf("Invalid Kuma JSON: %v", err) http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest) return } backup := importer.ConvertKuma(&kb) if err := s.ImportData(backup); err != nil { log.Printf("Kuma import failed: %v", err) http.Error(w, "Import failed", http.StatusInternalServerError) return } fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version) }) // 6. Probe Registration mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var req struct { ID string `json:"id"` Name string `json:"name"` Region string `json:"region"` Version string `json:"version"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.ID == "" { http.Error(w, "id is required", http.StatusBadRequest) return } if err := s.RegisterNode(models.ProbeNode{ ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version, }); err != nil { log.Printf("Probe register failed: %v", err) http.Error(w, "Registration failed", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck }) // 7. Probe Assignment Fetch mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } nodeID := r.URL.Query().Get("node_id") var nodeRegion string if nodeID != "" { if node, err := s.GetNode(nodeID); err == nil { nodeRegion = node.Region } } sites := eng.GetAllSites() var assigned []models.Site for _, site := range sites { if site.Paused || site.Type == "push" || site.Type == "group" { continue } if site.Regions != "" && nodeRegion != "" { matched := false for _, r := range strings.Split(site.Regions, ",") { if strings.TrimSpace(r) == nodeRegion { matched = true break } } if !matched { continue } } assigned = append(assigned, site) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck }) // 8. Probe Result Submission mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var req struct { NodeID string `json:"node_id"` Results []struct { SiteID int `json:"site_id"` LatencyNs int64 `json:"latency_ns"` IsUp bool `json:"is_up"` } `json:"results"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.NodeID == "" { http.Error(w, "node_id is required", http.StatusBadRequest) return } for _, result := range req.Results { if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil { log.Printf("Failed to save probe result: %v", err) } eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp) } if err := s.UpdateNodeLastSeen(req.NodeID); err != nil { log.Printf("Failed to update node last seen: %v", err) } _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck }) // 9. Prometheus Metrics mux.HandleFunc("/metrics", metrics.Handler(eng)) // 10. Status Page if cfg.EnableStatus { mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }) mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { state := eng.GetLiveState() activeWindows, _ := s.GetActiveMaintenanceWindows() maintSet := make(map[int]bool) allInMaint := false for _, mw := range activeWindows { if mw.Type != "maintenance" { continue } if mw.MonitorID == 0 { allInMaint = true } else { maintSet[mw.MonitorID] = true } } for id, site := range state { site.Token = "" if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) { site.Status = "MAINT" } state[id] = site } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(state) //nolint:errcheck }) } addr := fmt.Sprintf(":%d", cfg.Port) srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} go func() { fmt.Printf("HTTP Server listening on %s\n", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Printf("HTTP server error: %v", err) } }() return srv } func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) { sites := eng.GetAllSites() sort.Slice(sites, func(i, j int) bool { if sites[i].Status != sites[j].Status { if sites[i].Status == "DOWN" { return true } if sites[j].Status == "DOWN" { return false } } return sites[i].Name < sites[j].Name }) data := struct { Title string Sites []models.Site }{Title: title, Sites: sites} if err := statusTpl.Execute(w, data); err != nil { log.Printf("Failed to render status page: %v", err) } }