package server import ( "crypto/subtle" "encoding/json" "fmt" "html/template" "log/slog" "net" "net/http" "sort" "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/importer" "gitea.lerkolabs.com/lerkolabs/uptop/internal/metrics" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store" ) const maxRequestBody = 1 << 20 type ServerConfig struct { Port int EnableStatus bool Title string ClusterKey string TLSCert string TLSKey string ClusterMode string MetricsPublic bool CORSOrigin string TrustedProxies []*net.IPNet QuietHTTPLog bool } type Server struct { cfg ServerConfig store store.Store eng *monitor.Engine pushRL *RateLimiter probeRL *RateLimiter backupRL *RateLimiter statusRL *RateLimiter } func NewServer(cfg ServerConfig, s store.Store, eng *monitor.Engine) *Server { return &Server{ cfg: cfg, store: s, eng: eng, pushRL: NewRateLimiter(60, cfg.TrustedProxies), probeRL: NewRateLimiter(30, cfg.TrustedProxies), backupRL: NewRateLimiter(10, cfg.TrustedProxies), statusRL: NewRateLimiter(120, cfg.TrustedProxies), } } func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { srv := NewServer(cfg, s, eng) return srv.Start() } func (s *Server) Start() *http.Server { if s.cfg.ClusterKey == "" { slog.Warn("no UPTOP_CLUSTER_SECRET set, cluster API endpoints will reject all requests") } if s.cfg.ClusterMode != "" && s.cfg.ClusterMode != "leader" && s.cfg.TLSCert == "" { slog.Warn("cluster mode active without TLS, secrets transmitted in cleartext") } handler := s.routes() addr := fmt.Sprintf(":%d", s.cfg.Port) httpSrv := &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 s.cfg.TLSCert != "" && s.cfg.TLSKey != "" { slog.Info("HTTPS server listening", "addr", addr) if err := httpSrv.ListenAndServeTLS(s.cfg.TLSCert, s.cfg.TLSKey); err != nil && err != http.ErrServerClosed { slog.Error("HTTPS server failed", "err", err) } } else { slog.Info("HTTP server listening", "addr", addr) if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("HTTP server failed", "err", err) } } }() return httpSrv } func (s *Server) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/push", RateLimit(s.pushRL, s.handlePush)) mux.HandleFunc("/api/health", s.handleHealth) mux.HandleFunc("/api/backup/export", RateLimit(s.backupRL, s.handleExport)) mux.HandleFunc("/api/backup/import", RateLimit(s.backupRL, s.handleImport)) mux.HandleFunc("/api/import/kuma", RateLimit(s.backupRL, s.handleKumaImport)) mux.HandleFunc("/api/probe/register", RateLimit(s.probeRL, s.handleProbeRegister)) mux.HandleFunc("/api/probe/assignments", RateLimit(s.probeRL, s.handleProbeAssignments)) mux.HandleFunc("/api/probe/results", RateLimit(s.probeRL, s.handleProbeResults)) mux.HandleFunc("/metrics", s.handleMetrics) if s.cfg.EnableStatus { mux.HandleFunc("/status", RateLimit(s.statusRL, s.handleStatus)) mux.HandleFunc("/status/json", RateLimit(s.statusRL, s.handleStatusJSON)) } handler := securityHeadersMiddleware(mux) if !s.cfg.QuietHTTPLog { handler = loggingMiddleware(s.cfg.TrustedProxies, handler) } if s.cfg.TLSCert != "" { handler = hstsMiddleware(handler) } return handler } func (s *Server) requireAuth(r *http.Request) bool { return s.cfg.ClusterKey != "" && checkSecret(r.Header.Get("X-Upkeep-Secret"), s.cfg.ClusterKey) } func (s *Server) handlePush(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 slog.Warn("push token in query string is deprecated, use Authorization: Bearer header") } } if token == "" { http.Error(w, "Missing token", http.StatusBadRequest) return } if s.eng.RecordHeartbeat(token) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } else { http.Error(w, "Invalid Token", http.StatusNotFound) } } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), s.cfg.ClusterKey) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !s.requireAuth(r) { http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized) return } data, err := s.store.ExportData(r.Context()) if err != nil { slog.Error("export failed", "err", 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 = models.RedactAlertSettings(data.Alerts[i].Type, data.Alerts[i].Settings) } } _ = json.NewEncoder(w).Encode(data) //nolint:errcheck } func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if !s.requireAuth(r) { 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.store.ImportData(r.Context(), data); err != nil { slog.Error("import failed", "err", err) http.Error(w, "Import failed", http.StatusInternalServerError) return } _, _ = w.Write([]byte("Import Successful")) } func (s *Server) handleKumaImport(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if !s.requireAuth(r) { 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 { slog.Error("invalid Kuma JSON", "err", err) http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest) return } backup := importer.ConvertKuma(&kb) if err := s.store.ImportData(r.Context(), backup); err != nil { slog.Error("Kuma import failed", "err", 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) } func (s *Server) handleProbeRegister(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if !s.requireAuth(r) { 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.store.RegisterNode(r.Context(), models.ProbeNode{ ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version, }); err != nil { slog.Error("probe registration failed", "err", err) http.Error(w, "Registration failed", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck } func (s *Server) handleProbeAssignments(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !s.requireAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } nodeID := r.URL.Query().Get("node_id") var nodeRegion string if nodeID != "" { if node, err := s.store.GetNode(r.Context(), nodeID); err == nil { nodeRegion = node.Region } } sites := s.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 _, reg := range strings.Split(site.Regions, ",") { if strings.TrimSpace(reg) == 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 } func (s *Server) handleProbeResults(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } if !s.requireAuth(r) { 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"` ErrorReason string `json:"error_reason"` } `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 { s.eng.EnqueueProbeCheck(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp) s.eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp, result.ErrorReason) } if err := s.store.UpdateNodeLastSeen(r.Context(), req.NodeID); err != nil { slog.Error("node last-seen update failed", "err", err) } _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck } func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !s.cfg.MetricsPublic { if !s.requireAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } metrics.Handler(s.eng)(w, r) } func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { renderStatusPage(w, s.cfg.Title, s.eng) } func (s *Server) handleStatusJSON(w http.ResponseWriter, r *http.Request) { state := s.eng.GetLiveState() activeWindows, _ := s.store.GetActiveMaintenanceWindows(r.Context()) 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 } } public := make(map[int]statusSite, len(state)) for id, site := range state { displayStatus := string(site.Status) if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) { displayStatus = "MAINT" } public[id] = statusSite{ Name: site.Name, Type: site.Type, URL: site.URL, Status: displayStatus, Paused: site.Paused, LastCheck: site.LastCheck, Latency: site.Latency, } } if s.cfg.CORSOrigin != "" { w.Header().Set("Access-Control-Allow-Origin", s.cfg.CORSOrigin) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(public) //nolint:errcheck } // --- Helpers --- 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 "" } // statusSite is the public DTO for /status/json. type statusSite struct { Name string Type string URL string Status string Paused bool LastCheck time.Time Latency time.Duration } // --- Middleware --- type statusWriter struct { http.ResponseWriter code int } func (w *statusWriter) WriteHeader(code int) { w.code = code w.ResponseWriter.WriteHeader(code) } func loggingMiddleware(trusted []*net.IPNet, 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", "") slog.Info("http request", "method", r.Method, "path", path, "status", sw.code, "duration", time.Since(start).Round(time.Millisecond), "ip", clientIP(r, trusted)) //nolint:gosec // structured slog, not format string }) } 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 == models.StatusDown { return true } if sites[j].Status == models.StatusDown { 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 { slog.Error("status page render failed", "err", err) } } var statusTpl = template.Must(template.New("status").Parse(` {{.Title}}

{{.Title}}

Powered by uptop
`))