Files
certctl/cmd/server/main.go
T
shankar0123 9c1d446e40 fix(security,config): remove unimplemented JWT auth-type, close silent downgrade (G-1)
The pre-G-1 config validator accepted CERTCTL_AUTH_TYPE=jwt and the
startup log faithfully echoed 'authentication enabled type=jwt'.
Reasonable people read that and concluded JWT auth was on. It wasn't.
The auth-middleware wiring at cmd/server/main.go unconditionally routed
every request through the api-key bearer middleware regardless of
cfg.Auth.Type. So CERTCTL_AUTH_TYPE=jwt quietly compared the incoming
'Authorization: Bearer <token>' against whatever string the operator put
in CERTCTL_AUTH_SECRET — real JWT clients got 401, and operators who
treated CERTCTL_AUTH_SECRET as a *signing* secret (because they thought
they were configuring JWT) had effectively handed an attacker an api-key.
A security finding masquerading as a config option.

We chose the audit-recommended structural fix: remove the option, fail
fast at startup, and add the gateway-fronting pattern as the documented
forward path. Implementing JWT middleware would have meant jwks vs
static-secret rotation, claim mapping, expiry enforcement, audience and
issuer validation, key rollover semantics, and regression coverage at the
same depth as the existing api-key path — a feature, not a fix. Operators
who genuinely need JWT/OIDC front certctl with an authenticating gateway
(oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium /
Authelia) and run the upstream certctl with CERTCTL_AUTH_TYPE=none. Same
shape works on docker-compose and Helm.

The change is comprehensive across 7 phases — every surface that
mentioned 'jwt' as a certctl-auth-type is updated, plus structural
backstops (typed enum, runtime guard, helm template validation, CI grep
guard) so the lie can't reappear.

Files changed:

Phase 1 — production code (typed enum + jwt removal):
- internal/config/config.go: AuthType typed alias + AuthTypeAPIKey /
  AuthTypeNone constants + ValidAuthTypes() helper. Validate() routes
  literal 'jwt' through a dedicated multi-line diagnostic naming the
  authenticating-gateway pattern, then cross-checks against
  ValidAuthTypes(). Secret-required branch simplified to api-key-only.
  Field comment on AuthConfig.Type rewritten to drop jwt and point at
  the gateway pattern.
- internal/api/middleware/middleware.go: AuthConfig.Type field comment
  references the typed config.AuthType constants.
- internal/api/handler/health.go: same treatment for HealthHandler.AuthType.
- cmd/server/main.go: defense-in-depth runtime switch immediately after
  config.Load() — exits 1 on any unsupported auth-type that bypassed the
  validator. Auth-disabled startup log explicitly names the
  authenticating-gateway pattern.

Phase 2 — tests (Red→Green, contract pinning):
- internal/config/config_test.go: TestValidate_JWTAuth_RejectedDedicated
  (two table rows pinning the dedicated G-1 error fires regardless of
  whether Secret is set), TestValidAuthTypesDoesNotContainJWT (property
  guard against future re-introduction),
  TestValidAuthTypesIsExactly_APIKey_None (allowed-set contract),
  TestValidate_GenericInvalidAuthType (pins non-jwt invalid values still
  hit the generic invalid-auth-type error). Removed the prior
  TestValidate_JWTAuth_MissingSecret happy-path since its premise is
  inverted post-G-1.
- internal/api/handler/health_test.go: removed
  TestAuthInfo_ReturnsAuthType_JWT (which baked the silent-downgrade lie
  into the regression suite). Pre-existing _APIKey test continues to
  cover the api-key happy path.

Phase 3 — spec, docs, env templates:
- api/openapi.yaml: auth_type enum dropped to [api-key, none] with
  inline comment naming the G-1 closure.
- .env.example (root): CERTCTL_AUTH_TYPE comment block rewritten to drop
  jwt and point at the gateway pattern; secret-required conditional
  simplified to api-key-only.
- docs/architecture.md: middleware-stack bullet rewritten to drop the
  JWT mention; new H3 'Authenticating-gateway pattern (JWT, OIDC, mTLS)'
  section explaining the design rationale and listing oauth2-proxy /
  Envoy ext_authz / Traefik ForwardAuth / Pomerium / Authelia / Caddy
  forward_auth / Apache mod_auth_openidc / nginx auth_request as the
  standard fronting options.
- docs/upgrade-to-v2-jwt-removal.md (new ~125 lines): migration guide
  with preconditions, what-changes, both recovery paths, complete
  docker-compose oauth2-proxy walkthrough, Traefik ForwardAuth and Envoy
  ext_authz patterns, rollback posture.

Phase 4 — Helm chart (template validation + docs):
- deploy/helm/certctl/templates/_helpers.tpl: new certctl.validateAuthType
  helper mirroring the existing certctl.tls.required pattern. Fails
  template render on any server.auth.type outside {api-key, none} with
  a multi-line diagnostic.
- deploy/helm/certctl/templates/server-deployment.yaml,
  server-configmap.yaml, server-secret.yaml: invoke the helper at the
  top of each template that depends on .Values.server.auth.type.
- deploy/helm/certctl/values.yaml: auth: block comment expanded with the
  G-1 rationale and gateway-pattern cross-reference.
- deploy/helm/CHART_SUMMARY.md: server.auth.type table row now surfaces
  the allowed set and points at the upgrade doc.
- deploy/helm/certctl/README.md: new 'JWT / OIDC via authenticating
  gateway' section with a Kubernetes-flavored oauth2-proxy + certctl
  walkthrough.

Phase 5 — release surface:
- CHANGELOG.md: new [unreleased] top entry with Breaking / Removed /
  Added / Changed sections; explicit pointer at
  docs/upgrade-to-v2-jwt-removal.md from the Breaking subsection.

