Files
go-otel/cmd/server/main.go
T
developer 36f00d49b9
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m3s
CSP violation reports: ingest endpoint, ClickHouse storage, UI view
- POST /v1/csp collects browser CSP reports in both wire formats (legacy
  application/csp-report and Reporting API application/reports+json), with
  CORS limited to the operator's domains (any subdomain of k6n.net,
  tornberg.me, knatofs.se) and preflight handling.
- Reports are stored in a dedicated typed ClickHouse table (csp_reports,
  90-day TTL) with a JSONL file fallback (OTEL_CSP_PATH).
- New authenticated view endpoints /api/csp/reports and /api/csp/summary
  plus a 'CSP reports' screen in the UI (tab 12) with time window, site and
  directive filters and report drill-down.
- Documented in instrumentation.md; unit tests for parsing/handlers/file
  store and an env-gated ClickHouse integration test.
2026-09-23 16:50:43 +00:00

616 lines
25 KiB
Go

// Package main implements the Go OTel Debugger server.
package main
import (
"context"
"log"
"net"
"net/http"
"net/http/pprof"
"os"
"path/filepath"
"strings"
"git.tornberg.me/go-otel/internal/abuseipdb"
"git.tornberg.me/go-otel/internal/alerts"
"git.tornberg.me/go-otel/internal/auth"
"git.tornberg.me/go-otel/internal/blocker"
"git.tornberg.me/go-otel/internal/collector"
"git.tornberg.me/go-otel/internal/geoip"
"git.tornberg.me/go-otel/internal/honeypot"
"git.tornberg.me/go-otel/internal/investigation"
"git.tornberg.me/go-otel/internal/issues"
kube "git.tornberg.me/go-otel/internal/kubernetes"
"git.tornberg.me/go-otel/internal/prometheus"
"git.tornberg.me/go-otel/internal/sourcemaps"
"git.tornberg.me/go-otel/internal/storage"
"git.tornberg.me/go-otel/internal/todos"
"git.tornberg.me/go-otel/internal/topology"
"git.tornberg.me/go-otel/internal/websocket"
"go.opentelemetry.io/collector/pdata/plog"
"go.opentelemetry.io/collector/pdata/pmetric"
"go.opentelemetry.io/collector/pdata/ptrace"
logsgrpc "go.opentelemetry.io/proto/otlp/collector/logs/v1"
metricsgrpc "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
tracegrpc "go.opentelemetry.io/proto/otlp/collector/trace/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/stats"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
// gRPC Logging Interceptor
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req)
if err != nil {
st, _ := status.FromError(err)
log.Printf("gRPC ERROR [%s] code=%s message=%s", info.FullMethod, st.Code(), st.Message())
}
//else {
// log.Printf("gRPC SUCCESS [%s]", info.FullMethod)
//}
return resp, err
}
// gRPC Connection Stats Handler
type connStatsHandler struct {
stats.Handler
}
func (h *connStatsHandler) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context {
log.Printf("gRPC CONN [OPEN] from=%s", info.RemoteAddr)
return ctx
}
func (h *connStatsHandler) HandleConn(ctx context.Context, s stats.ConnStats) {
if _, ok := s.(*stats.ConnEnd); ok {
log.Printf("gRPC CONN [CLOSED]")
}
}
func (h *connStatsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context {
return ctx
}
func (h *connStatsHandler) HandleRPC(ctx context.Context, s stats.RPCStats) {}
type traceService struct {
tracegrpc.UnimplementedTraceServiceServer
collector *collector.Collector
}
func (s *traceService) Export(ctx context.Context, req *tracegrpc.ExportTraceServiceRequest) (*tracegrpc.ExportTraceServiceResponse, error) {
// log.Printf("Received gRPC traces %d", len(req.ResourceSpans))
// Convert protobuf to bytes then to pdata
data, err := proto.Marshal(req)
if err != nil {
log.Printf("failed to marshal trace request: %v", err)
return nil, err
}
unmarshaler := &ptrace.ProtoUnmarshaler{}
traces, err := unmarshaler.UnmarshalTraces(data)
if err != nil {
log.Printf("failed to unmarshal traces: %v", err)
return nil, err
}
if err := s.collector.ConsumeTraces(ctx, traces); err != nil {
log.Printf("failed to consume traces: %v", err)
return nil, err
}
return &tracegrpc.ExportTraceServiceResponse{}, nil
}
type metricsService struct {
metricsgrpc.UnimplementedMetricsServiceServer
collector *collector.Collector
}
func (s *metricsService) Export(ctx context.Context, req *metricsgrpc.ExportMetricsServiceRequest) (*metricsgrpc.ExportMetricsServiceResponse, error) {
// log.Printf("Received gRPC metrics %d", len(req.ResourceMetrics))
// Convert protobuf to bytes then to pdata
data, err := proto.Marshal(req)
if err != nil {
log.Printf("failed to marshal metrics request: %v", err)
return nil, err
}
unmarshaler := &pmetric.ProtoUnmarshaler{}
metrics, err := unmarshaler.UnmarshalMetrics(data)
if err != nil {
log.Printf("failed to unmarshal metrics: %v", err)
return nil, err
}
if err := s.collector.ConsumeMetrics(ctx, metrics); err != nil {
log.Printf("failed to consume metrics: %v", err)
return nil, err
}
return &metricsgrpc.ExportMetricsServiceResponse{}, nil
}
type logsService struct {
logsgrpc.UnimplementedLogsServiceServer
collector *collector.Collector
}
func (s *logsService) Export(ctx context.Context, req *logsgrpc.ExportLogsServiceRequest) (*logsgrpc.ExportLogsServiceResponse, error) {
// log.Printf("Received gRPC logs %d", len(req.ResourceLogs))
// Convert protobuf to bytes then to pdata
data, err := proto.Marshal(req)
if err != nil {
log.Printf("failed to marshal logs request: %v", err)
return nil, err
}
unmarshaler := &plog.ProtoUnmarshaler{}
logs, err := unmarshaler.UnmarshalLogs(data)
if err != nil {
log.Printf("failed to unmarshal logs: %v", err)
return nil, err
}
if err := s.collector.ConsumeLogs(ctx, logs); err != nil {
log.Printf("failed to consume logs: %v", err)
return nil, err
}
return &logsgrpc.ExportLogsServiceResponse{}, nil
}
// withCORS allows cross-origin ingestion (e.g. from the otel-rum.js browser
// script running on a third-party site like event-agent.k6n.net) into the
// OTLP HTTP endpoints. It is deliberately permissive on origin and headers,
// scoped by the ingest key/rate limiting instead of same-origin policy.
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
reqHeaders := r.Header.Get("Access-Control-Request-Headers")
if reqHeaders != "" {
w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
} else {
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Otel-Ingest-Key, Authorization, Baggage, Traceparent, Tracestate, X-Requested-With")
}
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
// reportingOriginSuffixes are the registrable domains allowed to post browser
// reports (CSP violations) cross-origin: any subdomain of these, plus the
// bare domains themselves.
var reportingOriginSuffixes = []string{"k6n.net", "tornberg.me", "knatofs.se"}
// reportingOriginAllowed reports whether an Origin header is one of the
// operator's own sites (any subdomain of reportingOriginSuffixes). Anything
// else gets no CORS grant; browsers then block the report delivery.
func reportingOriginAllowed(origin string) bool {
host := storage.URLLowerHost(origin)
if host == "" {
return false
}
for _, suffix := range reportingOriginSuffixes {
if host == suffix || strings.HasSuffix(host, "."+suffix) {
return true
}
}
return false
}
// withReportingCORS gates the browser report ingest endpoints on the
// operator's own domains instead of "*". CSP report delivery is a
// non-simple CORS request (application/csp-report / application/reports+json),
// so preflights must be answered here too.
func withReportingCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// The response depends on Origin even when it is refused, so caches
// never serve one site's grant to another.
w.Header().Add("Vary", "Origin")
if origin := r.Header.Get("Origin"); reportingOriginAllowed(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Max-Age", "86400")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
// dataPath resolves a small state file: the env override, else next to the
// telemetry log (a mounted volume in the cluster), else the working directory.
func dataPath(env, name string) string {
if p := os.Getenv(env); p != "" {
return p
}
if lp := os.Getenv("OTELLOG_PATH"); lp != "" {
return filepath.Join(filepath.Dir(lp), name)
}
return name
}
// setupStore returns the telemetry store: ClickHouse when OTEL_CLICKHOUSE_ADDR
// is set (host:port, native protocol), otherwise the JSONL file store.
func setupStore(ctx context.Context) storage.Store {
addr := os.Getenv("OTEL_CLICKHOUSE_ADDR")
if addr == "" {
logPath := os.Getenv("OTELLOG_PATH")
if logPath == "" {
logPath = "otel-agent.log"
}
log.Printf("storage: file store at %s (set OTEL_CLICKHOUSE_ADDR for ClickHouse)", logPath)
return storage.NewFileStore(logPath)
}
chStore, err := storage.NewClickHouseStore(ctx, storage.ClickHouseConfig{
Addr: addr,
Database: os.Getenv("OTEL_CLICKHOUSE_DB"),
Username: os.Getenv("OTEL_CLICKHOUSE_USER"),
Password: os.Getenv("OTEL_CLICKHOUSE_PASSWORD"),
})
if err != nil {
log.Printf("storage: ClickHouse unavailable (%v), falling back to file store", err)
logPath := os.Getenv("OTELLOG_PATH")
if logPath == "" {
logPath = "otel-agent.log"
}
return storage.NewFileStore(logPath)
}
log.Printf("storage: ClickHouse at %s", addr)
return chStore
}
func main() {
// Keep profiling off the public UI/API listener. The dedicated listener is
// bound to loopback by default; set OTEL_PPROF_ADDR to expose it through a
// port-forward or to bind it elsewhere when diagnosing a deployment.
pprofAddr := os.Getenv("OTEL_PPROF_ADDR")
if pprofAddr == "" {
pprofAddr = "127.0.0.1:6060"
}
go startPprofServer(pprofAddr)
// Configure history size (default 100, can be made configurable)
historySize := 100
store := setupStore(context.Background())
coll := collector.New(nil, historySize, store) // Temporary nil for wsServer
if occurrenceStore, ok := store.(storage.OccurrenceStore); ok {
occurrences, err := occurrenceStore.RecentOccurrences(context.Background(), 100000)
if err != nil {
log.Printf("issues: persisted index restore skipped: %v", err)
} else if n, err := coll.RestoreIssues(context.Background(), occurrences); err != nil {
log.Printf("issues: persisted index restore failed after %d occurrences: %v", n, err)
} else if len(occurrences) > 0 {
log.Printf("issues: restored %d persisted occurrences", n)
}
}
kubeWatcher, kubeErr := kube.NewFromEnv(context.Background())
if kubeErr != nil {
log.Printf("kubernetes: rollout watcher disabled: %v", kubeErr)
} else if kubeWatcher != nil {
coll.SetKubernetesWatcher(kubeWatcher)
log.Printf("kubernetes: rollout watcher enabled")
}
topo, topoErr := topology.NewFromEnv(context.Background())
if topoErr != nil {
log.Printf("topology: ingress registry disabled: %v", topoErr)
}
coll.SetPrometheusClient(prometheus.NewFromEnv())
sourceMaps, err := sourcemaps.New(os.Getenv("OTEL_SOURCEMAP_DIR"))
if err != nil {
log.Fatalf("failed to initialize source maps: %v", err)
}
coll.SetSourceMaps(sourceMaps)
// Aggregate stats endpoints are enabled only for stores that support
// them (ClickHouse); they 501 politely on the file store.
if sq, ok := store.(storage.StatsQuerier); ok {
coll.SetStatsQuerier(sq)
}
if sq, ok := store.(storage.SourceIPQuerier); ok {
coll.SetSourceIPQuerier(sq)
}
wsServer := websocket.New(coll)
honeypotTracker := honeypot.New(honeypot.ConfigFromEnv())
coll.SetHoneypotRecorder(honeypotTracker)
// Optional ingress blocker: off unless OTEL_BLOCKER_ENABLED=true, and
// dry-run unless OTEL_BLOCKER_DRY_RUN=false.
ipBlocker, err := blocker.Start(context.Background(), blocker.ConfigFromEnv(), honeypotTracker)
if err != nil {
log.Printf("ip blocker disabled: %v", err)
}
// Optional spike alerts (Telegram / webhook): off unless a channel is
// configured or OTEL_ALERT_DRY_RUN=true.
var alertRunner *alerts.Runner
if chStore, ok := store.(*storage.ClickHouseStore); ok {
alertRunner = alerts.Start(context.Background(), alertConfig(coll), chStore.Conn(), chStore.Database(), topo)
}
// Update collector with wsServer
coll.SetBroadcaster(wsServer)
// Optional MaxMind databases for the UI's IP details dialog.
geo, err := geoip.Open(os.Getenv("OTEL_GEOIP_CITY_DB"), os.Getenv("OTEL_GEOIP_ASN_DB"))
if err != nil {
log.Fatalf("failed to open geoip database: %v", err)
}
defer geo.Close()
var investigationStore investigation.Store
if chStore, ok := store.(*storage.ClickHouseStore); ok {
if st, err := investigation.NewClickHouseStore(context.Background(), chStore.Conn(), chStore.Database()); err != nil {
log.Printf("ip investigation results will not be persisted: %v", err)
} else {
investigationStore = st
}
}
var todoStore todos.Store
if chStore, ok := store.(*storage.ClickHouseStore); ok {
if st, err := todos.NewClickHouseStore(context.Background(), chStore.Conn(), chStore.Database()); err != nil {
log.Printf("todos: clickhouse unavailable, using file store: %v", err)
} else {
todoStore = st
}
}
if todoStore == nil {
if st, err := todos.NewFileStore(dataPath("OTEL_TODOS_PATH", "otel-todos.jsonl")); err != nil {
log.Printf("todos disabled: %v", err)
} else {
todoStore = st
}
}
if todoStore != nil {
coll.SetTodoStore(todoStore)
}
// CSP violation reports: the ClickHouse store keeps a dedicated typed
// table; without ClickHouse, fall back to a JSONL file next to the
// telemetry log so reports still land.
if cspStore, ok := store.(storage.CSPStore); ok {
coll.SetCSPStore(cspStore)
} else if st, err := storage.NewCSPFileStore(dataPath("OTEL_CSP_PATH", "otel-csp-reports.jsonl")); err != nil {
log.Printf("csp reports disabled: %v", err)
} else {
coll.SetCSPStore(st)
log.Printf("csp reports: file store (set OTEL_CLICKHOUSE_ADDR for ClickHouse)")
}
var statusStore issues.StatusStore
if chStore, ok := store.(*storage.ClickHouseStore); ok {
if st, err := issues.NewClickHouseStatusStore(context.Background(), chStore.Conn(), chStore.Database()); err != nil {
log.Printf("issue status: clickhouse unavailable, using file store: %v", err)
} else {
statusStore = st
}
}
if statusStore == nil {
if st, err := issues.NewFileStatusStore(dataPath("OTEL_ISSUE_STATUS_PATH", "otel-issue-status.json")); err != nil {
log.Printf("issue status will not be persisted: %v", err)
} else {
statusStore = st
}
}
if statusStore != nil {
if err := coll.SetIssueStatusStore(context.Background(), statusStore); err != nil {
log.Printf("failed to load issue status: %v", err)
}
}
investigator := investigation.NewHandler(investigation.ConfigFromEnv(), investigationStore)
abuse := abuseipdb.NewHandler(abuseipdb.ConfigFromEnv())
ingestKey := os.Getenv("OTEL_INGEST_KEY")
coll.SetIngestKey(ingestKey)
coll.SetSourceMapKey(os.Getenv("OTEL_SOURCEMAP_KEY"))
oidcAuth := auth.NewOidcAuth(ingestKey)
log.Printf("auth: OIDC issuer=%s client=%s disabled=%v", oidcAuth.Issuer(), oidcAuth.ClientID(), oidcAuth.IsDisabled())
// Start gRPC server
go func() {
lis, err := net.Listen("tcp", ":4317")
if err != nil {
log.Fatal("failed to listen for gRPC", err)
}
s := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
grpc.StatsHandler(&connStatsHandler{}),
)
tracegrpc.RegisterTraceServiceServer(s, &traceService{collector: coll})
metricsgrpc.RegisterMetricsServiceServer(s, &metricsService{collector: coll})
logsgrpc.RegisterLogsServiceServer(s, &logsService{collector: coll})
log.Print("Starting gRPC server on port 4317")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve gRPC: %v", err)
}
}()
// OTLP HTTP mux (standard OTel port 4318)
otelMux := http.NewServeMux()
otelMux.HandleFunc("/v1/traces", withCORS(coll.HandleTraces))
otelMux.HandleFunc("/v1/traces/", withCORS(coll.HandleTraces))
otelMux.HandleFunc("/v1/metrics", withCORS(coll.HandleMetrics))
otelMux.HandleFunc("/v1/metrics/", withCORS(coll.HandleMetrics))
otelMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs))
otelMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs))
otelMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals))
otelMux.HandleFunc("/v1/csp", withReportingCORS(coll.HandleCSPIngest))
otelMux.HandleFunc("/v1/csp/", withReportingCORS(coll.HandleCSPIngest))
otelMux.HandleFunc("/v1/todos", withCORS(coll.HandleTodoCreate))
otelMux.HandleFunc("/health", coll.HandleHealth)
// UI and API mux (port 8080)
uiMux := http.NewServeMux()
// Also register OTLP endpoints on 8080 for backwards compatibility.
// CORS is enabled here since this is the publicly exposed port (via the
// ingress) that a browser script such as static/otel-rum.js posts to.
uiMux.HandleFunc("/v1/traces", withCORS(coll.HandleTraces))
uiMux.HandleFunc("/v1/traces/", withCORS(coll.HandleTraces))
uiMux.HandleFunc("/v1/metrics", withCORS(coll.HandleMetrics))
uiMux.HandleFunc("/v1/metrics/", withCORS(coll.HandleMetrics))
uiMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs))
uiMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs))
uiMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals))
uiMux.HandleFunc("/v1/todos", withCORS(coll.HandleTodoCreate))
uiMux.HandleFunc("/v1/csp", withReportingCORS(coll.HandleCSPIngest))
uiMux.HandleFunc("/v1/csp/", withReportingCORS(coll.HandleCSPIngest))
uiMux.HandleFunc("/health", coll.HandleHealth)
// CSP report views (read side) sit with the other protected API routes.
uiMux.Handle("GET /api/csp/reports", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleCSPReports)))
uiMux.Handle("GET /api/csp/summary", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleCSPSummary)))
uiMux.HandleFunc("GET /internal/honeypot/ips", honeypotTracker.HandleIPs)
// Public OIDC endpoints
uiMux.HandleFunc("POST /api/auth/token", oidcAuth.HandleTokenExchange)
uiMux.HandleFunc("GET /api/auth/config", oidcAuth.HandleConfig)
uiMux.Handle("GET /api/ip/{ip}", oidcAuth.RequireAuth(http.HandlerFunc(geo.Handler)))
uiMux.Handle("POST /api/source-ips/{ip}/investigate", oidcAuth.RequireAuth(http.HandlerFunc(investigator.Start)))
uiMux.Handle("GET /api/source-ips/{ip}/investigation", oidcAuth.RequireAuth(http.HandlerFunc(investigator.Get)))
uiMux.Handle("GET /api/source-ips/{ip}/abuseipdb", oidcAuth.RequireAuth(http.HandlerFunc(abuse.Check)))
uiMux.Handle("POST /api/source-ips/{ip}/abuseipdb/report", oidcAuth.RequireAuth(http.HandlerFunc(abuse.Report)))
uiMux.Handle("/api/honeypot/ips", oidcAuth.RequireAuth(http.HandlerFunc(honeypotTracker.HandleIPsForUser)))
if alertRunner != nil {
uiMux.Handle("GET /api/alerts", oidcAuth.RequireAuth(http.HandlerFunc(alertRunner.HandleStatus)))
uiMux.Handle("POST /api/alerts/test", oidcAuth.RequireAuth(http.HandlerFunc(alertRunner.HandleTest)))
}
if ipBlocker != nil {
uiMux.Handle("GET /api/blocker/status", oidcAuth.RequireAuth(http.HandlerFunc(ipBlocker.HandleStatus)))
uiMux.Handle("POST /api/blocker/ips/{ip}", oidcAuth.RequireAuth(http.HandlerFunc(ipBlocker.HandleAdd)))
}
// Source-map uploads are intentionally public so the release build script
// can upload maps without an OIDC session or bearer token.
uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap)
uiMux.Handle("GET /api/sourcemaps", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleListSourceMaps)))
uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks)))
uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus)
uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify)
// Protected API & WebSocket routes
uiMux.Handle("/api/history", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleHistory)))
uiMux.Handle("/api/history/traces", oidcAuth.RequireAuth(coll.HandleHistoryByType("traces")))
uiMux.Handle("/api/history/metrics", oidcAuth.RequireAuth(coll.HandleHistoryByType("metrics")))
uiMux.Handle("/api/history/logs", oidcAuth.RequireAuth(coll.HandleHistoryByType("logs")))
uiMux.Handle("/api/history/search", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSearch)))
uiMux.Handle("/api/history/wait", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleWait)))
uiMux.Handle("GET /api/traces/summaries", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTraceSummaries)))
uiMux.Handle("GET /api/issues/{id}/traces", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueTraces)))
uiMux.Handle("GET /api/issues/{id}/sources", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueSources)))
uiMux.Handle("GET /api/traces/{traceId}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTraceByID)))
uiMux.Handle("/api/issues", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssues)))
uiMux.Handle("POST /api/ml/test-spike", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleMLDetectorTest)))
uiMux.Handle("GET /api/issues/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueByID)))
uiMux.Handle("PATCH /api/issues/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueByID)))
uiMux.Handle("GET /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueSimilar)))
uiMux.Handle("POST /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSimilarityFeedback)))
uiMux.Handle("GET /api/todos", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTodoList)))
uiMux.Handle("PATCH /api/todos/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTodoUpdate)))
uiMux.Handle("GET /api/patterns", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandlePatterns)))
uiMux.Handle("GET /api/topology", oidcAuth.RequireAuth(http.HandlerFunc(topo.HandleTopology)))
uiMux.Handle("GET /api/rollouts", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRollouts)))
uiMux.Handle("GET /api/rollouts/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRolloutByID)))
uiMux.Handle("GET /api/rollouts/{id}/impact", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRolloutImpact)))
// Aggregate stats (ClickHouse-backed); return 501 with an explanatory
// JSON body when only the file store is configured.
uiMux.Handle("GET /api/source-ips", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSourceIPs)))
uiMux.Handle("GET /api/source-ips/{ip}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSourceIPActivity)))
uiMux.Handle("/api/stats/errors", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleStatsErrors)))
uiMux.Handle("/api/stats/releases", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleStatsReleases)))
uiMux.Handle("/api/stats/services", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleStatsServices)))
uiMux.Handle("/api/stats/endpoints", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleStatsEndpoints)))
uiMux.HandleFunc("/ws", wsServer.HandleConnections)
uiMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// Serve the agent instructions doc as a static route so an AI agent can
// fetch it directly from the base URL before making any API calls.
uiMux.HandleFunc("GET /agents_example.md", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "agents_example.md")
})
uiMux.HandleFunc("GET /instrumentation.md", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "instrumentation.md")
})
uiMux.HandleFunc("GET /static/instrumentation.md", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "instrumentation.md")
})
distDir := "./dist"
if _, err := os.Stat(distDir); os.IsNotExist(err) {
if _, err := os.Stat("./otel-ui/dist"); err == nil {
distDir = "./otel-ui/dist"
} else if _, err := os.Stat("./index.html"); err == nil {
// Development fallback: serve the checked-in visualizer when no
// separately built frontend exists.
distDir = "."
}
}
uiMux.Handle("/", spaHandler(http.Dir(distDir)))
log.Printf("Starting HTTP server on port 8080 (UI/API)")
log.Printf("OTLP HTTP endpoints (also on port 8080):")
log.Printf(" Traces: http://localhost:8080/v1/traces")
log.Printf(" Metrics: http://localhost:8080/v1/metrics")
log.Printf(" Logs: http://localhost:8080/v1/logs")
log.Printf(" Vitals: http://localhost:8080/v1/vitals (Web Vitals beacons)")
log.Printf("OTLP gRPC endpoint:")
log.Printf(" Traces/Metrics/Logs: localhost:4317")
log.Printf("WebSocket: ws://localhost:8080/ws")
// Start OTLP HTTP server on port 4318
go func() {
log.Printf("Starting OTLP HTTP server on port 4318")
if err := http.ListenAndServe(":4318", otelMux); err != nil {
log.Printf("failed to start OTLP HTTP server: %v", err)
}
}()
httpPort := os.Getenv("PORT")
if httpPort == "" {
httpPort = "8080"
}
uiHandler := honeypotTracker.Middleware(uiMux)
if err := http.ListenAndServe(":"+httpPort, uiHandler); err != nil {
log.Fatalf("failed to start HTTP server on :%s: %v", httpPort, err)
}
}
func startPprofServer(addr string) {
mux := http.NewServeMux()
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
log.Printf("Starting pprof server on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Printf("pprof server stopped: %v", err)
}
}
func spaHandler(root http.FileSystem) http.Handler {
fileServer := http.FileServer(root)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Skip API, WebSocket, and OTLP endpoints
if !strings.HasPrefix(path, "/api") && !strings.HasPrefix(path, "/ws") && !strings.HasPrefix(path, "/v1") {
if _, err := root.Open(path); err != nil {
// File does not exist, serve index.html
r.URL.Path = "/"
}
}
fileServer.ServeHTTP(w, r)
})
}
// alertConfig is the env-driven alert config plus muting of ignored issues.
func alertConfig(coll *collector.Collector) alerts.Config {
cfg := alerts.ConfigFromEnv()
cfg.Muted = coll.IsIssueIgnored
return cfg
}