package server import ( "crypto/subtle" "encoding/json" "fmt" "html/template" "log" "net/http" "sort" "strings" "time" "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" ) const maxRequestBody = 1 << 20 func checkSecret(got, want string) bool { return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 } func extractBearerToken(r *http.Request) string { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { return strings.TrimPrefix(auth, "Bearer ") } return "" } var sensitiveKeys = map[string]bool{ "pass": true, "password": true, "token": true, "routing_key": true, "user": true, "username": true, } func redactSettings(settings map[string]string) map[string]string { redacted := make(map[string]string, len(settings)) for k, v := range settings { if sensitiveKeys[k] && v != "" { redacted[k] = "***REDACTED***" } else { redacted[k] = v } } return redacted } var statusTpl = template.Must(template.New("status").Parse(` {{.Title}}

{{.Title}}

Powered by uptop
`)) type ServerConfig struct { Port int EnableStatus bool Title string ClusterKey string TLSCert string TLSKey string ClusterMode string MetricsPublic bool CORSOrigin string } 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.") } pushRL := NewRateLimiter(60) probeRL := NewRateLimiter(30) backupRL := NewRateLimiter(10) statusRL := NewRateLimiter(120) mux := http.NewServeMux() // 1. Push Heartbeat mux.HandleFunc("/api/push", RateLimit(pushRL, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } token := extractBearerToken(r) if token == "" { if qt := r.URL.Query().Get("token"); qt != "" { token = qt log.Printf("DEPRECATED: push token in query string โ€” use Authorization: Bearer header instead") } } 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 r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } 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", RateLimit(backupRL, 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 } if r.URL.Query().Get("redact_secrets") != "false" { for i := range data.Alerts { data.Alerts[i].Settings = redactSettings(data.Alerts[i].Settings) } } _ = json.NewEncoder(w).Encode(data) //nolint:errcheck })) // 4. Config Import mux.HandleFunc("/api/backup/import", RateLimit(backupRL, 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, maxRequestBody) 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", RateLimit(backupRL, 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, maxRequestBody) 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", RateLimit(probeRL, 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, maxRequestBody) 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", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } 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", RateLimit(probeRL, 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, maxRequestBody) 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", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !cfg.MetricsPublic && cfg.ClusterKey != "" { if !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } metrics.Handler(eng)(w, r) }) // 10. Status Page if cfg.EnableStatus { mux.HandleFunc("/status", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) })) mux.HandleFunc("/status/json", RateLimit(statusRL, 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 } if cfg.CORSOrigin != "" { w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(state) //nolint:errcheck })) } if cfg.ClusterMode != "" && cfg.ClusterMode != "leader" && cfg.TLSCert == "" { fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.") } handler := loggingMiddleware(securityHeadersMiddleware(mux)) if cfg.TLSCert != "" { handler = hstsMiddleware(handler) } addr := fmt.Sprintf(":%d", cfg.Port) srv := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } go func() { if cfg.TLSCert != "" && cfg.TLSKey != "" { fmt.Printf("HTTPS Server listening on %s\n", addr) if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil && err != http.ErrServerClosed { log.Printf("HTTPS server error: %v", err) } } else { 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 } type statusWriter struct { http.ResponseWriter code int } func (w *statusWriter) WriteHeader(code int) { w.code = code w.ResponseWriter.WriteHeader(code) } func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w, code: 200} next.ServeHTTP(sw, r) path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "") log.Printf("%s %s %d %s %s", r.Method, path, sw.code, time.Since(start).Round(time.Millisecond), clientIP(r)) //nolint:gosec // path sanitized above }) } func securityHeadersMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'") next.ServeHTTP(w, r) }) } func hstsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") next.ServeHTTP(w, r) }) } 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) } }