package main import ( "context" "embed" "errors" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "blackbox/server/internal/auth" "blackbox/server/internal/db" "blackbox/server/internal/handlers" "blackbox/server/internal/hub" "blackbox/server/internal/incidents" bbmcp "blackbox/server/internal/mcp" "blackbox/server/internal/middleware" "blackbox/server/internal/models" "blackbox/server/internal/notify" "blackbox/server/internal/static" "blackbox/shared/timezone" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gorm.io/gorm" ) //go:embed web/dist var staticFiles embed.FS //go:embed docs/openapi.yaml var openapiSpec []byte var ( Version = "dev" Commit = "unknown" ) const defaultDBPath = "/data/blackbox.db" func main() { if len(os.Args) > 1 && os.Args[1] == "--health-check" { addr := getEnv("LISTEN_ADDR", ":8080") if strings.HasPrefix(addr, ":") { addr = "localhost" + addr } // Plain HTTP is intentional: TLS termination is expected at the ingress layer. // This check targets the container's loopback interface only. client := &http.Client{Timeout: 4 * time.Second} resp, err := client.Get("http://" + addr + "/api/setup/health") if err != nil { os.Exit(1) } _ = resp.Body.Close() if resp.StatusCode >= 400 { os.Exit(1) } os.Exit(0) } if tz, err := timezone.ConfigureLocal(); err != nil { log.Printf("timezone: invalid TZ %q: %v; using container default timezone", tz, err) } log.Printf("Blackbox Server %s (%s) starting", Version, Commit) rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() jwtSecret := os.Getenv("JWT_SECRET") if jwtSecret == "" { log.Fatal("JWT_SECRET environment variable is required") } agentConfig, err := middleware.NewAgentAuthConfig(os.Getenv("AGENT_TOKENS")) if err != nil { log.Fatalf("AGENT_TOKENS configuration error: %v", err) } webhookSecret := os.Getenv("WEBHOOK_SECRET") if webhookSecret == "" { log.Printf("WEBHOOK_SECRET not set; webhook sources must be configured in Admin > Data Sources before webhook auth will succeed") } dbPath := getEnv("DB_PATH", defaultDBPath) database, err := db.Init(dbPath) if err != nil { log.Fatalf("database init failed: %v", err) } log.Printf("database initialized at %s", dbPath) if err := handlers.MigrateDataSources(database, webhookSecret); err != nil { log.Fatalf("data source migration failed: %v", err) } handlers.PrimeWebhookSecretCache(database, "webhook_uptime_kuma", webhookSecret) handlers.PrimeWebhookSecretCache(database, "webhook_watchtower", webhookSecret) handlers.PrimeWebhookSecretsCache(database, "webhook_komodo") mcpMgr := bbmcp.NewMCPManager(database) defer func() { if err := mcpMgr.Shutdown(context.Background()); err != nil { log.Printf("mcp: shutdown failed: %v", err) } }() restoreMCPState(database, mcpMgr) db.StartOIDCStateSweeper(rootCtx, database) db.StartNotificationLogSweeper(rootCtx, database) eventHub := hub.New() notifier := notify.NewDispatcher(database) digestFlusher := notify.NewFlusher(database) go digestFlusher.Run(rootCtx) handlers.StartNodeStatusMonitor(rootCtx, database, eventHub, 0) incidentCh := incidents.NewChannel() incidentMgr := incidents.NewManager(database, eventHub, notifier) managerCtx, stopManager := context.WithCancel(context.Background()) defer stopManager() managerDone := make(chan struct{}) go func() { defer close(managerDone) incidentMgr.Run(managerCtx, incidentCh) }() registry := auth.NewOIDCRegistry(database) if os.Getenv("OIDC_ENABLED") == "true" { var providerCount int64 if err := database.Model(&models.OIDCProviderConfig{}).Count(&providerCount).Error; err != nil { log.Printf("failed to count OIDC providers for env migration: %v", err) } else if providerCount == 0 { issuer := strings.TrimSpace(os.Getenv("OIDC_ISSUER")) clientID := strings.TrimSpace(os.Getenv("OIDC_CLIENT_ID")) clientSecret := strings.TrimSpace(os.Getenv("OIDC_CLIENT_SECRET")) redirectURL := strings.TrimSpace(os.Getenv("OIDC_REDIRECT_URL")) if issuer == "" || clientID == "" || clientSecret == "" || redirectURL == "" { log.Printf("OIDC_ENABLED=true but one or more required env vars are missing; skipping OIDC provider seed") } else { provider := models.OIDCProviderConfig{ ID: ulid.Make().String(), Name: "SSO", Issuer: issuer, ClientID: clientID, ClientSecret: clientSecret, RedirectURL: redirectURL, RequireVerifiedEmail: models.BoolPtr(true), Enabled: models.BoolPtr(true), } if err := database.Create(&provider).Error; err != nil { log.Printf("failed to seed OIDC provider from env: %v", err) } else { log.Printf("OIDC_ENABLED env vars detected, seeded 'SSO' provider — migrate to admin UI to manage") } } } } go func() { const retryInterval = 60 * time.Second lastOIDCRegistryStatus := oidcRegistryStatusUnknown for attempt := 1; ; attempt++ { if err := rootCtx.Err(); err != nil { return } ctx, cancel := context.WithTimeout(rootCtx, 30*time.Second) err := registry.Reload(ctx) cancel() if err != nil { if errors.Is(err, context.Canceled) { return } log.Printf("OIDC registry reload attempt %d failed: %v", attempt, err) } else { providers, listErr := registry.ListEnabled() if listErr != nil { log.Printf("OIDC registry readiness check %d failed: %v", attempt, listErr) } else { currentOIDCRegistryStatus := oidcRegistryStatusFromProviders(providers, registry) if msg := oidcRegistryTransitionMessage(attempt, lastOIDCRegistryStatus, currentOIDCRegistryStatus); msg != "" { log.Print(msg) } lastOIDCRegistryStatus = currentOIDCRegistryStatus } } select { case <-rootCtx.Done(): return case <-time.After(retryInterval): } } }() r := chi.NewRouter() r.Use(middleware.SecurityHeaders()) r.Get("/api/setup/status", handlers.SetupStatus(database)) r.Get("/api/setup/health", handlers.HealthCheck(database, registry)) r.Get("/api/openapi.yaml", handlers.OpenAPISpec(openapiSpec)) r.Get("/api/docs", handlers.APIDocs()) r.Group(func(r chi.Router) { r.Use(middleware.RateLimit(10*time.Minute, 3)) r.Post("/api/auth/bootstrap", handlers.Bootstrap(database, jwtSecret)) }) r.Get("/api/auth/oidc/providers", handlers.ListPublicOIDCProviders(database, registry)) r.With(middleware.RateLimit(time.Minute, 10)).Get("/api/auth/oidc/{provider_id}/login", handlers.OIDCProviderLogin(database, registry)) r.Get("/api/auth/oidc/{provider_id}/callback", handlers.OIDCProviderCallback(database, registry, jwtSecret)) r.Group(func(r chi.Router) { r.Use(middleware.RateLimit(time.Minute, 10)) r.Post("/api/auth/login", handlers.Login(database, jwtSecret)) r.Post("/api/auth/register", handlers.Register(database, jwtSecret)) }) r.Post("/api/auth/logout", handlers.Logout()) r.Group(func(r chi.Router) { r.Use(middleware.JWTAuth(jwtSecret)) r.Use(middleware.TokenVersionCheck(database)) r.Get("/api/auth/me", handlers.CurrentUser()) r.Patch("/api/auth/me", handlers.UpdateAccount(database, jwtSecret)) r.Post("/api/auth/invite", handlers.CreateInvite(database)) r.Get("/api/auth/invite", handlers.ListInvites(database)) r.Get("/api/nodes", handlers.ListNodes(database)) r.Get("/api/incidents", handlers.ListIncidents(database)) r.Get("/api/incidents/summary", handlers.GetIncidentSummary(database)) r.Post("/api/incidents/membership", handlers.ListIncidentMembership(database)) r.Get("/api/incidents/{id}", handlers.GetIncident(database)) r.Get("/api/incidents/{id}/report.pdf", handlers.DownloadIncidentReport(database)) r.Get("/api/entries", handlers.ListEntries(database)) r.Get("/api/entries/services", handlers.ListEntryServices(database)) r.Post("/api/entries", handlers.CreateEntry(database, eventHub, incidentCh, managerCtx.Done())) r.Get("/api/entries/{id}", handlers.GetEntry(database)) r.Post("/api/entries/{id}/notes", handlers.CreateNote(database)) r.Get("/api/entries/{id}/notes", handlers.ListNotes(database)) r.Delete("/api/notes/{id}", handlers.DeleteNote(database)) r.Get("/api/ws", handlers.WebSocketHandler(eventHub)) }) r.Group(func(r chi.Router) { r.Use(middleware.JWTAuth(jwtSecret)) r.Use(middleware.TokenVersionCheck(database)) r.Use(middleware.RequireAdmin()) r.Delete("/api/auth/invite/{id}", handlers.RevokeInvite(database)) r.Get("/api/admin/users", handlers.ListAdminUsers(database)) r.Patch("/api/admin/users/{id}", handlers.UpdateAdminUser(database, eventHub)) r.Post("/api/admin/users/{id}/force-logout", handlers.ForceLogoutUser(database, eventHub)) r.Delete("/api/admin/users/{id}", handlers.DeleteAdminUser(database, eventHub)) r.Get("/api/admin/config", handlers.AdminConfig(database, webhookSecret, mcpMgr)) r.Put("/api/admin/settings/file-watcher", handlers.UpdateFileWatcherSettings(database)) r.Put("/api/admin/settings/base-url", handlers.UpdateBaseURLSetting(database)) r.Get("/api/admin/settings/systemd", handlers.GetSystemdSettings(database)) r.Put("/api/admin/settings/systemd/{node_name}", handlers.UpdateSystemdSettings(database)) r.Put("/api/admin/settings/ai", handlers.UpdateAISettings(database)) r.Post("/api/admin/settings/ai/test", handlers.TestAISettings(database)) r.Put("/api/admin/settings/ollama", handlers.UpdateOllamaSettingsLegacy(database)) // deprecated alias r.Put("/api/admin/settings/mcp", handlers.UpdateMCPSettings(database, mcpMgr)) r.Post("/api/admin/settings/mcp/regenerate-token", handlers.RegenerateMCPToken(database, mcpMgr)) r.Get("/api/admin/oidc/providers", handlers.ListOIDCProviders(database)) r.Post("/api/admin/oidc/providers", handlers.CreateOIDCProvider(database, registry)) r.Patch("/api/admin/oidc/providers/{id}", handlers.UpdateOIDCProvider(database, registry)) r.Delete("/api/admin/oidc/providers/{id}", handlers.DeleteOIDCProvider(database, registry)) r.Get("/api/admin/oidc/policy", handlers.GetOIDCPolicy(database)) r.Put("/api/admin/oidc/policy", handlers.SetOIDCPolicy(database)) r.Get("/api/admin/notifications", handlers.ListNotificationDests(database)) r.Post("/api/admin/notifications", handlers.CreateNotificationDest(database)) r.Put("/api/admin/notifications/{id}", handlers.UpdateNotificationDest(database)) r.Delete("/api/admin/notifications/{id}", handlers.DeleteNotificationDest(database)) r.Post("/api/admin/notifications/{id}/test", handlers.TestNotificationDest(database, notifier)) r.Get("/api/admin/github/releases", handlers.GetGitHubReleases(database)) r.Get("/api/admin/excluded-targets", handlers.ListExcludedTargets(database)) r.Post("/api/admin/excluded-targets", handlers.CreateExcludedTarget(database)) r.Delete("/api/admin/excluded-targets/{id}", handlers.DeleteExcludedTarget(database)) r.Get("/api/admin/audit-logs", handlers.ListAuditLogs(database)) r.Get("/api/admin/webhook-deliveries", handlers.ListWebhookDeliveries(database)) r.Get("/api/admin/sources", handlers.ListSources(database)) r.Get("/api/admin/sources/types", handlers.ListSourceTypes()) r.Post("/api/admin/sources", handlers.CreateSource(database)) r.Put("/api/admin/sources/{id}", handlers.UpdateSource(database)) r.Delete("/api/admin/sources/{id}", handlers.DeleteSource(database)) }) r.Group(func(r chi.Router) { r.Use(middleware.AgentAuth(agentConfig)) r.Get("/api/agent/config", handlers.AgentConfig(database)) r.Post("/api/agent/push", handlers.AgentPush(database, eventHub, incidentCh, managerCtx.Done())) r.Post("/api/agent/push/batch", handlers.AgentPushBatch(database, eventHub, incidentCh, managerCtx.Done())) }) r.With( middleware.RateLimit(time.Minute, 60), middleware.WebhookAuthFunc(func() string { return handlers.GetCachedWebhookSecret(database, "webhook_uptime_kuma", webhookSecret) }), ).Post("/api/webhooks/uptime", handlers.WebhookUptime(database, eventHub, incidentCh, managerCtx.Done())) r.With( middleware.RateLimit(time.Minute, 60), middleware.WebhookAuthFunc(func() string { return handlers.GetCachedWebhookSecret(database, "webhook_watchtower", webhookSecret) }), ).Post("/api/webhooks/watchtower", handlers.WebhookWatchtower(database, eventHub, incidentCh, managerCtx.Done())) r.With( middleware.RateLimit(time.Minute, 60), middleware.WebhookAuthFuncMulti(func() map[string]string { return handlers.GetCachedWebhookSecrets(database, "webhook_komodo") }), ).Post("/api/webhooks/komodo", handlers.WebhookKomodo(database, eventHub, incidentCh, managerCtx.Done())) r.Handle("/mcp", mcpMgr.Handler()) spaHandler := static.Handler(staticFiles) r.NotFound(func(w http.ResponseWriter, req *http.Request) { if strings.HasPrefix(req.URL.Path, "/api/") { http.NotFound(w, req) return } spaHandler.ServeHTTP(w, req) }) r.Handle("/*", spaHandler) addr := getEnv("LISTEN_ADDR", ":8080") log.Printf("listening on %s", addr) server := &http.Server{Addr: addr, Handler: r} shutdownDone := make(chan struct{}) go func() { defer close(shutdownDone) <-rootCtx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) { log.Printf("server shutdown error: %v", err) } if !handlers.WaitForIncidentDispatches(35 * time.Second) { log.Printf("incidents: timed out waiting for dispatch goroutines to drain") } close(incidentCh) <-managerDone }() if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server failed: %v", err) } if rootCtx.Err() != nil { <-shutdownDone } } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func restoreMCPState(database *gorm.DB, mcpMgr *bbmcp.MCPManager) { var settings []models.AppSetting if err := database.Where("key IN ?", []string{"mcp_enabled", "mcp_auth_token"}).Find(&settings).Error; err != nil { log.Printf("mcp: failed to load startup settings: %v", err) return } m := make(map[string]string, len(settings)) for _, s := range settings { m[s.Key] = s.Value } if m["mcp_enabled"] != "true" || m["mcp_auth_token"] == "" { return } if err := mcpMgr.ApplySettings(true, m["mcp_auth_token"]); err != nil { log.Printf("mcp: failed to restore state on startup: %v", err) } }