Phase 6 — CI guardrail:
- .github/workflows/ci.yml: new 'Forbidden auth-type literal regression
  guard (G-1)' step. Scoped patterns catch the actual regression shapes
  (map literal, slice literal, switch case, OpenAPI enum, env-file
  default, AuthType('jwt') cast). Comments and the dedicated rejection
  branch are intentionally exempt; connector-package JWT references
  (Google OAuth2 / step-ca) are exempt as out-of-scope external
  protocols. Verified locally: the guard passes on the actual tree and
  fires on all 4 synthetic regression patterns.

Out of scope (explicitly untouched):
- internal/connector/discovery/gcpsm/gcpsm.go — Google OAuth2 service-
  account JWT (external protocol).
- internal/connector/issuer/googlecas/googlecas.go — same.
- internal/connector/issuer/stepca/stepca.go — step-ca's provisioner
  one-time-token JWT for /sign API.
- docs/test-env.md, docs/connectors.md, docs/features.md — describe
  external CAs' use of JWT, not certctl's auth shape.
- Implementing actual JWT middleware. Feature, not a fix.

Verification (all gates pass):
- go build ./... — clean
- go vet ./... — clean
- go test -short ./... — every package green
- go test -short -race ./internal/config/... ./internal/api/... — clean
- govulncheck ./... — no vulnerabilities in our code
- helm lint deploy/helm/certctl/ — clean
- helm template with auth.type=api-key — renders OK
- helm template with auth.type=none — renders OK
- helm template with auth.type=jwt — fails with validateAuthType
  diagnostic (exit 1)
- python3 yaml.safe_load on api/openapi.yaml — parses
- CI guardrail mirror — clean on real tree, fires on all 4 synthetic
  regression patterns
- Smoke test: 'CERTCTL_AUTH_TYPE=jwt ./certctl-server' exits non-zero
  with: 'Failed to load configuration: CERTCTL_AUTH_TYPE=jwt is no
  longer accepted (G-1 silent auth downgrade): no JWT middleware ships
  with certctl. To use JWT/OIDC, run an authenticating gateway
  (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium) in
  front of certctl and set CERTCTL_AUTH_TYPE=none on the upstream.
  See docs/architecture.md "Authenticating-gateway pattern" and
  docs/upgrade-to-v2-jwt-removal.md for the migration walkthrough'

config pkg coverage: ValidAuthTypes 100%, Validate 94.7%, total 75.5%.

Refs: coverage-gap-audit-2026-04-24-v5/unified-audit.md
      §2 P1 cluster, cat-g-jwt_silent_auth_downgrade
      Audit recommendation followed verbatim: 'Remove jwt from
      validAuthTypes until middleware ships'.
2026-04-25 00:22:23 +00:00

