Files
certctl/cmd/server/main.go
T
shankar0123 f549a7aa79 security: fail closed when CERTCTL_CONFIG_ENCRYPTION_KEY is unset (fixes C-2)
EncryptIfKeySet/DecryptIfKeySet in internal/crypto/encryption.go previously
returned plaintext + wasEncrypted=false when the operator had not configured
CERTCTL_CONFIG_ENCRYPTION_KEY. That produced a data-at-rest confidentiality
bypass (CWE-311): sensitive fields on dynamically-configured issuer and
target rows (source='database') were persisted to PostgreSQL without any
encryption, and no caller could distinguish the encrypted from the plaintext
branch at runtime. The only visible signal was a single warning log line
emitted once at startup.

Fail closed instead:

- EncryptIfKeySet / DecryptIfKeySet now return crypto.ErrEncryptionKeyRequired
  (a new exported sentinel, errors.Is-unwrappable) when the key is empty or
  nil, rather than silently emitting plaintext. The (result, wasEncrypted,
  err) tuple signature is preserved for source compatibility; only the
  semantics of the no-key branch changed.

- cmd/server/main.go grows a startup pre-flight check: if no encryption key
  is configured the server lists issuers and targets, counts rows with
  source='database', and refuses to start (os.Exit(1)) if any exist. Operators
  must either configure CERTCTL_CONFIG_ENCRYPTION_KEY or remove the exposed
  rows before the control plane can boot. The warning-only path is retained
  for the clean-slate case (no database rows).

- internal/service/issuer.go's SeedFromEnvVars now guards the encryption call
  with len(s.encryptionKey) > 0 so env-seeded rows (source='env', which are
  reconstructable on every boot from process env) continue to persist as
  plaintext in the 'config' column when no key is configured. Registry load
  already falls through to cfg.Config when EncryptedConfig is nil. GUI/API
  write paths (source='database') remain fail-closed via propagation of
  ErrEncryptionKeyRequired.

- Integration tests that exercise CreateIssuer via the handler layer now
  supply a real 32-byte AES-256 test key so the encrypt path runs instead of
  returning ErrEncryptionKeyRequired. Same pattern in internal/service/
  testutil_test.go for consolidated service-layer tests.

- internal/crypto/encryption_test.go grows regression guards:
  TestEncryptIfKeySet_EmptyKeyFailsClosed (nil_key + empty_key subtests),
  TestDecryptIfKeySet_EmptyKeyFailsClosed (nil_key + empty_key subtests),
  TestEncryptDecryptIfKeySet_RoundTripProducesDifferentCiphertext,
  TestDecryptIfKeySet_RejectsTamperedCiphertext, and
  TestEncryptIfKeySet_PreservesErrEncryptionKeyRequiredSentinel (verifies
  the sentinel unwraps through fmt.Errorf(%w)-style wrapping).

Wire format is unchanged: AES-256-GCM Encrypt/Decrypt/DeriveKey, the
12-byte nonce prefix, the GCM auth tag, the PBKDF2 salt
('certctl-config-encryption-v1'), and the 100,000 iteration count are all
byte-identical. Ciphertexts produced before this change remain decryptable.

Verified:
- go build ./... : clean
- go vet ./...   : clean
- go test -race ./internal/crypto/... ./internal/service/... \
    ./internal/integration/... ./cmd/server/... : pass
- golangci-lint run ./... : 0 issues
- govulncheck ./... : 0 reachable vulnerabilities
- rg 'return plaintext, false, nil' internal/ : no matches
- Coverage: crypto 85.0% (unchanged), service 67.8% (was 67.9%, noise),
  cmd/server 0.0% (unchanged baseline). All above CI thresholds.

See certctl-audit-report.md for the full finding record and resolution log.
2026-04-16 21:10:40 +00:00

686 lines
26 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"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"
"github.com/shankar0123/certctl/internal/crypto"
"github.com/shankar0123/certctl/internal/domain"
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/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)
}
// 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-GCM encrypted config).
// On first boot with an empty database, env var issuers are seeded automatically.
var encryptionKey []byte
if cfg.Encryption.ConfigEncryptionKey != "" {
encryptionKey = crypto.DeriveKey(cfg.Encryption.ConfigEncryptionKey)
logger.Info("config encryption enabled (AES-256-GCM)")
} 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)
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, renewalService, deploymentService, logger)
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,
}
if err := agentRepo.Create(context.Background(), sentinelAgent); err != nil {
// Ignore duplicate key errors (agent already exists)
logger.Debug("sentinel agent creation", "status", "exists or created", "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,
}
if err := agentRepo.Create(context.Background(), sentinelAWS); err != nil {
logger.Debug("sentinel agent creation", "status", "exists or created", "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,
}
if err := agentRepo.Create(context.Background(), sentinelAzure); err != nil {
logger.Debug("sentinel agent creation", "status", "exists or created", "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,
}
if err := agentRepo.Create(context.Background(), sentinelGCP); err != nil {
logger.Debug("sentinel agent creation", "status", "exists or created", "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)
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)
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)
sched.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval)
sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval)
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())
}
// 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,
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 {
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}")
}
logger.Info("registered all API handlers")
// Build middleware stack
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
Type: cfg.Auth.Type,
Secret: cfg.Auth.Secret,
})
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,
}
// 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,
}
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
}
if cfg.Auth.Type == "none" {
logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production")
} 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,
)
if _, err := os.Stat(webDir + "/index.html"); err == nil {
fileServer := http.FileServer(http.Dir(webDir))
finalHandler = 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
}
// All other API and EST routes go through the full middleware stack (with auth)
if (len(path) >= 8 && path[:8] == "/api/v1/") ||
(len(path) >= 16 && path[:16] == "/.well-known/est") {
apiHandler.ServeHTTP(w, r)
return
}
// Try to serve static files (JS, CSS, assets)
if len(path) > 8 && path[:8] == "/assets/" {
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback: serve index.html for all other routes
http.ServeFile(w, r, webDir+"/index.html")
})
logger.Info("dashboard available at /", "web_dir", webDir)
} else {
// No dashboard: route health/auth-info without auth, everything else through full stack
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
noAuthHandler.ServeHTTP(w, r)
return
}
apiHandler.ServeHTTP(w, r)
})
logger.Info("dashboard directory not found, serving API only")
}
// Server configuration
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
httpServer := &http.Server{
Addr: addr,
Handler: finalHandler,
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
IdleTimeout: 60 * time.Second,
}
// Start HTTP server in background
logger.Info("starting HTTP server", "address", addr)
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP 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 HTTP server")
if err := httpServer.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server shutdown error", "error", err)
}
// Close database connection
if err := db.Close(); err != nil {
logger.Error("error closing database connection", "error", err)
}
logger.Info("certctl server stopped")
}