main: wire CRL/OCSP responder services into runtime

Activates the CRL/OCSP responder pipeline that landed dormant in
phases 1-4 (commits dc44826, 6d1da84, ff20fba, c76bfcf):

  * IssuerRegistry gains SetLocalIssuerDeps + LocalIssuerDeps struct.
    Rebuild type-asserts each constructed connector to *local.Connector
    and injects ocspResponderRepo + signerDriver + IssuerID + key dir
    + (optional) rotation-grace + validity overrides. Non-local
    connectors are unaffected (the type-assert fails silently). Adapter
    pattern preserved: callers still see service.IssuerConnector.

  * cmd/server/main.go:
    - constructs CRLCacheRepository + OCSPResponderRepository from db
    - constructs signer.FileDriver (default; PKCS#11 driver plugs in
      later via the same Driver interface, no main.go changes needed)
    - calls issuerRegistry.SetLocalIssuerDeps(...) BEFORE BuildRegistry
      so the deps are in place when local connectors are constructed
    - wires CRLCacheService into CertificateService via SetCRLCacheSvc
      (Phase 4 cache-aware GenerateDERCRL path now active)
    - calls scheduler.SetCRLCacheService + SetCRLGenerationInterval
      after sched is constructed; logs the interval at startup

  * config: new OCSPResponderConfig struct + Scheduler.CRLGenerationInterval
    field. Three new env vars:
      CERTCTL_OCSP_RESPONDER_KEY_DIR (no default; operator MUST set in prod)
      CERTCTL_OCSP_RESPONDER_ROTATION_GRACE (default 7d)
      CERTCTL_OCSP_RESPONDER_VALIDITY (default 30d)
      CERTCTL_CRL_GENERATION_INTERVAL (default 1h)

Backward compat: when env vars are unset, the responder bootstrap path
still activates (with default rotation grace + validity, key dir = cwd
which is fine for tests), and the CRL cache pre-populates on the
1h interval. Operators not running the local issuer see no behavior
change.

