package server import ( "encoding/json" "fmt" "go-upkeep/internal/importer" "go-upkeep/internal/metrics" "go-upkeep/internal/models" "go-upkeep/internal/monitor" "go-upkeep/internal/store" "html/template" "log" "net/http" "sort" "strings" ) var statusTpl = template.Must(template.New("status").Parse(` {{.Title}}

{{.Title}}

Powered by Go-Upkeep
`)) 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) { if cfg.ClusterKey == "" { fmt.Println("WARNING: No UPKEEP_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", 400) return } if eng.RecordHeartbeat(token) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } else { http.Error(w, "Invalid Token", 404) } }) // 2. Health Check (For Cluster Follower) mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey != "" && r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized", 401) 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 == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) return } data, err := s.ExportData() if err != nil { log.Printf("Export failed: %v", err) http.Error(w, "Export failed", 500) return } json.NewEncoder(w).Encode(data) }) // 4. Config Import mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", 405) return } if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized", 401) return } var data models.Backup if err := json.NewDecoder(r.Body).Decode(&data); err != nil { http.Error(w, "Invalid JSON", 400) return } if err := s.ImportData(data); err != nil { log.Printf("Import failed: %v", err) http.Error(w, "Import failed", 500) 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", 405) return } if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized", 401) return } 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", 400) return } backup := importer.ConvertKuma(&kb) if err := s.ImportData(backup); err != nil { log.Printf("Kuma import failed: %v", err) http.Error(w, "Import failed", 500) return } w.Write([]byte(fmt.Sprintf("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", 405) return } if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized", 401) return } 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", 400) return } if req.ID == "" { http.Error(w, "id is required", 400) 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", 500) return } json.NewEncoder(w).Encode(map[string]bool{"ok": true}) }) // 7. Probe Assignment Fetch mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) { if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized", 401) 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}) }) // 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", 405) return } if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized", 401) return } 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", 400) return } if req.NodeID == "" { http.Error(w, "node_id is required", 400) 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) } s.UpdateNodeLastSeen(req.NodeID) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) }) // 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) }) } go func() { addr := fmt.Sprintf(":%d", cfg.Port) fmt.Printf("HTTP Server listening on %s\n", addr) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("HTTP server failed: %v", err) } }() } 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} statusTpl.Execute(w, data) }