package httpapi import ( "context" "crypto/ed25519" "crypto/rand" "encoding/hex" "errors" "net/http" "strings" "sync" "time" "evobgp/internal/audit" "evobgp/internal/jobs" "evobgp/internal/logging" "evobgp/internal/maintenance" "evobgp/internal/pgmonitor" "evobgp/internal/runtimelogs" "evobgp/internal/store" "evobgp/internal/telemetry" "github.com/jackc/pgx/v5/pgxpool" ) // Server implements EvoBGP control-plane HTTP API. type Server struct { store store.Backend pgPool *pgxpool.Pool pgMonitor *pgmonitor.Service maintConfig *maintenance.ConfigProvider maintStats *maintenance.DBStatsProvider jobs *jobs.Registry authLimiter *authRateLimiter operationOnce sync.Once operationLimiter *operationLimiter bundlePriv ed25519.PrivateKey bundleCache *nodeBundleCache nodeBundleBuild nodeBundleBuilder keyResolver *apiKeyResolver firewallResolver *firewallTokenResolver bundleSeedHex string corsOrigins []string cdnHTTP *http.Client runtimeLogs *runtimelogs.Service runtimeLogsPolicyTenant string mux *http.ServeMux backgroundMu sync.Mutex backgroundStarted bool backgroundClosed bool backgroundCancel context.CancelFunc telemetryCollector *telemetry.Collector // Portal / dual-auth (JWT) configuration. jwtSecret string authIssuer string authPortalURL string portalTenantID string authRequired bool auditIngestSecret string auditPusher *audit.PortalPusher } // Options configures the API server. type Options struct { APIKeys string // DatabaseURL enables PostgreSQL-backed store (migrations applied on connect). DatabaseURL string InsecureDev bool SeedDemo bool BundleSeedHex string CORSAllowedOrigins string // RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional). RuntimeLogsPolicyTenant string // Portal / dual-auth (JWT) — leave empty to disable JWT path. JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret) AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top) AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI) PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config) // AuditIngestSecret — AUTH_AUDIT_INGEST_SECRET for portal push (optional). AuditIngestSecret string } // New constructs Server and wiring for async jobs. func New(opts Options) (*Server, error) { backend, reg, pool, err := BootstrapWorkers(context.Background(), opts) if err != nil { return nil, err } var priv ed25519.PrivateKey if strings.TrimSpace(opts.BundleSeedHex) != "" { seed, err := hex.DecodeString(strings.TrimSpace(opts.BundleSeedHex)) if err != nil { return nil, err } if len(seed) != ed25519.SeedSize { return nil, errors.New("httpapi: BundleSeedHex must decode to 32 bytes") } priv = ed25519.NewKeyFromSeed(seed) } else { _, priv, _ = ed25519.GenerateKey(rand.Reader) } resolver, err := newAPIKeyResolver(opts.APIKeys, backend) if err != nil { return nil, err } fwResolver, err := newFirewallTokenResolver(backend) if err != nil { return nil, err } var pgMon *pgmonitor.Service var maintCfg *maintenance.ConfigProvider var maintStats *maintenance.DBStatsProvider if pool != nil { pgMon = pgmonitor.NewService(pool) maintCfg = maintenance.NewConfigProvider(backend) _ = maintCfg.Reload(context.Background()) maintStats = maintenance.NewDBStatsProvider(pgMon) } s := &Server{ store: backend, pgPool: pool, pgMonitor: pgMon, maintConfig: maintCfg, maintStats: maintStats, jobs: reg, bundlePriv: priv, bundleCache: newNodeBundleCache(nodeBundleCacheMaxEntries, nodeBundleCacheMaxBytes), keyResolver: resolver, firewallResolver: fwResolver, bundleSeedHex: strings.TrimSpace(opts.BundleSeedHex), corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins), cdnHTTP: NewCDNHTTPClient(), runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()), runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant), jwtSecret: strings.TrimSpace(opts.JWTSecret), authIssuer: strings.TrimSpace(opts.AuthIssuer), authPortalURL: strings.TrimSpace(opts.AuthPortalURL), portalTenantID: strings.TrimSpace(opts.PortalTenantID), authRequired: opts.AuthRequired, auditIngestSecret: strings.TrimSpace(opts.AuditIngestSecret), } if s.authIssuer == "" { s.authIssuer = "https://auth.shnt.top" } s.initAuditPusher(s.authPortalURL, s.auditIngestSecret) s.mux = http.NewServeMux() s.registerRoutes() return s, nil } // Close releases database resources. func (s *Server) Close() { if s == nil { return } s.backgroundMu.Lock() if s.backgroundClosed { s.backgroundMu.Unlock() return } s.backgroundClosed = true cancel := s.backgroundCancel collector := s.telemetryCollector s.backgroundMu.Unlock() if cancel != nil { cancel() } if collector != nil { _ = collector.Close(context.Background()) } // Do not close the pool while a worker is still persisting its final state. if s.jobs != nil { s.jobs.RequestCancelAll() _ = s.jobs.Drain(context.Background()) } if s.pgPool != nil { s.pgPool.Close() } } // Store exposes the backing store (for operators / tests). func (s *Server) Store() store.Backend { return s.store } // Jobs exposes the in-process async job registry (for scheduler / evobgp-all). func (s *Server) Jobs() *jobs.Registry { return s.jobs } // StartBackground starts shared schedulers and telemetry polling once until ctx is cancelled. func (s *Server) StartBackground(ctx context.Context) { if s == nil { return } s.backgroundMu.Lock() defer s.backgroundMu.Unlock() if s.backgroundClosed || s.backgroundStarted { return } s.backgroundStarted = true backgroundCtx, cancel := context.WithCancel(ctx) s.backgroundCancel = cancel if s.store != nil { s.telemetryCollector = telemetry.Start(backgroundCtx, s.store, telemetry.ConfigFromEnv()) } if s.pgPool != nil { pgmonitor.StartScheduler(backgroundCtx, s.pgPool) if s.jobs != nil { jobs.StartDurableQueueLoop(backgroundCtx, s.jobs) jobs.StartDeploymentReconciliationLoop(backgroundCtx, s.jobs, s.store) } } if s != nil && s.maintConfig != nil && s.jobs != nil { maintenance.StartScheduler(backgroundCtx, s.maintConfig, func(policyID string, dryRun bool, idem string) { key := idem _, _, err := s.jobs.EnqueueContext(backgroundCtx, store.SystemJobTenantID, jobs.KindMaintenancePolicyRun, &key, nil, map[string]any{ "policy_id": policyID, "dry_run": dryRun, "trigger": "scheduler", }) if err != nil { logging.With("component", "maintenance.scheduler").Error("enqueue failed", "policy_id", policyID, "err", err) } }, 30*time.Second) } if s != nil && s.runtimeLogs != nil && s.store != nil { runtimelogs.StartAutoCleanupScheduler(backgroundCtx, runtimelogs.SchedulerDeps{ Service: s.runtimeLogs, Store: s.store, PolicyTenant: s.runtimeLogsPolicyTenant, }, 30*time.Second) } }