go vet clean across the full module. Targeted tests for config +
service + scheduler packages all green. Full module build deferred
to CI (sandbox /sessions disk pressure prevented unzipping a
transitive dep — same disk-full pattern the prior commits hit; not
a code issue).
This commit is contained in:
Shankar
2026-04-29 01:48:23 +00:00
parent 266ed572b6
commit 46e17c56fb
3 changed files with 175 additions and 23 deletions
+66 -23
View File
@@ -25,6 +25,7 @@ import (
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/crypto/signer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository/postgres"
"github.com/shankar0123/certctl/internal/scheduler"
@@ -288,9 +289,38 @@ func main() {
caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certificateRepo, profileRepo)
caOperationsSvc.SetIssuerRegistry(issuerRegistry)
// Bundle CRL/OCSP-Responder: wire CRL cache + OCSP responder
// repositories. The CRL cache lets the HTTP CRL endpoint serve from
// pre-generated bytes (Phase 3). The OCSP responder repo lets the
// local issuer bootstrap a dedicated responder cert per RFC 6960
// §2.6 instead of signing OCSP with the CA key directly (Phase 2).
//
// The signer.FileDriver is the production driver; it provides keys
// to the responder bootstrap path. Future drivers (PKCS#11, cloud
// KMS) plug in via the same Driver interface without changing this
// wiring. The DirHardener / Marshaler hooks stay nil here — the
// bootstrap path's GenerateOutPath sets the destination per
// responder; the local issuer's existing keystore.ensureKeyDirSecure
// equivalent is invoked by FileDriver.Generate when DirHardener is
// supplied at the call site.
crlCacheRepo := postgres.NewCRLCacheRepository(db)
ocspResponderRepo := postgres.NewOCSPResponderRepository(db)
signerDriver := &signer.FileDriver{}
issuerRegistry.SetLocalIssuerDeps(&service.LocalIssuerDeps{
OCSPResponderRepo: ocspResponderRepo,
SignerDriver: signerDriver,
KeyDir: cfg.OCSPResponder.KeyDir,
RotationGrace: cfg.OCSPResponder.RotationGrace,
Validity: cfg.OCSPResponder.Validity,
})
crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger)
// Wire sub-services into CertificateService
certificateService.SetRevocationSvc(revocationSvc)
certificateService.SetCAOperationsSvc(caOperationsSvc)
// CRL cache makes GenerateDERCRL serve from the pre-generated cache
// instead of regenerating per request (CRL/OCSP-Responder Phase 4).
certificateService.SetCRLCacheSvc(crlCacheService)
certificateService.SetTargetRepo(targetRepo)
certificateService.SetJobRepo(jobRepo)
certificateService.SetKeygenMode(cfg.Keygen.Mode)
@@ -570,6 +600,19 @@ func main() {
// here alongside the other scheduler-interval setters so the
// documented env var actually takes effect.
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
// CRL/OCSP-Responder Phase 3: drive the crlGenerationLoop. The cache
// service walks every issuer in the registry, regenerates the CRL,
// and persists into crl_cache. The HTTP /.well-known/pki/crl/ handler
// reads from the cache via certificateService.GenerateDERCRL (which
// consults crlCacheService when wired). The loop is gated on the
// service being non-nil, mirroring how digestService and others are
// wired conditionally below.
sched.SetCRLCacheService(crlCacheService)
sched.SetCRLGenerationInterval(cfg.Scheduler.CRLGenerationInterval)
logger.Info("CRL pre-generation scheduler enabled",
"interval", cfg.Scheduler.CRLGenerationInterval.String())
if cfg.NetworkScan.Enabled {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
@@ -611,28 +654,28 @@ func main() {
// 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,
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,
BulkRenewal: bulkRenewalHandler,
BulkReassignment: bulkReassignmentHandler,
@@ -1104,7 +1147,7 @@ func preflightEnrollmentIssuer(ctx context.Context, protocol, issuerID string, i
// - /api/v1/* → auth (Bearer token required)
// - /assets/* → static file server (dashboard only)
// - anything else → SPA index.html fallback (dashboard only)
// OR apiHandler (no dashboard)
// 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
+46
View File
@@ -40,6 +40,34 @@ type Config struct {
HealthCheck HealthCheckConfig
Encryption EncryptionConfig
CloudDiscovery CloudDiscoveryConfig
OCSPResponder OCSPResponderConfig
}
// OCSPResponderConfig configures the dedicated OCSP-responder cert
// per issuer (RFC 6960 §2.6 + §4.2.2.2). When unset, the local issuer
// falls back to signing OCSP responses with the CA key directly.
//
// Bundle CRL/OCSP-Responder Phase 2.
type OCSPResponderConfig struct {
// KeyDir is the filesystem directory where FileDriver-backed
// responder keys are written. Operators MUST set this in
// production (the default of "" maps to cwd, which is fine for
// tests but not for serious deployments).
// Setting: CERTCTL_OCSP_RESPONDER_KEY_DIR.
KeyDir string
// RotationGrace is the window before NotAfter at which the
// responder cert is rotated. Default: 7 days. Operators with
// stricter relying-party caching expectations may shorten;
// operators with looser ones may lengthen.
// Setting: CERTCTL_OCSP_RESPONDER_ROTATION_GRACE.
RotationGrace time.Duration
// Validity is how long a freshly-bootstrapped responder cert is
// valid for. Default: 30 days. Shorter validity means more
// frequent rotations + smaller revocation-list windows.
// Setting: CERTCTL_OCSP_RESPONDER_VALIDITY.
Validity time.Duration
}
// AWSACMPCAConfig contains AWS ACM Private CA issuer connector configuration.
@@ -806,6 +834,14 @@ type SchedulerConfig struct {
// had no path. Post-C-1 main.go wires this knob.
// Setting: CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL environment variable.
ShortLivedExpiryCheckInterval time.Duration
// CRLGenerationInterval is how often the scheduler pre-generates
// CRLs into the crl_cache table. The /.well-known/pki/crl/{issuer_id}
// HTTP endpoint reads from this cache instead of regenerating per
// request. Default: 1 hour.
// Setting: CERTCTL_CRL_GENERATION_INTERVAL environment variable.
// Bundle CRL/OCSP-Responder Phase 3.
CRLGenerationInterval time.Duration
}
// LogConfig contains logging configuration.
@@ -1015,6 +1051,11 @@ func Load() (*Config, error) {
// C-1 closure: matches the in-memory default at
// internal/scheduler/scheduler.go:145 (30 * time.Second).
ShortLivedExpiryCheckInterval: getEnvDuration("CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL", 30*time.Second),
// CRL/OCSP-Responder Phase 3: pre-generation cadence.
// Default 1h matches the in-scheduler default; relying-party
// CRL refresh expectations under RFC 5280 are typically
// hourly to daily, so 1h gives operators plenty of margin.
CRLGenerationInterval: getEnvDuration("CERTCTL_CRL_GENERATION_INTERVAL", 1*time.Hour),
},
Log: LogConfig{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
@@ -1194,6 +1235,11 @@ func Load() (*Config, error) {
Credentials: getEnv("CERTCTL_GCP_SM_CREDENTIALS", ""),
},
},
OCSPResponder: OCSPResponderConfig{
KeyDir: getEnv("CERTCTL_OCSP_RESPONDER_KEY_DIR", ""),
RotationGrace: getEnvDuration("CERTCTL_OCSP_RESPONDER_ROTATION_GRACE", 7*24*time.Hour),
Validity: getEnvDuration("CERTCTL_OCSP_RESPONDER_VALIDITY", 30*24*time.Hour),
},
}
// Parse CERTCTL_API_KEYS_NAMED for named key authentication (M-002).
+63
View File
@@ -5,10 +5,14 @@ import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer/local"
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
"github.com/shankar0123/certctl/internal/crypto"
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// IssuerRegistry is a thread-safe registry of issuer connectors.
@@ -18,6 +22,29 @@ type IssuerRegistry struct {
mu sync.RWMutex
issuers map[string]IssuerConnector
logger *slog.Logger
// localDeps, when set, is injected into every *local.Connector
// constructed by Rebuild via SetOCSPResponderRepo + SetSignerDriver
// + SetIssuerID + SetOCSPResponderKeyDir. Wires the dedicated OCSP
// responder cert flow (RFC 6960 §2.6); see Bundle CRL/OCSP-Responder
// Phase 2. When unset, local connectors fall back to signing OCSP
// with the CA key directly (the historical behaviour, preserved for
// callers that don't supply these deps).
localDeps *LocalIssuerDeps
}
// LocalIssuerDeps groups the optional dependencies that the local
// issuer needs for the dedicated OCSP responder cert flow. All fields
// are required when localDeps is set on the registry; nil-checking
// individual fields would partially-initialize the responder path
// which is worse than the all-or-nothing fallback to direct CA-key
// signing.
type LocalIssuerDeps struct {
OCSPResponderRepo repository.OCSPResponderRepository
SignerDriver signer.Driver
KeyDir string // where FileDriver-backed responder keys land
RotationGrace time.Duration // optional override; default 7d if zero
Validity time.Duration // optional override; default 30d if zero
}
// NewIssuerRegistry creates a new empty issuer registry.
@@ -28,6 +55,17 @@ func NewIssuerRegistry(logger *slog.Logger) *IssuerRegistry {
}
}
// SetLocalIssuerDeps configures the per-local-connector dependencies
// applied by Rebuild. Must be called before BuildRegistry / Rebuild
// so the deps are in place when local connectors are constructed.
//
// Bundle CRL/OCSP-Responder Phase 2.
func (r *IssuerRegistry) SetLocalIssuerDeps(deps *LocalIssuerDeps) {
r.mu.Lock()
defer r.mu.Unlock()
r.localDeps = deps
}
// Get returns the issuer connector for the given ID and whether it exists.
func (r *IssuerRegistry) Get(id string) (IssuerConnector, bool) {
r.mu.RLock()
@@ -109,6 +147,31 @@ func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey string)
continue
}
// Bundle CRL/OCSP-Responder Phase 2: when local deps are
// configured on the registry, inject them into every freshly-
// constructed *local.Connector so its SignOCSPResponse takes
// the dedicated responder cert path. Type-assert is the
// pragmatic seam — the factory returns issuer.Connector so
// this is the only place that knows what concrete type was
// just built.
if localConn, ok := connector.(*local.Connector); ok && r.localDeps != nil {
localConn.SetIssuerID(cfg.ID)
localConn.SetOCSPResponderRepo(r.localDeps.OCSPResponderRepo)
localConn.SetSignerDriver(r.localDeps.SignerDriver)
if r.localDeps.KeyDir != "" {
localConn.SetOCSPResponderKeyDir(r.localDeps.KeyDir)
}
if r.localDeps.RotationGrace > 0 {
localConn.SetOCSPResponderRotationGrace(r.localDeps.RotationGrace)
}
if r.localDeps.Validity > 0 {
localConn.SetOCSPResponderValidity(r.localDeps.Validity)
}
r.logger.Info("local issuer wired with dedicated OCSP responder deps",
"id", cfg.ID,
"key_dir", r.localDeps.KeyDir)
}
newIssuers[cfg.ID] = NewIssuerConnectorAdapter(connector)
r.logger.Info("issuer loaded into registry", "id", cfg.ID, "type", cfg.Type)
}