server/main.go
Rama main · réplica de lectura del repositorio original.
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)
}
}