974 lines
41 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/shankar0123/certctl/internal/api/handler"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/api/router"
"github.com/shankar0123/certctl/internal/config"
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm"
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv"
discoverygcpsm "github.com/shankar0123/certctl/internal/connector/discovery/gcpsm"
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository/postgres"
"github.com/shankar0123/certctl/internal/scheduler"
"github.com/shankar0123/certctl/internal/service"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
os.Exit(1)
}
// Defense-in-depth runtime guard for the auth-type discriminator.
//
// G-1 (P1): config.Load() already runs Validate() which rejects "jwt"
// and any value outside config.ValidAuthTypes() with a dedicated
// diagnostic. This switch is belt-and-braces — if a future refactor
// bypasses the validator (test harness, alt config loader, env-var
// rebinding after Load) the server must not silently boot with an
// unsupported auth shape. The error path uses fmt.Fprintf because
// the slog logger is constructed from cfg below this point; we want
// the failure to be visible regardless of log-level configuration.
switch config.AuthType(cfg.Auth.Type) {
case config.AuthTypeAPIKey, config.AuthTypeNone:
// ok — fall through
default:
fmt.Fprintf(os.Stderr,
"unsupported auth type at runtime: %q (valid: %v) — config validation should have caught this; refusing to start\n",
cfg.Auth.Type, config.ValidAuthTypes())
os.Exit(1)
}
// Set up structured logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: cfg.GetLogLevel(),
}))
logger.Info("certctl server starting",
"version", "2.0.9",
"server_host", cfg.Server.Host,
"server_port", cfg.Server.Port)
// Initialize database connection pool
db, err := postgres.NewDB(cfg.Database.URL)
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.Close()
logger.Info("connected to database")
// Run migrations
logger.Info("running migrations", "path", cfg.Database.MigrationsPath)
if err := postgres.RunMigrations(db, cfg.Database.MigrationsPath); err != nil {
logger.Error("failed to run migrations", "error", err)
os.Exit(1)
}
logger.Info("migrations completed")
// Initialize repositories with real PostgreSQL connection
auditRepo := postgres.NewAuditRepository(db)
certificateRepo := postgres.NewCertificateRepository(db)
issuerRepo := postgres.NewIssuerRepository(db)
targetRepo := postgres.NewTargetRepository(db)
agentRepo := postgres.NewAgentRepository(db)
jobRepo := postgres.NewJobRepository(db)
policyRepo := postgres.NewPolicyRepository(db)
notificationRepo := postgres.NewNotificationRepository(db)
renewalPolicyRepo := postgres.NewRenewalPolicyRepository(db)
profileRepo := postgres.NewProfileRepository(db)
teamRepo := postgres.NewTeamRepository(db)
ownerRepo := postgres.NewOwnerRepository(db)
logger.Info("initialized all repositories")
// Initialize dynamic issuer registry.
// Issuers are loaded from the database (with AES-256-GCM encrypted config).
// On first boot with an empty database, env var issuers are seeded automatically.
//
// M-8 (CWE-916 / CWE-329): the encryption passphrase is passed as a raw
// string into IssuerService / TargetService / IssuerRegistry. Each call to
// crypto.EncryptIfKeySet generates a fresh 16-byte PBKDF2 salt and emits a
// v2 blob (magic 0x02 || salt || nonce || sealed). Decryption auto-detects
// v1 legacy blobs (no magic) and falls back to the fixed v1 salt for
// backward compatibility; v1 blobs transparently upgrade to v2 on next
// write. DO NOT pre-derive the key here with crypto.DeriveKey — that was
// the v1 fixed-salt behaviour that M-8 removes.
encryptionKey := cfg.Encryption.ConfigEncryptionKey
if encryptionKey != "" {
logger.Info("config encryption enabled (AES-256-GCM, per-ciphertext PBKDF2 salt)")
} else {
// C-2 fix: fail closed at startup when database-sourced issuer or target
// rows exist without a configured encryption key. Previously the server
// would emit a one-line warning and silently persist new GUI-created
// configs as plaintext (CWE-311). Refuse to start instead: the operator
// must either configure CERTCTL_CONFIG_ENCRYPTION_KEY or remove the
// vulnerable rows before the control plane can boot.
ctx := context.Background()
dbIssuers, ierr := issuerRepo.List(ctx)
if ierr != nil {
logger.Error("startup check: failed to list issuers", "error", ierr)
os.Exit(1)
}
dbTargets, terr := targetRepo.List(ctx)
if terr != nil {
logger.Error("startup check: failed to list targets", "error", terr)
os.Exit(1)
}
var dbIssuerCount, dbTargetCount int
for _, iss := range dbIssuers {
if iss != nil && iss.Source == "database" {
dbIssuerCount++
}
}
for _, tgt := range dbTargets {
if tgt != nil && tgt.Source == "database" {
dbTargetCount++
}
}
if dbIssuerCount > 0 || dbTargetCount > 0 {
logger.Error(
"startup refused: CERTCTL_CONFIG_ENCRYPTION_KEY is not set but database-sourced configs exist "+
"(would expose sensitive fields as plaintext, CWE-311). "+
"Set the encryption key or remove the affected rows before restarting.",
"database_sourced_issuers", dbIssuerCount,
"database_sourced_targets", dbTargetCount,
)
os.Exit(1)
}
logger.Warn("CERTCTL_CONFIG_ENCRYPTION_KEY not set — env-seeded issuers will be stored in plaintext; GUI-created issuers and targets will be rejected until a key is configured")
}
issuerRegistry := service.NewIssuerRegistry(logger)
// Initialize revocation repository
revocationRepo := postgres.NewRevocationRepository(db)
// Initialize services (following the dependency graph)
auditService := service.NewAuditService(auditRepo)
policyService := service.NewPolicyService(policyRepo, auditService)
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
// Drives /api/v1/renewal-policies CRUD; the service layer owns slugify + validation,
// the repo layer owns sentinel translation for 23505 (name UNIQUE) and 23503
// (FK-RESTRICT against managed_certificates.renewal_policy_id).
renewalPolicyService := service.NewRenewalPolicyService(renewalPolicyRepo)
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
notifierRegistry := make(map[string]service.Notifier)
// Wire notifier connectors from config
if cfg.Notifiers.SlackWebhookURL != "" {
slackNotifier := notifyslack.New(notifyslack.Config{
WebhookURL: cfg.Notifiers.SlackWebhookURL,
ChannelOverride: cfg.Notifiers.SlackChannel,
Username: cfg.Notifiers.SlackUsername,
})
notifierRegistry["Slack"] = slackNotifier
logger.Info("Slack notifier enabled")
}
if cfg.Notifiers.TeamsWebhookURL != "" {
teamsNotifier := notifyteams.New(notifyteams.Config{
WebhookURL: cfg.Notifiers.TeamsWebhookURL,
})
notifierRegistry["Teams"] = teamsNotifier
logger.Info("Teams notifier enabled")
}
if cfg.Notifiers.PagerDutyRoutingKey != "" {
pdNotifier := notifypagerduty.New(notifypagerduty.Config{
RoutingKey: cfg.Notifiers.PagerDutyRoutingKey,
Severity: cfg.Notifiers.PagerDutySeverity,
})
notifierRegistry["PagerDuty"] = pdNotifier
logger.Info("PagerDuty notifier enabled")
}
if cfg.Notifiers.OpsGenieAPIKey != "" {
ogNotifier := notifyopsgenie.New(notifyopsgenie.Config{
APIKey: cfg.Notifiers.OpsGenieAPIKey,
Priority: cfg.Notifiers.OpsGeniePriority,
})
notifierRegistry["OpsGenie"] = ogNotifier
logger.Info("OpsGenie notifier enabled")
}
// Wire email notifier if SMTP is configured
var emailAdapter *notifyemail.NotifierAdapter
if cfg.Notifiers.SMTPHost != "" && cfg.Notifiers.SMTPFromAddress != "" {
emailConnector := notifyemail.New(&notifyemail.Config{
SMTPHost: cfg.Notifiers.SMTPHost,
SMTPPort: cfg.Notifiers.SMTPPort,
Username: cfg.Notifiers.SMTPUsername,
Password: cfg.Notifiers.SMTPPassword,
FromAddress: cfg.Notifiers.SMTPFromAddress,
UseTLS: cfg.Notifiers.SMTPUseTLS,
}, logger)
emailAdapter = notifyemail.NewNotifierAdapter(emailConnector)
notifierRegistry["Email"] = emailAdapter
logger.Info("Email notifier enabled",
"smtp_host", cfg.Notifiers.SMTPHost,
"smtp_port", cfg.Notifiers.SMTPPort,
"from", cfg.Notifiers.SMTPFromAddress)
}
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
notificationService.SetOwnerRepo(ownerRepo)
// Create RevocationSvc with its dependencies
revocationSvc := service.NewRevocationSvc(certificateRepo, revocationRepo, auditService)
revocationSvc.SetIssuerRegistry(issuerRegistry)
revocationSvc.SetNotificationService(notificationService)
// Create CAOperationsSvc with its dependencies
caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certificateRepo, profileRepo)
caOperationsSvc.SetIssuerRegistry(issuerRegistry)
// Wire sub-services into CertificateService
certificateService.SetRevocationSvc(revocationSvc)
certificateService.SetCAOperationsSvc(caOperationsSvc)
certificateService.SetTargetRepo(targetRepo)
certificateService.SetJobRepo(jobRepo)
certificateService.SetKeygenMode(cfg.Keygen.Mode)
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
renewalService.SetTargetRepo(targetRepo)
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, certificateRepo, ownerRepo, renewalService, deploymentService, logger)
// I-001: emit "job_retry" audit events when the scheduler resets Failed→Pending.
// SetAuditService is optional — JobService falls back to nil-guarded no-op if unwired.
jobService.SetAuditService(auditService)
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
agentService.SetProfileRepo(profileRepo)
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
// Seed issuers from env vars on first boot (empty database only), then build registry
issuerService.SeedFromEnvVars(context.Background(), cfg)
if err := issuerService.BuildRegistry(context.Background()); err != nil {
logger.Error("failed to build issuer registry from database", "error", err)
}
logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len())
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
profileService := service.NewProfileService(profileRepo, auditService)
teamService := service.NewTeamService(teamRepo, auditService)
ownerService := service.NewOwnerService(ownerRepo, auditService)
agentGroupRepo := postgres.NewAgentGroupRepository(db)
agentGroupService := service.NewAgentGroupService(agentGroupRepo, auditService)
discoveryRepo := postgres.NewDiscoveryRepository(db)
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
networkScanRepo := postgres.NewNetworkScanRepository(db)
networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger)
logger.Info("initialized network scan service")
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
// This agent ID is used as the agent_id in discovered_certificates for network-scanned certs.
if cfg.NetworkScan.Enabled {
sentinelAgent := &domain.Agent{
ID: service.SentinelAgentID,
Name: "Network Scanner (Server-Side)",
Status: domain.AgentStatusOnline,
}
// M-6: use CreateIfNotExists so duplicate rows on restart/upgrade are
// idempotent without swallowing unrelated DB failures (CWE-662).
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelAgent)
if err != nil {
logger.Error("sentinel agent creation failed", "id", service.SentinelAgentID, "error", err)
} else if created {
logger.Info("sentinel agent created", "id", service.SentinelAgentID)
} else {
logger.Debug("sentinel agent already exists", "id", service.SentinelAgentID)
}
}
// Initialize cloud discovery sources (M50)
var cloudDiscoveryService *service.CloudDiscoveryService
if cfg.CloudDiscovery.Enabled {
cloudDiscoveryService = service.NewCloudDiscoveryService(discoveryService, logger)
// AWS Secrets Manager
if cfg.CloudDiscovery.AWSSM.Enabled {
awsSource := discoveryawssm.New(&cfg.CloudDiscovery.AWSSM, logger)
cloudDiscoveryService.RegisterSource(awsSource)
// Create sentinel agent for AWS SM
sentinelAWS := &domain.Agent{
ID: service.SentinelAWSSecretsMgr,
Name: "AWS Secrets Manager Discovery",
Status: domain.AgentStatusOnline,
}
// M-6: idempotent create (CWE-662).
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelAWS)
if err != nil {
logger.Error("sentinel agent creation failed", "id", service.SentinelAWSSecretsMgr, "error", err)
} else if created {
logger.Info("sentinel agent created", "id", service.SentinelAWSSecretsMgr)
} else {
logger.Debug("sentinel agent already exists", "id", service.SentinelAWSSecretsMgr)
}
}
// Azure Key Vault
if cfg.CloudDiscovery.AzureKV.Enabled {
azureSource := discoveryazurekv.New(discoveryazurekv.Config{
VaultURL: cfg.CloudDiscovery.AzureKV.VaultURL,
TenantID: cfg.CloudDiscovery.AzureKV.TenantID,
ClientID: cfg.CloudDiscovery.AzureKV.ClientID,
ClientSecret: cfg.CloudDiscovery.AzureKV.ClientSecret,
}, logger)
cloudDiscoveryService.RegisterSource(azureSource)
sentinelAzure := &domain.Agent{
ID: service.SentinelAzureKeyVault,
Name: "Azure Key Vault Discovery",
Status: domain.AgentStatusOnline,
}
// M-6: idempotent create (CWE-662).
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelAzure)
if err != nil {
logger.Error("sentinel agent creation failed", "id", service.SentinelAzureKeyVault, "error", err)
} else if created {
logger.Info("sentinel agent created", "id", service.SentinelAzureKeyVault)
} else {
logger.Debug("sentinel agent already exists", "id", service.SentinelAzureKeyVault)
}
}
// GCP Secret Manager
if cfg.CloudDiscovery.GCPSM.Enabled {
gcpSource := discoverygcpsm.New(&cfg.CloudDiscovery.GCPSM, logger)
cloudDiscoveryService.RegisterSource(gcpSource)
sentinelGCP := &domain.Agent{
ID: service.SentinelGCPSecretMgr,
Name: "GCP Secret Manager Discovery",
Status: domain.AgentStatusOnline,
}
// M-6: idempotent create (CWE-662).
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelGCP)
if err != nil {
logger.Error("sentinel agent creation failed", "id", service.SentinelGCPSecretMgr, "error", err)
} else if created {
logger.Info("sentinel agent created", "id", service.SentinelGCPSecretMgr)
} else {
logger.Debug("sentinel agent already exists", "id", service.SentinelGCPSecretMgr)
}
}
logger.Info("cloud discovery enabled",
"sources", cloudDiscoveryService.SourceCount(),
"interval", cfg.CloudDiscovery.Interval.String())
}
logger.Info("initialized all services")
// Initialize bulk revocation service
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
// Initialize stats and metrics services
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
// I-005: wire the notification repository so DashboardSummary.NotificationsDead
// is populated, which in turn drives the Prometheus counter
// certctl_notification_dead_total in GetPrometheusMetrics. Setter
// pattern keeps NewStatsService's nine call sites (main.go + stats_test.go
// + 8 digest_test.go sites) untouched.
statsService.SetNotifRepo(notificationRepo)
logger.Info("initialized stats service")
// Initialize API handlers
certificateHandler := handler.NewCertificateHandler(certificateService)
issuerHandler := handler.NewIssuerHandler(issuerService)
targetHandler := handler.NewTargetHandler(targetService)
agentHandler := handler.NewAgentHandler(agentService)
jobHandler := handler.NewJobHandler(jobService)
policyHandler := handler.NewPolicyHandler(policyService)
// G-1: RenewalPolicyHandler — /api/v1/renewal-policies CRUD. Value-returning
// constructor matches the house pattern (PolicyHandler, IssuerHandler etc.);
// the registry stores it by value in HandlerRegistry.RenewalPolicies.
renewalPolicyHandler := handler.NewRenewalPolicyHandler(renewalPolicyService)
profileHandler := handler.NewProfileHandler(profileService)
teamHandler := handler.NewTeamHandler(teamService)
ownerHandler := handler.NewOwnerHandler(ownerService)
agentGroupHandler := handler.NewAgentGroupHandler(agentGroupService)
auditHandler := handler.NewAuditHandler(auditService)
notificationHandler := handler.NewNotificationHandler(notificationService)
statsHandler := handler.NewStatsHandler(statsService)
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
verificationHandler := handler.NewVerificationHandler(verificationService)
exportService := service.NewExportService(certificateRepo, auditService)
exportHandler := handler.NewExportHandler(exportService)
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
// Initialize digest service (requires email notifier)
var digestService *service.DigestService
var digestHandler *handler.DigestHandler
if cfg.Digest.Enabled && emailAdapter != nil {
digestService = service.NewDigestService(
statsService, certificateRepo, ownerRepo, emailAdapter, cfg.Digest.Recipients, logger,
)
digestHandler = handler.NewDigestHandler(digestService)
logger.Info("digest service enabled",
"interval", cfg.Digest.Interval.String(),
"recipients", len(cfg.Digest.Recipients))
} else {
// Create a no-op digest handler for route registration
digestHandler = handler.NewDigestHandler(nil)
if cfg.Digest.Enabled && emailAdapter == nil {
logger.Warn("digest enabled but SMTP not configured — digest emails will not be sent")
}
}
// Initialize health check service (M48)
var healthCheckService *service.HealthCheckService
var healthCheckHandler *handler.HealthCheckHandler
if cfg.HealthCheck.Enabled {
healthCheckRepo := postgres.NewHealthCheckRepository(db)
healthCheckService = service.NewHealthCheckService(
healthCheckRepo,
auditService,
logger,
cfg.HealthCheck.MaxConcurrent,
time.Duration(cfg.HealthCheck.DefaultTimeout)*time.Millisecond,
cfg.HealthCheck.HistoryRetention,
cfg.HealthCheck.AutoCreate,
)
healthCheckHandler = handler.NewHealthCheckHandler(healthCheckService)
logger.Info("health check service enabled",
"interval", cfg.HealthCheck.CheckInterval.String(),
"max_concurrent", cfg.HealthCheck.MaxConcurrent)
} else {
// Create a no-op health check handler for route registration
healthCheckHandler = handler.NewHealthCheckHandler(nil)
}
logger.Info("initialized all handlers")
// Create context with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize scheduler
sched := scheduler.NewScheduler(
renewalService,
jobService,
agentService,
notificationService,
networkScanService,
logger,
)
// Configure scheduler intervals from config
sched.SetRenewalCheckInterval(cfg.Scheduler.RenewalCheckInterval)
sched.SetJobProcessorInterval(cfg.Scheduler.JobProcessorInterval)
// I-001: drive the failed-job retry loop. Runs on start + every RetryInterval
// (default 5m, CERTCTL_SCHEDULER_RETRY_INTERVAL). Kept adjacent to the job
// processor setter because they share the JobServicer dependency.
sched.SetJobRetryInterval(cfg.Scheduler.RetryInterval)
sched.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval)
sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval)
// I-005: drive the failed-notification retry sweep. Runs every
// NotificationRetryInterval (default 2m, CERTCTL_NOTIFICATION_RETRY_INTERVAL)
// and transitions eligible Failed notifications whose next_retry_at has
// arrived back to Pending so the notification processor picks them up on
// its next tick. Kept adjacent to the notification processor setter
// because they share the NotificationServicer dependency (same placement
// pattern as I-001's SetJobRetryInterval above).
sched.SetNotificationRetryInterval(cfg.Scheduler.NotificationRetryInterval)
if cfg.NetworkScan.Enabled {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
}
if digestService != nil {
sched.SetDigestService(digestService)
sched.SetDigestInterval(cfg.Digest.Interval)
logger.Info("digest scheduler enabled", "interval", cfg.Digest.Interval.String())
}
if healthCheckService != nil {
sched.SetHealthCheckService(healthCheckService)
sched.SetHealthCheckInterval(cfg.HealthCheck.CheckInterval)
logger.Info("health check scheduler enabled", "interval", cfg.HealthCheck.CheckInterval.String())
}
if cloudDiscoveryService != nil && cloudDiscoveryService.SourceCount() > 0 {
sched.SetCloudDiscoveryService(cloudDiscoveryService)
sched.SetCloudDiscoveryInterval(cfg.CloudDiscovery.Interval)
logger.Info("cloud discovery scheduler enabled",
"interval", cfg.CloudDiscovery.Interval.String(),
"sources", cloudDiscoveryService.SourceCount())
}
// Wire job timeout reaper (I-003)
sched.SetJobReaperService(jobService)
sched.SetJobTimeoutInterval(cfg.Scheduler.JobTimeoutInterval)
sched.SetAwaitingCSRTimeout(cfg.Scheduler.AwaitingCSRTimeout)
sched.SetAwaitingApprovalTimeout(cfg.Scheduler.AwaitingApprovalTimeout)
logger.Info("job timeout reaper enabled",
"interval", cfg.Scheduler.JobTimeoutInterval.String(),
"csr_timeout", cfg.Scheduler.AwaitingCSRTimeout.String(),
"approval_timeout", cfg.Scheduler.AwaitingApprovalTimeout.String())
// Start scheduler
logger.Info("starting scheduler")
startedChan := sched.Start(ctx)
<-startedChan
logger.Info("scheduler started")
// Build the API router with all handlers
apiRouter := router.New()
apiRouter.RegisterHandlers(router.HandlerRegistry{
Certificates: certificateHandler,
Issuers: issuerHandler,
Targets: targetHandler,
Agents: agentHandler,
Jobs: jobHandler,
Policies: policyHandler,
RenewalPolicies: renewalPolicyHandler,
Profiles: profileHandler,
Teams: teamHandler,
Owners: ownerHandler,
AgentGroups: agentGroupHandler,
Audit: auditHandler,
Notifications: notificationHandler,
Stats: statsHandler,
Metrics: metricsHandler,
Health: healthHandler,
Discovery: discoveryHandler,
NetworkScan: networkScanHandler,
Verification: verificationHandler,
Export: exportHandler,
Digest: *digestHandler,
HealthChecks: healthCheckHandler,
BulkRevocation: bulkRevocationHandler,
})
// Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled {
issuerConn, ok := issuerRegistry.Get(cfg.EST.IssuerID)
if !ok {
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
os.Exit(1)
}
estService := service.NewESTService(cfg.EST.IssuerID, issuerConn, auditService, logger)
estService.SetProfileRepo(profileRepo)
if cfg.EST.ProfileID != "" {
estService.SetProfileID(cfg.EST.ProfileID)
}
estHandler := handler.NewESTHandler(estService)
apiRouter.RegisterESTHandlers(estHandler)
logger.Info("EST server enabled",
"issuer_id", cfg.EST.IssuerID,
"profile_id", cfg.EST.ProfileID,
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
}
// Register SCEP (RFC 8894) handlers if enabled
if cfg.SCEP.Enabled {
// H-2 fix: fail closed at startup when SCEP is enabled without a
// challenge password configured. Previously the service-layer guard
// at internal/service/scep.go:72-79 skipped the password check when
// s.challengePassword == "", meaning any client that could reach the
// /scep endpoint could enroll an arbitrary CSR against the configured
// issuer (CWE-306, missing authentication for a critical function).
// Refuse to start instead: the operator must set
// CERTCTL_SCEP_CHALLENGE_PASSWORD (or disable SCEP) before the control
// plane can boot.
if err := preflightSCEPChallengePassword(cfg.SCEP.Enabled, cfg.SCEP.ChallengePassword); err != nil {
logger.Error(
"startup refused: SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is not set "+
"(would allow unauthenticated certificate enrollment, CWE-306). "+
"Set a non-empty challenge password or disable SCEP before restarting.",
"error", err,
)
os.Exit(1)
}
issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID)
if !ok {
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
os.Exit(1)
}
scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword)
scepService.SetProfileRepo(profileRepo)
if cfg.SCEP.ProfileID != "" {
scepService.SetProfileID(cfg.SCEP.ProfileID)
}
scepHandler := handler.NewSCEPHandler(scepService)
apiRouter.RegisterSCEPHandlers(scepHandler)
logger.Info("SCEP server enabled",
"issuer_id", cfg.SCEP.IssuerID,
"profile_id", cfg.SCEP.ProfileID,
"challenge_password_set", cfg.SCEP.ChallengePassword != "",
"endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}")
}
// Register RFC 5280 CRL and RFC 6960 OCSP handlers under /.well-known/pki/.
// These are always enabled (no config gate) — revocation data must be
// reachable to relying parties for any cert certctl issues. The finalHandler
// routing gate below strips auth middleware for this prefix so browsers,
// OpenSSL, OCSP stapling sidecars, and mTLS clients can fetch without
// presenting certctl Bearer tokens.
apiRouter.RegisterPKIHandlers(certificateHandler)
logger.Info("PKI endpoints registered",
"endpoints", "/.well-known/pki/{crl/{issuer_id},ocsp/{issuer_id}/{serial}}")
logger.Info("registered all API handlers")
// Build middleware stack.
//
// Authentication unification (M-002): every authenticated request now
// carries a named actor in the request context so audit events record
// the real key identity instead of the hardcoded "api-key-user" string.
// Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For backward
// compatibility CERTCTL_AUTH_SECRET is synthesized into legacy-key-N
// entries with Admin=false.
var namedKeys []middleware.NamedAPIKey
if config.AuthType(cfg.Auth.Type) != config.AuthTypeNone {
// Translate typed config.NamedAPIKey -> middleware.NamedAPIKey. The
// two structs are field-compatible but live in different packages to
// preserve the config→middleware dependency direction.
for _, nk := range cfg.Auth.NamedKeys {
namedKeys = append(namedKeys, middleware.NamedAPIKey{
Name: nk.Name,
Key: nk.Key,
Admin: nk.Admin,
})
}
// Back-compat: if no named keys but legacy Secret is configured,
// synthesize named entries so the audit trail still attributes the
// action (instead of falling back to "api-key-user" / "anonymous").
if len(namedKeys) == 0 && cfg.Auth.Secret != "" {
parts := strings.Split(cfg.Auth.Secret, ",")
idx := 0
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
namedKeys = append(namedKeys, middleware.NamedAPIKey{
Name: fmt.Sprintf("legacy-key-%d", idx),
Key: p,
Admin: false,
})
idx++
}
if len(namedKeys) > 0 {
logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating",
"synthesized_keys", len(namedKeys))
}
}
}
authMiddleware := middleware.NewAuthWithNamedKeys(namedKeys)
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
AllowedOrigins: cfg.CORS.AllowedOrigins,
})
structuredLogger := middleware.NewLogging(logger)
// Request body size limit middleware — prevents memory exhaustion attacks (CWE-400)
bodyLimitMiddleware := middleware.NewBodyLimit(middleware.BodyLimitConfig{
MaxBytes: cfg.Server.MaxBodySize,
})
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
// API audit log middleware — records every API call to the audit trail
auditAdapter := middleware.NewAuditServiceAdapter(
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
return auditService.RecordEvent(ctx, actor, domain.ActorType(actorType), action, resourceType, resourceID, details)
},
)
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
ExcludePaths: []string{"/health", "/ready"},
Logger: logger,
})
logger.Info("API audit logging enabled (excluding /health, /ready)")
middlewareStack := []func(http.Handler) http.Handler{
middleware.RequestID,
structuredLogger,
middleware.Recovery,
bodyLimitMiddleware,
corsMiddleware,
authMiddleware,
auditMiddleware.Middleware,
}
// Add rate limiter if enabled
if cfg.RateLimit.Enabled {
rateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{
RPS: cfg.RateLimit.RPS,
BurstSize: cfg.RateLimit.BurstSize,
})
middlewareStack = []func(http.Handler) http.Handler{
middleware.RequestID,
structuredLogger,
middleware.Recovery,
bodyLimitMiddleware,
rateLimiter,
corsMiddleware,
authMiddleware,
auditMiddleware.Middleware,
}
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
}
if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone {
logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production except behind an authenticating gateway (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium)")
} else {
logger.Info("authentication enabled", "type", cfg.Auth.Type)
}
if cfg.Keygen.Mode == "server" {
logger.Warn("server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only")
} else {
logger.Info("agent-side key generation enabled — private keys never leave agent infrastructure")
}
// Apply middleware to API router
apiHandler := middleware.Chain(apiRouter, middlewareStack...)
// Wrap with dashboard static file serving
// Vite builds to web/dist/; fall back to web/ for legacy single-file SPA
var finalHandler http.Handler
webDir := "./web/dist"
if _, err := os.Stat(webDir + "/index.html"); err != nil {
webDir = "./web"
}
// Health/ready routes bypass the full middleware stack (no auth required).
// These are registered on the inner router without auth, but the outer
// middleware chain wraps everything. Route them directly to the inner router.
noAuthHandler := middleware.Chain(apiRouter,
middleware.RequestID,
structuredLogger,
middleware.Recovery,
)
dashboardEnabled := false
if _, err := os.Stat(webDir + "/index.html"); err == nil {
dashboardEnabled = true
}
finalHandler = buildFinalHandler(apiHandler, noAuthHandler, webDir, dashboardEnabled)
if dashboardEnabled {
logger.Info("dashboard available at /", "web_dir", webDir)
} else {
logger.Info("dashboard directory not found, serving API only")
}
// HTTPS-everywhere milestone §2.1: fail-loud if the TLS configuration is
// missing or malformed. Duplicates config.Validate() for defense in depth
// (same pattern as preflightSCEPChallengePassword).
if err := preflightServerTLS(cfg.Server.TLS.CertPath, cfg.Server.TLS.KeyPath); err != nil {
logger.Error("startup refused: HTTPS cert unusable; control plane is HTTPS-only",
"error", err,
"cert_path", cfg.Server.TLS.CertPath,
"key_path", cfg.Server.TLS.KeyPath)
os.Exit(1)
}
// Load the cert+key into a SIGHUP-reloadable holder. Any subsequent
// SIGHUP triggers a fresh read and atomic swap so rotations do not need
// a restart. Reload failures keep the previous cert and log a warning.
tlsCertHolder, err := newCertHolder(cfg.Server.TLS.CertPath, cfg.Server.TLS.KeyPath)
if err != nil {
logger.Error("startup refused: failed to load TLS cert holder",
"error", err,
"cert_path", cfg.Server.TLS.CertPath,
"key_path", cfg.Server.TLS.KeyPath)
os.Exit(1)
}
stopTLSWatcher := tlsCertHolder.watchSIGHUP(logger)
defer stopTLSWatcher()
// Server configuration
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
httpServer := &http.Server{
Addr: addr,
Handler: finalHandler,
TLSConfig: buildServerTLSConfig(tlsCertHolder),
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
IdleTimeout: 60 * time.Second,
}
// Start HTTPS server in background. ListenAndServeTLS is called with
// empty cert+key arguments because the cert is sourced through
// TLSConfig.GetCertificate (the SIGHUP-reloadable holder). Passing file
// paths here would pin the first-loaded cert and defeat hot reload.
logger.Info("HTTPS server listening",
"address", addr,
"cert_path", cfg.Server.TLS.CertPath,
"min_version", "TLS1.3")
go func() {
if err := httpServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
logger.Error("HTTPS server error", "error", err)
}
}()
// Wait for shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigChan
logger.Info("received shutdown signal", "signal", sig.String())
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
cancel() // Stop scheduler
// Wait for in-flight scheduler work to complete (up to 30 seconds)
logger.Info("waiting for scheduler to complete in-flight work")
if err := sched.WaitForCompletion(30 * time.Second); err != nil {
logger.Warn("scheduler work did not complete in time", "error", err)
}
logger.Info("shutting down HTTPS server")
if err := httpServer.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTPS server shutdown error", "error", err)
}
// Drain in-flight audit-recording goroutines before closing the DB pool.
// The audit middleware spawns one goroutine per non-excluded request; those
// goroutines run detached from the request context and write to the
// audit_events table via the same *sql.DB. Without this drain, SIGTERM
// would close the DB pool while recordings were mid-flight, silently
// dropping audit events (M-1, CWE-662 / CWE-400).
logger.Info("flushing audit middleware in-flight recordings")
if err := auditMiddleware.Flush(shutdownCtx); err != nil {
logger.Warn("audit middleware flush did not complete in time", "error", err)
}
// Close database connection
if err := db.Close(); err != nil {
logger.Error("error closing database connection", "error", err)
}
logger.Info("certctl server stopped")
}
// preflightSCEPChallengePassword enforces the H-2 fix: if SCEP is enabled, a
// non-empty challenge password MUST be configured. Returns a non-nil error
// otherwise so the caller can refuse to start the control plane (CWE-306,
// missing authentication for a critical function).
//
// This helper is extracted so the check can be unit tested without booting
// the full server. The caller (main) is responsible for translating the
// returned error into a structured log line and os.Exit(1).
func preflightSCEPChallengePassword(enabled bool, challengePassword string) error {
if !enabled {
return nil
}
if challengePassword == "" {
return fmt.Errorf("SCEP enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty: " +
"SCEP enrollment would accept any client (CWE-306); " +
"configure a non-empty shared secret or set CERTCTL_SCEP_ENABLED=false")
}
return nil
}
// buildFinalHandler builds the outer HTTP dispatch handler that routes incoming
// requests to either the authenticated apiHandler chain or the unauthenticated
// noAuthHandler chain based on URL path prefix. Extracted from main() so the
// dispatch logic can be unit tested without booting the full server stack
// (see cmd/server/finalhandler_test.go).
//
// Dispatch rules (M-001, audit 2026-04-19, option D):
//
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
// - /api/v1/* → auth (Bearer token required)
// - /assets/* → static file server (dashboard only)
// - anything else → SPA index.html fallback (dashboard only)
// OR apiHandler (no dashboard)
//
// EST/SCEP clients (IoT devices, 802.1X supplicants, MDM endpoints, network
// appliances) cannot present certctl Bearer tokens, so those endpoints must be
// reachable without the Auth middleware. Authentication is instead enforced by
// CSR signature verification, profile policy gates, and for SCEP the
// challengePassword shared secret (fail-loud gated by preflightSCEPChallengePassword
// above).
//
// webDir must point to a directory containing index.html + assets/ when
// dashboardEnabled is true; it is ignored otherwise.
func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, dashboardEnabled bool) http.Handler {
var fileServer http.Handler
if dashboardEnabled {
fileServer = http.FileServer(http.Dir(webDir))
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Health/ready and auth/info bypass auth middleware.
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
// auth/info: React app calls this before login to detect auth mode.
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
noAuthHandler.ServeHTTP(w, r)
return
}
// RFC 5280 CRL and RFC 6960 OCSP live under /.well-known/pki/ and MUST
// be served unauthenticated — relying parties (browsers, OpenSSL, OCSP
// stapling sidecars, mTLS clients) cannot present certctl Bearer tokens.
if strings.HasPrefix(path, "/.well-known/pki") {
noAuthHandler.ServeHTTP(w, r)
return
}
// RFC 7030 EST endpoints ride the no-auth middleware chain (M-001,
// option D, audit 2026-04-19). Trust boundary is CSR signature + profile
// policy, not HTTP Bearer. /.well-known/est/cacerts is explicitly
// anonymous per RFC 7030 §4.1.1.
if strings.HasPrefix(path, "/.well-known/est") {
noAuthHandler.ServeHTTP(w, r)
return
}
// RFC 8894 SCEP rides the no-auth chain (M-001, option D). SCEP clients
// authenticate via the challengePassword attribute in the PKCS#10 CSR,
// not via HTTP Bearer tokens. preflightSCEPChallengePassword refuses to
// start the server if SCEP is enabled without a non-empty shared secret.
if path == "/scep" || strings.HasPrefix(path, "/scep/") {
noAuthHandler.ServeHTTP(w, r)
return
}
// Authenticated API routes — full middleware stack including Auth.
if strings.HasPrefix(path, "/api/v1/") {
apiHandler.ServeHTTP(w, r)
return
}
if !dashboardEnabled {
// No dashboard: everything non-special falls through to the
// authenticated handler (preserves pre-M-001 behavior for API-only
// deployments).
apiHandler.ServeHTTP(w, r)
return
}
// Dashboard-present: serve static assets directly, SPA fallback for
// everything else.
if strings.HasPrefix(path, "/assets/") {
fileServer.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, webDir+"/index.html")
})
}