mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
9c1d446e40
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'.
1496 lines
60 KiB
Go
1496 lines
60 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config represents the complete application configuration.
|
|
// All configuration values are read from environment variables with CERTCTL_ prefix.
|
|
type Config struct {
|
|
Server ServerConfig
|
|
Database DatabaseConfig
|
|
Scheduler SchedulerConfig
|
|
Log LogConfig
|
|
Auth AuthConfig
|
|
RateLimit RateLimitConfig
|
|
CORS CORSConfig
|
|
Keygen KeygenConfig
|
|
CA CAConfig
|
|
Notifiers NotifierConfig
|
|
NetworkScan NetworkScanConfig
|
|
EST ESTConfig
|
|
SCEP SCEPConfig
|
|
Verification VerificationConfig
|
|
ACME ACMEConfig
|
|
Vault VaultConfig
|
|
DigiCert DigiCertConfig
|
|
Sectigo SectigoConfig
|
|
GoogleCAS GoogleCASConfig
|
|
AWSACMPCA AWSACMPCAConfig
|
|
Entrust EntrustConfig
|
|
GlobalSign GlobalSignConfig
|
|
EJBCA EJBCAConfig
|
|
Digest DigestConfig
|
|
HealthCheck HealthCheckConfig
|
|
Encryption EncryptionConfig
|
|
CloudDiscovery CloudDiscoveryConfig
|
|
}
|
|
|
|
// AWSACMPCAConfig contains AWS ACM Private CA issuer connector configuration.
|
|
type AWSACMPCAConfig struct {
|
|
// Region is the AWS region where the Private CA resides (e.g., "us-east-1").
|
|
// Required for AWS ACM PCA integration.
|
|
// Setting: CERTCTL_AWS_PCA_REGION environment variable.
|
|
Region string
|
|
|
|
// CAArn is the ARN of the ACM Private CA certificate authority.
|
|
// Format: arn:aws:acm-pca:<region>:<account>:certificate-authority/<id>
|
|
// Required for AWS ACM PCA integration.
|
|
// Setting: CERTCTL_AWS_PCA_CA_ARN environment variable.
|
|
CAArn string
|
|
|
|
// SigningAlgorithm is the signing algorithm for certificate issuance.
|
|
// Valid: SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA, SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA.
|
|
// Default: "SHA256WITHRSA".
|
|
// Setting: CERTCTL_AWS_PCA_SIGNING_ALGORITHM environment variable.
|
|
SigningAlgorithm string
|
|
|
|
// ValidityDays is the certificate validity period in days.
|
|
// Default: 365.
|
|
// Setting: CERTCTL_AWS_PCA_VALIDITY_DAYS environment variable.
|
|
ValidityDays int
|
|
|
|
// TemplateArn is the optional ARN of an ACM PCA certificate template.
|
|
// Used for constrained subordinate CAs or custom certificate profiles.
|
|
// Setting: CERTCTL_AWS_PCA_TEMPLATE_ARN environment variable.
|
|
TemplateArn string
|
|
}
|
|
|
|
// EntrustConfig contains Entrust Certificate Services issuer connector configuration.
|
|
// Entrust uses mTLS client certificate authentication.
|
|
type EntrustConfig struct {
|
|
// APIUrl is the Entrust CA Gateway base URL.
|
|
// Setting: CERTCTL_ENTRUST_API_URL environment variable.
|
|
APIUrl string
|
|
|
|
// ClientCertPath is the path to the mTLS client certificate PEM file.
|
|
// Setting: CERTCTL_ENTRUST_CLIENT_CERT_PATH environment variable.
|
|
ClientCertPath string
|
|
|
|
// ClientKeyPath is the path to the mTLS client private key PEM file.
|
|
// Setting: CERTCTL_ENTRUST_CLIENT_KEY_PATH environment variable.
|
|
ClientKeyPath string
|
|
|
|
// CAId is the Entrust CA identifier.
|
|
// Setting: CERTCTL_ENTRUST_CA_ID environment variable.
|
|
CAId string
|
|
|
|
// ProfileId is the optional enrollment profile identifier.
|
|
// Setting: CERTCTL_ENTRUST_PROFILE_ID environment variable.
|
|
ProfileId string
|
|
}
|
|
|
|
// GlobalSignConfig contains GlobalSign Atlas HVCA issuer connector configuration.
|
|
// GlobalSign uses mTLS client certificate authentication plus API key/secret headers.
|
|
type GlobalSignConfig struct {
|
|
// APIUrl is the GlobalSign Atlas HVCA base URL (region-aware).
|
|
// Setting: CERTCTL_GLOBALSIGN_API_URL environment variable.
|
|
APIUrl string
|
|
|
|
// APIKey is the GlobalSign API key.
|
|
// Setting: CERTCTL_GLOBALSIGN_API_KEY environment variable.
|
|
APIKey string
|
|
|
|
// APISecret is the GlobalSign API secret.
|
|
// Setting: CERTCTL_GLOBALSIGN_API_SECRET environment variable.
|
|
APISecret string
|
|
|
|
// ClientCertPath is the path to the mTLS client certificate PEM file.
|
|
// Setting: CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH environment variable.
|
|
ClientCertPath string
|
|
|
|
// ClientKeyPath is the path to the mTLS client private key PEM file.
|
|
// Setting: CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH environment variable.
|
|
ClientKeyPath string
|
|
|
|
// ServerCAPath is the optional path to a PEM file containing the CA
|
|
// certificate(s) used to verify the GlobalSign Atlas HVCA API server
|
|
// certificate. If empty, the system trust store is used. Set this
|
|
// for private/lab Atlas deployments whose server TLS chain is not
|
|
// present in the host's default trust bundle.
|
|
// Setting: CERTCTL_GLOBALSIGN_SERVER_CA_PATH environment variable.
|
|
ServerCAPath string
|
|
}
|
|
|
|
// EJBCAConfig contains EJBCA (Keyfactor) issuer connector configuration.
|
|
// EJBCA supports dual authentication: mTLS or OAuth2 Bearer token.
|
|
type EJBCAConfig struct {
|
|
// APIUrl is the EJBCA REST API base URL.
|
|
// Setting: CERTCTL_EJBCA_API_URL environment variable.
|
|
APIUrl string
|
|
|
|
// AuthMode selects the authentication method: "mtls" or "oauth2". Default: "mtls".
|
|
// Setting: CERTCTL_EJBCA_AUTH_MODE environment variable.
|
|
AuthMode string
|
|
|
|
// ClientCertPath is the path to the mTLS client certificate PEM file (required when auth_mode=mtls).
|
|
// Setting: CERTCTL_EJBCA_CLIENT_CERT_PATH environment variable.
|
|
ClientCertPath string
|
|
|
|
// ClientKeyPath is the path to the mTLS client private key PEM file (required when auth_mode=mtls).
|
|
// Setting: CERTCTL_EJBCA_CLIENT_KEY_PATH environment variable.
|
|
ClientKeyPath string
|
|
|
|
// Token is the OAuth2 Bearer token (required when auth_mode=oauth2).
|
|
// Setting: CERTCTL_EJBCA_TOKEN environment variable.
|
|
Token string
|
|
|
|
// CAName is the EJBCA CA name. Required.
|
|
// Setting: CERTCTL_EJBCA_CA_NAME environment variable.
|
|
CAName string
|
|
|
|
// CertProfile is the optional EJBCA certificate profile name.
|
|
// Setting: CERTCTL_EJBCA_CERT_PROFILE environment variable.
|
|
CertProfile string
|
|
|
|
// EEProfile is the optional EJBCA end-entity profile name.
|
|
// Setting: CERTCTL_EJBCA_EE_PROFILE environment variable.
|
|
EEProfile string
|
|
}
|
|
|
|
// EncryptionConfig contains configuration for encrypting sensitive data at rest.
|
|
type EncryptionConfig struct {
|
|
// ConfigEncryptionKey is the passphrase used to derive AES-256-GCM keys for encrypting
|
|
// issuer config secrets in the database. If empty, configs are stored in plaintext (development only).
|
|
ConfigEncryptionKey string
|
|
}
|
|
|
|
// CloudDiscoveryConfig contains configuration for cloud secret manager discovery sources.
|
|
// Each source is enabled by setting its required env var(s).
|
|
type CloudDiscoveryConfig struct {
|
|
// Enabled controls whether cloud discovery sources run on a schedule.
|
|
// Default: false. Setting: CERTCTL_CLOUD_DISCOVERY_ENABLED.
|
|
Enabled bool
|
|
|
|
// Interval is the scheduler loop interval for cloud discovery.
|
|
// Default: 6 hours. Setting: CERTCTL_CLOUD_DISCOVERY_INTERVAL.
|
|
Interval time.Duration
|
|
|
|
// AWS Secrets Manager discovery
|
|
AWSSM AWSSecretsMgrDiscoveryConfig
|
|
|
|
// Azure Key Vault discovery
|
|
AzureKV AzureKVDiscoveryConfig
|
|
|
|
// GCP Secret Manager discovery
|
|
GCPSM GCPSecretMgrDiscoveryConfig
|
|
}
|
|
|
|
// AWSSecretsMgrDiscoveryConfig contains AWS Secrets Manager discovery settings.
|
|
type AWSSecretsMgrDiscoveryConfig struct {
|
|
// Enabled controls whether AWS SM discovery is active.
|
|
// Default: false. Setting: CERTCTL_AWS_SM_DISCOVERY_ENABLED.
|
|
Enabled bool
|
|
|
|
// Region is the AWS region to scan (e.g., "us-east-1").
|
|
// Setting: CERTCTL_AWS_SM_REGION.
|
|
Region string
|
|
|
|
// TagFilter is the tag key=value used to identify certificate secrets.
|
|
// Default: "type=certificate". Setting: CERTCTL_AWS_SM_TAG_FILTER.
|
|
TagFilter string
|
|
|
|
// NamePrefix filters secrets by name prefix (optional).
|
|
// Setting: CERTCTL_AWS_SM_NAME_PREFIX.
|
|
NamePrefix string
|
|
}
|
|
|
|
// AzureKVDiscoveryConfig contains Azure Key Vault discovery settings.
|
|
type AzureKVDiscoveryConfig struct {
|
|
// Enabled controls whether Azure KV discovery is active.
|
|
// Default: false. Setting: CERTCTL_AZURE_KV_DISCOVERY_ENABLED.
|
|
Enabled bool
|
|
|
|
// VaultURL is the Azure Key Vault URL (e.g., "https://myvault.vault.azure.net").
|
|
// Setting: CERTCTL_AZURE_KV_VAULT_URL.
|
|
VaultURL string
|
|
|
|
// TenantID is the Azure AD tenant ID.
|
|
// Setting: CERTCTL_AZURE_KV_TENANT_ID.
|
|
TenantID string
|
|
|
|
// ClientID is the Azure AD application (client) ID.
|
|
// Setting: CERTCTL_AZURE_KV_CLIENT_ID.
|
|
ClientID string
|
|
|
|
// ClientSecret is the Azure AD application secret.
|
|
// Setting: CERTCTL_AZURE_KV_CLIENT_SECRET.
|
|
ClientSecret string
|
|
}
|
|
|
|
// GCPSecretMgrDiscoveryConfig contains GCP Secret Manager discovery settings.
|
|
type GCPSecretMgrDiscoveryConfig struct {
|
|
// Enabled controls whether GCP SM discovery is active.
|
|
// Default: false. Setting: CERTCTL_GCP_SM_DISCOVERY_ENABLED.
|
|
Enabled bool
|
|
|
|
// Project is the GCP project ID.
|
|
// Setting: CERTCTL_GCP_SM_PROJECT.
|
|
Project string
|
|
|
|
// Credentials is the path to the GCP service account JSON file.
|
|
// Setting: CERTCTL_GCP_SM_CREDENTIALS.
|
|
Credentials string
|
|
}
|
|
|
|
// NotifierConfig contains configuration for notification connectors.
|
|
// Each notifier is enabled by setting its required env var (webhook URL or API key).
|
|
type NotifierConfig struct {
|
|
// SlackWebhookURL is the incoming webhook URL for Slack notifications.
|
|
// Format: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
|
|
// Optional: leave empty to disable Slack notifications.
|
|
SlackWebhookURL string
|
|
|
|
// SlackChannel optionally overrides the default channel in the Slack webhook.
|
|
// Example: "#alerts" or "@user". Leave empty to use webhook's default channel.
|
|
SlackChannel string
|
|
|
|
// SlackUsername sets the display name for Slack bot messages.
|
|
// Default: "certctl". Used in webhook message formatting.
|
|
SlackUsername string
|
|
|
|
// TeamsWebhookURL is the incoming webhook URL for Microsoft Teams notifications.
|
|
// Format: https://outlook.webhook.office.com/webhookb2/...
|
|
// Optional: leave empty to disable Teams notifications.
|
|
TeamsWebhookURL string
|
|
|
|
// PagerDutyRoutingKey is the integration key for PagerDuty Events API v2.
|
|
// Obtain from PagerDuty integration settings.
|
|
// Optional: leave empty to disable PagerDuty notifications.
|
|
PagerDutyRoutingKey string
|
|
|
|
// PagerDutySeverity sets the default severity level for PagerDuty events.
|
|
// Valid values: "info", "warning", "error", "critical". Default: "warning".
|
|
PagerDutySeverity string
|
|
|
|
// OpsGenieAPIKey is the API key for OpsGenie Alert API v2.
|
|
// Obtain from OpsGenie organization settings.
|
|
// Optional: leave empty to disable OpsGenie notifications.
|
|
OpsGenieAPIKey string
|
|
|
|
// OpsGeniePriority sets the default priority for OpsGenie alerts.
|
|
// Valid values: "P1", "P2", "P3", "P4", "P5". Default: "P3".
|
|
OpsGeniePriority string
|
|
|
|
// SMTPHost is the SMTP server hostname for sending email notifications.
|
|
// Example: "smtp.gmail.com", "smtp.sendgrid.net". Required for email notifications.
|
|
// Setting: CERTCTL_SMTP_HOST environment variable.
|
|
SMTPHost string
|
|
|
|
// SMTPPort is the SMTP server port. Default: 587 (STARTTLS).
|
|
// Common values: 25 (plain), 465 (implicit TLS), 587 (STARTTLS).
|
|
// Setting: CERTCTL_SMTP_PORT environment variable.
|
|
SMTPPort int
|
|
|
|
// SMTPUsername is the SMTP authentication username.
|
|
// Setting: CERTCTL_SMTP_USERNAME environment variable.
|
|
SMTPUsername string
|
|
|
|
// SMTPPassword is the SMTP authentication password or app-specific password.
|
|
// Setting: CERTCTL_SMTP_PASSWORD environment variable.
|
|
SMTPPassword string
|
|
|
|
// SMTPFromAddress is the sender email address for outbound notifications.
|
|
// Example: "certctl@example.com", "noreply@company.com".
|
|
// Setting: CERTCTL_SMTP_FROM_ADDRESS environment variable.
|
|
SMTPFromAddress string
|
|
|
|
// SMTPUseTLS enables TLS for the SMTP connection.
|
|
// Default: true. Set to false for plain SMTP (not recommended).
|
|
// Setting: CERTCTL_SMTP_USE_TLS environment variable.
|
|
SMTPUseTLS bool
|
|
}
|
|
|
|
// KeygenConfig controls where private keys are generated.
|
|
type KeygenConfig struct {
|
|
// Mode determines where certificate private keys are generated.
|
|
// Valid values: "agent" (default, production) or "server" (demo only).
|
|
// In "agent" mode, renewal/issuance jobs enter AwaitingCSR state and agents
|
|
// generate ECDSA P-256 keys locally. Private keys never leave agent infrastructure.
|
|
// In "server" mode, the control plane generates RSA keys — demo only, not for production
|
|
// as private keys touch the server. Requires explicit opt-in.
|
|
Mode string
|
|
}
|
|
|
|
// CAConfig controls the Local CA's operating mode.
|
|
type CAConfig struct {
|
|
// CertPath is the path to a PEM-encoded CA certificate for sub-CA mode.
|
|
// When set with KeyPath, the Local CA loads this cert instead of generating a self-signed root.
|
|
// Required: sub-CA mode must have both CertPath and KeyPath set.
|
|
// Optional: leave empty for self-signed mode (development/demo). Path must be absolute.
|
|
CertPath string
|
|
|
|
// KeyPath is the path to a PEM-encoded CA private key for sub-CA mode.
|
|
// Supports RSA, ECDSA, and PKCS#8 encoded keys.
|
|
// Required: must be set together with CertPath for sub-CA mode.
|
|
// Optional: leave empty for self-signed mode (development/demo). Path must be absolute.
|
|
KeyPath string
|
|
}
|
|
|
|
// StepCAConfig contains step-ca issuer connector configuration.
|
|
type StepCAConfig struct {
|
|
// URL is the base URL of the step-ca server.
|
|
// Example: "https://ca.example.com:9000". Required for step-ca integration.
|
|
URL string
|
|
|
|
// ProvisionerName is the name of the JWK provisioner configured in step-ca.
|
|
// Used to select which provisioner signs the certificate requests.
|
|
ProvisionerName string
|
|
|
|
// ProvisionerKeyPath is the path to the PEM-encoded JWK provisioner private key.
|
|
// Authenticates with the step-ca /sign API. Must be absolute path.
|
|
ProvisionerKeyPath string
|
|
|
|
// ProvisionerPassword is the optional password for the provisioner private key.
|
|
// Leave empty if the key file is not encrypted.
|
|
ProvisionerPassword string
|
|
}
|
|
|
|
// VaultConfig contains HashiCorp Vault PKI issuer connector configuration.
|
|
type VaultConfig struct {
|
|
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
|
|
// Required for Vault PKI integration.
|
|
// Setting: CERTCTL_VAULT_ADDR environment variable.
|
|
Addr string
|
|
|
|
// Token is the Vault token for authentication.
|
|
// Required for Vault PKI integration.
|
|
// Setting: CERTCTL_VAULT_TOKEN environment variable.
|
|
Token string
|
|
|
|
// Mount is the PKI secrets engine mount path.
|
|
// Default: "pki".
|
|
// Setting: CERTCTL_VAULT_MOUNT environment variable.
|
|
Mount string
|
|
|
|
// Role is the PKI role name used for signing certificates.
|
|
// Required for Vault PKI integration.
|
|
// Setting: CERTCTL_VAULT_ROLE environment variable.
|
|
Role string
|
|
|
|
// TTL is the requested certificate time-to-live.
|
|
// Default: "8760h" (1 year).
|
|
// Setting: CERTCTL_VAULT_TTL environment variable.
|
|
TTL string
|
|
}
|
|
|
|
// DigiCertConfig contains DigiCert CertCentral issuer connector configuration.
|
|
type DigiCertConfig struct {
|
|
// APIKey is the CertCentral API key for authentication.
|
|
// Required for DigiCert integration.
|
|
// Setting: CERTCTL_DIGICERT_API_KEY environment variable.
|
|
APIKey string
|
|
|
|
// OrgID is the DigiCert organization ID for certificate orders.
|
|
// Required for DigiCert integration.
|
|
// Setting: CERTCTL_DIGICERT_ORG_ID environment variable.
|
|
OrgID string
|
|
|
|
// ProductType is the DigiCert product type for certificate orders.
|
|
// Default: "ssl_basic". Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic".
|
|
// Setting: CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
|
|
ProductType string
|
|
|
|
// BaseURL is the DigiCert CertCentral API base URL.
|
|
// Default: "https://www.digicert.com/services/v2".
|
|
// Setting: CERTCTL_DIGICERT_BASE_URL environment variable.
|
|
BaseURL string
|
|
}
|
|
|
|
// SectigoConfig contains Sectigo Certificate Manager issuer connector configuration.
|
|
type SectigoConfig struct {
|
|
// CustomerURI is the Sectigo customer URI (organization identifier).
|
|
// Required for Sectigo integration.
|
|
// Setting: CERTCTL_SECTIGO_CUSTOMER_URI environment variable.
|
|
CustomerURI string
|
|
|
|
// Login is the Sectigo API account login.
|
|
// Required for Sectigo integration.
|
|
// Setting: CERTCTL_SECTIGO_LOGIN environment variable.
|
|
Login string
|
|
|
|
// Password is the Sectigo API account password or API key.
|
|
// Required for Sectigo integration.
|
|
// Setting: CERTCTL_SECTIGO_PASSWORD environment variable.
|
|
Password string
|
|
|
|
// OrgID is the Sectigo organization ID for certificate enrollments.
|
|
// Required for Sectigo integration.
|
|
// Setting: CERTCTL_SECTIGO_ORG_ID environment variable.
|
|
OrgID int
|
|
|
|
// CertType is the Sectigo certificate type ID (from GET /ssl/v1/types).
|
|
// Required for enrollment. Set via CERTCTL_SECTIGO_CERT_TYPE environment variable.
|
|
CertType int
|
|
|
|
// Term is the certificate validity in days (e.g., 365, 730).
|
|
// Default: 365.
|
|
// Setting: CERTCTL_SECTIGO_TERM environment variable.
|
|
Term int
|
|
|
|
// BaseURL is the Sectigo SCM API base URL.
|
|
// Default: "https://cert-manager.com/api".
|
|
// Setting: CERTCTL_SECTIGO_BASE_URL environment variable.
|
|
BaseURL string
|
|
}
|
|
|
|
// GoogleCASConfig contains Google Cloud Certificate Authority Service configuration.
|
|
type GoogleCASConfig struct {
|
|
// Project is the GCP project ID.
|
|
// Required for Google CAS integration.
|
|
// Setting: CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
|
Project string
|
|
|
|
// Location is the GCP region (e.g., "us-central1").
|
|
// Required for Google CAS integration.
|
|
// Setting: CERTCTL_GOOGLE_CAS_LOCATION environment variable.
|
|
Location string
|
|
|
|
// CAPool is the Certificate Authority pool name.
|
|
// Required for Google CAS integration.
|
|
// Setting: CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
|
|
CAPool string
|
|
|
|
// Credentials is the path to the service account JSON credentials file.
|
|
// Required for Google CAS integration.
|
|
// Setting: CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
|
|
Credentials string
|
|
|
|
// TTL is the default certificate time-to-live.
|
|
// Default: "8760h" (1 year).
|
|
// Setting: CERTCTL_GOOGLE_CAS_TTL environment variable.
|
|
TTL string
|
|
}
|
|
|
|
// DigestConfig controls the scheduled certificate digest email feature.
|
|
type DigestConfig struct {
|
|
// Enabled controls whether periodic digest emails are generated and sent.
|
|
// Default: false. When enabled, requires SMTP to be configured.
|
|
// Setting: CERTCTL_DIGEST_ENABLED environment variable.
|
|
Enabled bool
|
|
|
|
// Interval is how often digest emails are generated and sent.
|
|
// Default: 24 hours. Minimum: 1 hour.
|
|
// Setting: CERTCTL_DIGEST_INTERVAL environment variable.
|
|
Interval time.Duration
|
|
|
|
// Recipients is a comma-separated list of email addresses to receive digest emails.
|
|
// If empty, digests are sent to all certificate owners.
|
|
// Setting: CERTCTL_DIGEST_RECIPIENTS environment variable.
|
|
Recipients []string
|
|
}
|
|
|
|
// HealthCheckConfig contains configuration for continuous TLS health monitoring (M48).
|
|
type HealthCheckConfig struct {
|
|
// Enabled controls whether health checks are enabled.
|
|
// Default: false.
|
|
// Setting: CERTCTL_HEALTH_CHECK_ENABLED environment variable.
|
|
Enabled bool
|
|
|
|
// CheckInterval is the main scheduler loop interval for polling due checks.
|
|
// Default: 60 seconds. Each endpoint has its own check_interval_seconds.
|
|
// Setting: CERTCTL_HEALTH_CHECK_INTERVAL environment variable.
|
|
CheckInterval time.Duration
|
|
|
|
// DefaultInterval is the default probe interval in seconds for each endpoint (per-endpoint basis).
|
|
// Default: 300 seconds (5 minutes).
|
|
// Setting: CERTCTL_HEALTH_CHECK_DEFAULT_INTERVAL environment variable.
|
|
DefaultInterval int
|
|
|
|
// DefaultTimeout is the default TLS connection timeout in milliseconds.
|
|
// Default: 5000 milliseconds (5 seconds).
|
|
// Setting: CERTCTL_HEALTH_CHECK_DEFAULT_TIMEOUT environment variable.
|
|
DefaultTimeout int
|
|
|
|
// MaxConcurrent is the maximum number of concurrent TLS probes.
|
|
// Default: 20.
|
|
// Setting: CERTCTL_HEALTH_CHECK_MAX_CONCURRENT environment variable.
|
|
MaxConcurrent int
|
|
|
|
// HistoryRetention controls how long probe history records are kept.
|
|
// Default: 30 days. Older records are purged by the scheduler.
|
|
// Setting: CERTCTL_HEALTH_CHECK_HISTORY_RETENTION environment variable.
|
|
HistoryRetention time.Duration
|
|
|
|
// AutoCreate controls whether health checks are auto-created when:
|
|
// - A deployment job completes with verification success
|
|
// - A network scan target has health_check_enabled=true
|
|
// Default: true.
|
|
// Setting: CERTCTL_HEALTH_CHECK_AUTO_CREATE environment variable.
|
|
AutoCreate bool
|
|
}
|
|
|
|
// ACMEConfig contains ACME issuer connector configuration.
|
|
type ACMEConfig struct {
|
|
// DirectoryURL is the ACME directory URL for certificate issuance.
|
|
// Examples: "https://acme-v02.api.letsencrypt.org/directory" (Let's Encrypt),
|
|
// "https://acme.zerossl.com/v2/DV90" (ZeroSSL), or custom CA directory.
|
|
DirectoryURL string
|
|
|
|
// Email is the email address for ACME account registration.
|
|
// Used for certificate expiration notices and account recovery by ACME CA.
|
|
Email string
|
|
|
|
// ChallengeType selects the ACME challenge mechanism for domain validation.
|
|
// Valid values: "http-01" (default, requires public HTTP endpoint),
|
|
// "dns-01" (DNS TXT record per renewal), or "dns-persist-01" (standing DNS record).
|
|
// Default: "http-01".
|
|
ChallengeType string
|
|
|
|
// DNSPresentScript is the path to a shell script that creates DNS TXT records.
|
|
// Required for dns-01 and dns-persist-01 challenge types.
|
|
// Script receives these environment variables:
|
|
// - CERTCTL_DNS_DOMAIN: domain being validated (e.g., "example.com")
|
|
// - CERTCTL_DNS_FQDN: full record name (e.g., "_acme-challenge.example.com" or "_validation-persist.example.com")
|
|
// - CERTCTL_DNS_VALUE: TXT record value (key authorization digest for dns-01, or issuer domain info for dns-persist-01)
|
|
// - CERTCTL_DNS_TOKEN: ACME challenge token
|
|
// Example: /opt/dns-scripts/add-record.sh
|
|
DNSPresentScript string
|
|
|
|
// DNSCleanUpScript is the path to a shell script that removes DNS TXT records.
|
|
// Used only for dns-01 challenges to clean up temporary validation records.
|
|
// Script receives the same environment variables as DNSPresentScript.
|
|
// Leave empty if cleanup is not needed (e.g., dns-persist-01).
|
|
DNSCleanUpScript string
|
|
|
|
// DNSPersistIssuerDomain is the issuer domain for dns-persist-01 standing records.
|
|
// Example: "letsencrypt.org" or "zerossl.com". Only used if ChallengeType is "dns-persist-01".
|
|
// The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>"
|
|
DNSPersistIssuerDomain string
|
|
|
|
// Profile selects the ACME certificate profile for newOrder requests.
|
|
// Let's Encrypt supports "tlsserver" (standard TLS) and "shortlived" (6-day certs).
|
|
// Leave empty for the CA's default profile (backward-compatible).
|
|
// Setting: CERTCTL_ACME_PROFILE environment variable.
|
|
Profile string
|
|
|
|
// ARIEnabled enables ACME Renewal Information (RFC 9773) support.
|
|
// When enabled, the renewal scheduler queries the CA for suggested renewal windows
|
|
// instead of relying solely on static expiration thresholds.
|
|
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
|
|
// Setting: CERTCTL_ACME_ARI_ENABLED environment variable.
|
|
ARIEnabled bool
|
|
|
|
// Insecure skips TLS certificate verification when connecting to the ACME directory.
|
|
// Only use for testing with self-signed ACME servers like Pebble. Never in production.
|
|
// Setting: CERTCTL_ACME_INSECURE environment variable.
|
|
Insecure bool
|
|
}
|
|
|
|
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
|
type OpenSSLConfig struct {
|
|
// SignScript is the path to a shell script that signs certificate requests.
|
|
// Script receives: CSR_PATH, COMMON_NAME, OUTPUT_CERT_PATH as env vars.
|
|
// Must output the signed certificate PEM to OUTPUT_CERT_PATH.
|
|
// Example: /opt/ca-scripts/sign.sh
|
|
SignScript string
|
|
|
|
// RevokeScript is the path to a shell script that revokes certificates.
|
|
// Script receives: SERIAL_NUMBER, REASON_CODE as env vars.
|
|
// Best-effort: script failures do not block revocation recording.
|
|
// Leave empty if revocation is not supported by the custom CA.
|
|
RevokeScript string
|
|
|
|
// CRLScript is the path to a shell script that generates CRL (Certificate Revocation List).
|
|
// Script should output the DER-encoded CRL to stdout.
|
|
// Leave empty if CRL generation is not supported by the custom CA.
|
|
CRLScript string
|
|
|
|
// TimeoutSeconds is the maximum execution time for any shell script invocation.
|
|
// Default: 30 seconds. Prevents hung processes from blocking certificate operations.
|
|
TimeoutSeconds int
|
|
}
|
|
|
|
// ESTConfig controls the RFC 7030 Enrollment over Secure Transport server.
|
|
type ESTConfig struct {
|
|
// Enabled controls whether EST endpoints are available for device enrollment.
|
|
// Default: false (EST disabled). Set to true to enable RFC 7030 endpoints
|
|
// under /.well-known/est/ (cacerts, simpleenroll, simplereenroll, csrattrs).
|
|
Enabled bool
|
|
|
|
// IssuerID selects which issuer connector processes EST certificate requests.
|
|
// Valid values: "iss-local" (default), "iss-acme", "iss-stepca", "iss-openssl".
|
|
// Default: "iss-local". Must reference a configured issuer.
|
|
IssuerID string
|
|
|
|
// ProfileID optionally constrains EST enrollments to a specific certificate profile.
|
|
// When set, all EST enrollments must match the profile's crypto constraints.
|
|
// Leave empty to allow EST to use any configured issuer's defaults.
|
|
ProfileID string
|
|
}
|
|
|
|
// SCEPConfig controls the RFC 8894 Simple Certificate Enrollment Protocol server.
|
|
type SCEPConfig struct {
|
|
// Enabled controls whether SCEP endpoints are available for device enrollment.
|
|
// Default: false (SCEP disabled). Set to true to enable SCEP endpoints under /scep/.
|
|
Enabled bool
|
|
|
|
// IssuerID selects which issuer connector processes SCEP certificate requests.
|
|
// Default: "iss-local". Must reference a configured issuer.
|
|
IssuerID string
|
|
|
|
// ProfileID optionally constrains SCEP enrollments to a specific certificate profile.
|
|
// Leave empty to allow SCEP to use any configured issuer's defaults.
|
|
ProfileID string
|
|
|
|
// ChallengePassword is the shared secret used to authenticate SCEP enrollment requests.
|
|
// Clients include this in the PKCS#10 CSR challengePassword attribute.
|
|
//
|
|
// REQUIRED when Enabled is true. Config.Validate() below refuses to start the
|
|
// server if SCEP is enabled and this value is empty (H-2, CWE-306): post-M-001
|
|
// under option (D), the /scep endpoint rides the no-auth middleware chain per
|
|
// RFC 8894 §3.2, so the challenge password is the sole application-layer
|
|
// authentication boundary for SCEP enrollment. An empty shared secret would
|
|
// allow any client that can reach /scep to enroll a CSR against the configured
|
|
// issuer. The service-layer PKCSReq path also rejects this configuration
|
|
// defense-in-depth.
|
|
ChallengePassword string
|
|
}
|
|
|
|
// NetworkScanConfig controls the server-side active TLS scanner.
|
|
type NetworkScanConfig struct {
|
|
Enabled bool // Enable network scanning (default false)
|
|
ScanInterval time.Duration // How often to run network scans (default 6h)
|
|
}
|
|
|
|
// VerificationConfig controls post-deployment TLS verification behavior.
|
|
type VerificationConfig struct {
|
|
Enabled bool // Enable verification (default true)
|
|
Timeout time.Duration // Timeout for TLS probe (default 10s)
|
|
Delay time.Duration // Wait before verification after deployment (default 2s)
|
|
}
|
|
|
|
// ServerConfig contains HTTP server configuration.
|
|
type ServerConfig struct {
|
|
Host string // Server host (default: 127.0.0.1). Set via CERTCTL_SERVER_HOST.
|
|
Port int // Server port (default: 8080). Set via CERTCTL_SERVER_PORT.
|
|
MaxBodySize int64 // Maximum request body size in bytes (default: 1MB). Set via CERTCTL_MAX_BODY_SIZE.
|
|
TLS ServerTLSConfig // HTTPS-only TLS configuration. Both CertPath and KeyPath are required.
|
|
}
|
|
|
|
// ServerTLSConfig holds the server-side TLS material.
|
|
//
|
|
// The control plane is HTTPS-only as of the HTTPS-everywhere milestone
|
|
// (§3 locked decisions: no `http` mode, no dual-listener, TLS 1.3 only).
|
|
// Both CertPath and KeyPath are required; an empty value causes
|
|
// Config.Validate() to return a fail-loud error and the server refuses
|
|
// to start. There is no plaintext HTTP fallback, no N-release migration
|
|
// bridge, and no auto-generated self-signed cert — operators either
|
|
// supply a cert on disk (docker-compose init container, operator-managed
|
|
// file, cert-manager mount) or the process exits non-zero.
|
|
type ServerTLSConfig struct {
|
|
// CertPath is the filesystem path to the server's PEM-encoded X.509
|
|
// certificate. Set via CERTCTL_SERVER_TLS_CERT_PATH. Required.
|
|
CertPath string
|
|
|
|
// KeyPath is the filesystem path to the server's PEM-encoded private
|
|
// key that signs CertPath. Set via CERTCTL_SERVER_TLS_KEY_PATH. Required.
|
|
KeyPath string
|
|
}
|
|
|
|
// DatabaseConfig contains database connection configuration.
|
|
type DatabaseConfig struct {
|
|
URL string
|
|
MaxConnections int
|
|
MigrationsPath string
|
|
}
|
|
|
|
// SchedulerConfig contains scheduler timing configuration.
|
|
type SchedulerConfig struct {
|
|
// RenewalCheckInterval is how often the renewal scheduler checks for expiring certs.
|
|
// Default: 1 hour. Minimum: 1 minute. Certs are flagged for renewal at configured thresholds.
|
|
// Setting: CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL environment variable.
|
|
RenewalCheckInterval time.Duration
|
|
|
|
// JobProcessorInterval is how often the job scheduler processes pending jobs.
|
|
// Default: 30 seconds. Minimum: 1 second. Controls issuance, renewal, and deployment latency.
|
|
// Setting: CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL environment variable.
|
|
JobProcessorInterval time.Duration
|
|
|
|
// AgentHealthCheckInterval is how often the scheduler checks agent heartbeats.
|
|
// Default: 2 minutes. Minimum: 1 second. Marks agents offline if no recent heartbeat.
|
|
// Setting: CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL environment variable.
|
|
AgentHealthCheckInterval time.Duration
|
|
|
|
// NotificationProcessInterval is how often the scheduler processes pending notifications.
|
|
// Default: 1 minute. Minimum: 1 second. Sends notifications to Slack, Teams, PagerDuty, etc.
|
|
// Setting: CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL environment variable.
|
|
NotificationProcessInterval time.Duration
|
|
|
|
// NotificationRetryInterval is how often the scheduler retries failed
|
|
// notifications whose retry_count is below the service-layer 5-attempt
|
|
// DLQ budget. Default: 2 minutes. Minimum: 1 second. Mirrors the I-001
|
|
// RetryInterval knob: transitions eligible Failed notifications whose
|
|
// next_retry_at has arrived back to Pending so the notification processor
|
|
// picks them up on its next tick (closes coverage gap I-005 — HEAD had
|
|
// no retry path for transient SMTP/webhook failures and notifications
|
|
// stayed Failed forever).
|
|
// Setting: CERTCTL_NOTIFICATION_RETRY_INTERVAL environment variable.
|
|
NotificationRetryInterval time.Duration
|
|
|
|
// RetryInterval is how often the scheduler retries failed jobs whose Attempts
|
|
// counter is below MaxAttempts. Default: 5 minutes. Minimum: 1 second.
|
|
// Transitions eligible Failed jobs back to Pending so the job processor can
|
|
// pick them up again (closes coverage gap I-001 — JobService.RetryFailedJobs
|
|
// had no caller prior to this loop being wired).
|
|
// Setting: CERTCTL_SCHEDULER_RETRY_INTERVAL environment variable.
|
|
RetryInterval time.Duration
|
|
|
|
// JobTimeoutInterval is how often the reaper loop sweeps AwaitingCSR and
|
|
// AwaitingApproval jobs for TTL expiration. Default: 10 minutes. Minimum: 1
|
|
// second. Timed-out jobs are transitioned to Failed with a descriptive error
|
|
// message; I-001's retry loop then auto-promotes eligible Failed jobs back
|
|
// to Pending (closes coverage gap I-003).
|
|
// Setting: CERTCTL_JOB_TIMEOUT_INTERVAL environment variable.
|
|
JobTimeoutInterval time.Duration
|
|
|
|
// AwaitingCSRTimeout is the maximum age an AwaitingCSR job can remain in
|
|
// that state before the reaper transitions it to Failed. Default: 24 hours.
|
|
// An agent that hasn't submitted a CSR within this window is presumed
|
|
// unreachable. Minimum: 1 second.
|
|
// Setting: CERTCTL_JOB_AWAITING_CSR_TIMEOUT environment variable.
|
|
AwaitingCSRTimeout time.Duration
|
|
|
|
// AwaitingApprovalTimeout is the maximum age an AwaitingApproval job can
|
|
// remain in that state before the reaper transitions it to Failed. Default:
|
|
// 168 hours (7 days). Reviewers who haven't approved within this window
|
|
// force the renewal to fail loudly rather than silently stall. Minimum: 1
|
|
// second.
|
|
// Setting: CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT environment variable.
|
|
AwaitingApprovalTimeout time.Duration
|
|
}
|
|
|
|
// LogConfig contains logging configuration.
|
|
type LogConfig struct {
|
|
// Level sets the minimum log level for output.
|
|
// Valid values: "debug" (verbose), "info" (default), "warn" (warnings), "error" (errors only).
|
|
// Setting: CERTCTL_LOG_LEVEL environment variable. Default: "info".
|
|
Level string
|
|
|
|
// Format sets the output format for logs.
|
|
// Valid values: "json" (structured, for parsing), "text" (human-readable).
|
|
// Setting: CERTCTL_LOG_FORMAT environment variable. Default: "json".
|
|
Format string
|
|
}
|
|
|
|
// NamedAPIKey represents a single named API key with an optional admin flag.
|
|
// Named keys allow real actor attribution in the audit trail (M-002) and provide
|
|
// the admin-gate basis for privileged endpoints like bulk revocation (M-003).
|
|
type NamedAPIKey struct {
|
|
// Name is the identifier for the key (alphanumeric, hyphens, underscores).
|
|
// This value is recorded as the actor on every audit event the key authenticates.
|
|
Name string
|
|
// Key is the raw API-key secret the client presents as `Authorization: Bearer <key>`.
|
|
Key string
|
|
// Admin controls whether the key has admin privileges (bulk revocation, etc.).
|
|
Admin bool
|
|
}
|
|
|
|
// AuthType is the discriminator for the API auth middleware shape. The
|
|
// string alias preserves env-var roundtrip (the value flows through getEnv
|
|
// as a plain string) while giving us a typed surface for switches and
|
|
// validation. Use the named constants below rather than string literals
|
|
// so future enum additions/removals are caught at compile time.
|
|
//
|
|
// G-1 (P1): the pre-G-1 validAuthTypes map literal accepted "jwt" with no
|
|
// JWT middleware behind it (silent auth downgrade — the configured type
|
|
// was logged as "jwt" but every request routed through the api-key bearer
|
|
// middleware regardless). Operators who set CERTCTL_AUTH_TYPE=jwt thought
|
|
// they had JWT auth; they didn't. The typed alias + ValidAuthTypes()
|
|
// helper make the allowed set the single source of truth across config
|
|
// validation, the runtime defense-in-depth switch in main.go, and the
|
|
// helm-chart template guard (`certctl.validateAuthType`).
|
|
type AuthType string
|
|
|
|
const (
|
|
// AuthTypeAPIKey routes requests through the api-key bearer middleware.
|
|
// CERTCTL_AUTH_SECRET (or CERTCTL_API_KEYS_NAMED) is required.
|
|
AuthTypeAPIKey AuthType = "api-key"
|
|
|
|
// AuthTypeNone disables authentication entirely. Development only —
|
|
// the server logs a loud Warn at startup. Operators who need
|
|
// JWT/OIDC/mTLS run an authenticating gateway (oauth2-proxy / Envoy
|
|
// ext_authz / Traefik ForwardAuth / Pomerium) in front of certctl
|
|
// and set this value on the upstream certctl process. See
|
|
// docs/architecture.md "Authenticating-gateway pattern".
|
|
AuthTypeNone AuthType = "none"
|
|
)
|
|
|
|
// ValidAuthTypes returns the allowed CERTCTL_AUTH_TYPE values. The set is
|
|
// intentionally narrow — JWT was accepted pre-G-1 with no middleware
|
|
// implementation behind it. Single source of truth referenced by the
|
|
// validator below, the runtime guard in cmd/server/main.go, the helm
|
|
// chart template (`certctl.validateAuthType`), and the property test in
|
|
// config_test.go that pins "jwt" out of the slice forever.
|
|
func ValidAuthTypes() []AuthType {
|
|
return []AuthType{AuthTypeAPIKey, AuthTypeNone}
|
|
}
|
|
|
|
// AuthConfig contains authentication configuration.
|
|
type AuthConfig struct {
|
|
// Type sets the authentication mechanism for the REST API.
|
|
// Valid values: "api-key" (default, production) and "none" (development
|
|
// only — disables authentication on the API and logs a loud Warn at
|
|
// startup). For JWT/OIDC, run an authenticating gateway (oauth2-proxy /
|
|
// Envoy / 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.
|
|
// Setting: CERTCTL_AUTH_TYPE environment variable. Default: "api-key".
|
|
// Use the AuthType constants (AuthTypeAPIKey / AuthTypeNone) for typed
|
|
// comparisons; the field stays `string` to preserve env-var roundtrip
|
|
// shape used by getEnv() and downstream Helm/compose interpolation.
|
|
Type string
|
|
|
|
// Secret is the legacy authentication secret (comma-separated API keys).
|
|
// DEPRECATED in favor of NamedKeys — retained for backward compatibility.
|
|
// When NamedKeys is empty and Secret is set, each comma-separated key is
|
|
// registered as a synthesized named key (legacy-key-0, legacy-key-1, ...)
|
|
// with actor attribution defaulting to "legacy-key-<index>".
|
|
// Setting: CERTCTL_AUTH_SECRET environment variable.
|
|
Secret string
|
|
|
|
// NamedKeys is the parsed set of named API keys. Populated from
|
|
// CERTCTL_API_KEYS_NAMED via ParseNamedAPIKeys during Load(). When
|
|
// non-empty, this takes precedence over the legacy Secret field.
|
|
// Setting: CERTCTL_API_KEYS_NAMED="name1:key1,name2:key2:admin"
|
|
NamedKeys []NamedAPIKey
|
|
}
|
|
|
|
// RateLimitConfig contains rate limiting configuration.
|
|
type RateLimitConfig struct {
|
|
// Enabled controls whether rate limiting is enforced on API endpoints.
|
|
// Default: true. Set to false to disable rate limits (not recommended for production).
|
|
// Setting: CERTCTL_RATE_LIMIT_ENABLED environment variable.
|
|
Enabled bool
|
|
|
|
// RPS is the target requests per second allowed per client (token bucket rate).
|
|
// Default: 50. Higher values allow burst throughput; lower values restrict load.
|
|
// Setting: CERTCTL_RATE_LIMIT_RPS environment variable.
|
|
RPS float64
|
|
|
|
// BurstSize is the maximum number of requests allowed in a single burst.
|
|
// Default: 100. Allows clients to exceed RPS briefly when BurstSize tokens available.
|
|
// Must be at least as large as RPS. Higher = more lenient burst handling.
|
|
// Setting: CERTCTL_RATE_LIMIT_BURST environment variable.
|
|
BurstSize int
|
|
}
|
|
|
|
// CORSConfig contains CORS configuration.
|
|
type CORSConfig struct {
|
|
// AllowedOrigins is a list of allowed origins for CORS requests.
|
|
// Security default: empty list denies all CORS requests (same-origin only).
|
|
// ["*"] allows all origins (development/demo mode only, security risk).
|
|
// Specific origins (e.g., ["https://app.example.com"]) whitelist only those origins.
|
|
AllowedOrigins []string
|
|
}
|
|
|
|
// Load reads configuration from environment variables and returns a Config.
|
|
// Environment variables must have the CERTCTL_ prefix.
|
|
// Example: CERTCTL_SERVER_HOST, CERTCTL_DATABASE_URL, etc.
|
|
func Load() (*Config, error) {
|
|
cfg := &Config{
|
|
Server: ServerConfig{
|
|
Host: getEnv("CERTCTL_SERVER_HOST", "127.0.0.1"),
|
|
Port: getEnvInt("CERTCTL_SERVER_PORT", 8080),
|
|
MaxBodySize: getEnvInt64("CERTCTL_MAX_BODY_SIZE", 1024*1024), // 1MB default
|
|
// HTTPS-everywhere milestone §2.1: both paths REQUIRED. Empty defaults
|
|
// are intentional so Validate() emits a fail-loud error pointing at
|
|
// docs/tls.md rather than silently binding plaintext HTTP.
|
|
TLS: ServerTLSConfig{
|
|
CertPath: getEnv("CERTCTL_SERVER_TLS_CERT_PATH", ""),
|
|
KeyPath: getEnv("CERTCTL_SERVER_TLS_KEY_PATH", ""),
|
|
},
|
|
},
|
|
Database: DatabaseConfig{
|
|
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
|
|
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25),
|
|
MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"),
|
|
},
|
|
Scheduler: SchedulerConfig{
|
|
RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour),
|
|
JobProcessorInterval: getEnvDuration("CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL", 30*time.Second),
|
|
AgentHealthCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL", 2*time.Minute),
|
|
NotificationProcessInterval: getEnvDuration("CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL", 1*time.Minute),
|
|
// I-005: retry sweep for failed notifications. Mirrors RetryInterval
|
|
// (I-001 job retry) but scoped to the notification DLQ machinery.
|
|
// Default 2 minutes — fast enough to absorb transient SMTP/webhook
|
|
// blips, slow enough to respect the service-layer 5-attempt budget
|
|
// without hammering external notifier endpoints.
|
|
NotificationRetryInterval: getEnvDuration("CERTCTL_NOTIFICATION_RETRY_INTERVAL", 2*time.Minute),
|
|
RetryInterval: getEnvDuration("CERTCTL_SCHEDULER_RETRY_INTERVAL", 5*time.Minute),
|
|
JobTimeoutInterval: getEnvDuration("CERTCTL_JOB_TIMEOUT_INTERVAL", 10*time.Minute),
|
|
AwaitingCSRTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_CSR_TIMEOUT", 24*time.Hour),
|
|
AwaitingApprovalTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT", 168*time.Hour),
|
|
},
|
|
Log: LogConfig{
|
|
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
|
|
Format: getEnv("CERTCTL_LOG_FORMAT", "json"),
|
|
},
|
|
Auth: AuthConfig{
|
|
Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"),
|
|
Secret: getEnv("CERTCTL_AUTH_SECRET", ""),
|
|
// NamedKeys is populated from CERTCTL_API_KEYS_NAMED below so Load()
|
|
// can surface parse errors alongside other config errors.
|
|
},
|
|
RateLimit: RateLimitConfig{
|
|
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
|
|
RPS: getEnvFloat("CERTCTL_RATE_LIMIT_RPS", 50),
|
|
BurstSize: getEnvInt("CERTCTL_RATE_LIMIT_BURST", 100),
|
|
},
|
|
CORS: CORSConfig{
|
|
AllowedOrigins: getEnvList("CERTCTL_CORS_ORIGINS", nil),
|
|
},
|
|
Keygen: KeygenConfig{
|
|
Mode: getEnv("CERTCTL_KEYGEN_MODE", "agent"),
|
|
},
|
|
CA: CAConfig{
|
|
CertPath: getEnv("CERTCTL_CA_CERT_PATH", ""),
|
|
KeyPath: getEnv("CERTCTL_CA_KEY_PATH", ""),
|
|
},
|
|
Notifiers: NotifierConfig{
|
|
SlackWebhookURL: getEnv("CERTCTL_SLACK_WEBHOOK_URL", ""),
|
|
SlackChannel: getEnv("CERTCTL_SLACK_CHANNEL", ""),
|
|
SlackUsername: getEnv("CERTCTL_SLACK_USERNAME", "certctl"),
|
|
TeamsWebhookURL: getEnv("CERTCTL_TEAMS_WEBHOOK_URL", ""),
|
|
PagerDutyRoutingKey: getEnv("CERTCTL_PAGERDUTY_ROUTING_KEY", ""),
|
|
PagerDutySeverity: getEnv("CERTCTL_PAGERDUTY_SEVERITY", "warning"),
|
|
OpsGenieAPIKey: getEnv("CERTCTL_OPSGENIE_API_KEY", ""),
|
|
OpsGeniePriority: getEnv("CERTCTL_OPSGENIE_PRIORITY", "P3"),
|
|
SMTPHost: getEnv("CERTCTL_SMTP_HOST", ""),
|
|
SMTPPort: getEnvInt("CERTCTL_SMTP_PORT", 587),
|
|
SMTPUsername: getEnv("CERTCTL_SMTP_USERNAME", ""),
|
|
SMTPPassword: getEnv("CERTCTL_SMTP_PASSWORD", ""),
|
|
SMTPFromAddress: getEnv("CERTCTL_SMTP_FROM_ADDRESS", ""),
|
|
SMTPUseTLS: getEnvBool("CERTCTL_SMTP_USE_TLS", true),
|
|
},
|
|
NetworkScan: NetworkScanConfig{
|
|
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
|
ScanInterval: getEnvDuration("CERTCTL_NETWORK_SCAN_INTERVAL", 6*time.Hour),
|
|
},
|
|
EST: ESTConfig{
|
|
Enabled: getEnvBool("CERTCTL_EST_ENABLED", false),
|
|
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
|
|
ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""),
|
|
},
|
|
SCEP: SCEPConfig{
|
|
Enabled: getEnvBool("CERTCTL_SCEP_ENABLED", false),
|
|
IssuerID: getEnv("CERTCTL_SCEP_ISSUER_ID", "iss-local"),
|
|
ProfileID: getEnv("CERTCTL_SCEP_PROFILE_ID", ""),
|
|
ChallengePassword: getEnv("CERTCTL_SCEP_CHALLENGE_PASSWORD", ""),
|
|
},
|
|
Verification: VerificationConfig{
|
|
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
|
|
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
|
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second),
|
|
},
|
|
Vault: VaultConfig{
|
|
Addr: getEnv("CERTCTL_VAULT_ADDR", ""),
|
|
Token: getEnv("CERTCTL_VAULT_TOKEN", ""),
|
|
Mount: getEnv("CERTCTL_VAULT_MOUNT", "pki"),
|
|
Role: getEnv("CERTCTL_VAULT_ROLE", ""),
|
|
TTL: getEnv("CERTCTL_VAULT_TTL", "8760h"),
|
|
},
|
|
DigiCert: DigiCertConfig{
|
|
APIKey: getEnv("CERTCTL_DIGICERT_API_KEY", ""),
|
|
OrgID: getEnv("CERTCTL_DIGICERT_ORG_ID", ""),
|
|
ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
|
BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
|
},
|
|
Sectigo: SectigoConfig{
|
|
CustomerURI: getEnv("CERTCTL_SECTIGO_CUSTOMER_URI", ""),
|
|
Login: getEnv("CERTCTL_SECTIGO_LOGIN", ""),
|
|
Password: getEnv("CERTCTL_SECTIGO_PASSWORD", ""),
|
|
OrgID: getEnvInt("CERTCTL_SECTIGO_ORG_ID", 0),
|
|
CertType: getEnvInt("CERTCTL_SECTIGO_CERT_TYPE", 0),
|
|
Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365),
|
|
BaseURL: getEnv("CERTCTL_SECTIGO_BASE_URL", "https://cert-manager.com/api"),
|
|
},
|
|
GoogleCAS: GoogleCASConfig{
|
|
Project: getEnv("CERTCTL_GOOGLE_CAS_PROJECT", ""),
|
|
Location: getEnv("CERTCTL_GOOGLE_CAS_LOCATION", ""),
|
|
CAPool: getEnv("CERTCTL_GOOGLE_CAS_CA_POOL", ""),
|
|
Credentials: getEnv("CERTCTL_GOOGLE_CAS_CREDENTIALS", ""),
|
|
TTL: getEnv("CERTCTL_GOOGLE_CAS_TTL", "8760h"),
|
|
},
|
|
AWSACMPCA: AWSACMPCAConfig{
|
|
Region: getEnv("CERTCTL_AWS_PCA_REGION", ""),
|
|
CAArn: getEnv("CERTCTL_AWS_PCA_CA_ARN", ""),
|
|
SigningAlgorithm: getEnv("CERTCTL_AWS_PCA_SIGNING_ALGORITHM", "SHA256WITHRSA"),
|
|
ValidityDays: getEnvInt("CERTCTL_AWS_PCA_VALIDITY_DAYS", 365),
|
|
TemplateArn: getEnv("CERTCTL_AWS_PCA_TEMPLATE_ARN", ""),
|
|
},
|
|
Entrust: EntrustConfig{
|
|
APIUrl: getEnv("CERTCTL_ENTRUST_API_URL", ""),
|
|
ClientCertPath: getEnv("CERTCTL_ENTRUST_CLIENT_CERT_PATH", ""),
|
|
ClientKeyPath: getEnv("CERTCTL_ENTRUST_CLIENT_KEY_PATH", ""),
|
|
CAId: getEnv("CERTCTL_ENTRUST_CA_ID", ""),
|
|
ProfileId: getEnv("CERTCTL_ENTRUST_PROFILE_ID", ""),
|
|
},
|
|
GlobalSign: GlobalSignConfig{
|
|
APIUrl: getEnv("CERTCTL_GLOBALSIGN_API_URL", ""),
|
|
APIKey: getEnv("CERTCTL_GLOBALSIGN_API_KEY", ""),
|
|
APISecret: getEnv("CERTCTL_GLOBALSIGN_API_SECRET", ""),
|
|
ClientCertPath: getEnv("CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH", ""),
|
|
ClientKeyPath: getEnv("CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH", ""),
|
|
ServerCAPath: getEnv("CERTCTL_GLOBALSIGN_SERVER_CA_PATH", ""),
|
|
},
|
|
EJBCA: EJBCAConfig{
|
|
APIUrl: getEnv("CERTCTL_EJBCA_API_URL", ""),
|
|
AuthMode: getEnv("CERTCTL_EJBCA_AUTH_MODE", "mtls"),
|
|
ClientCertPath: getEnv("CERTCTL_EJBCA_CLIENT_CERT_PATH", ""),
|
|
ClientKeyPath: getEnv("CERTCTL_EJBCA_CLIENT_KEY_PATH", ""),
|
|
Token: getEnv("CERTCTL_EJBCA_TOKEN", ""),
|
|
CAName: getEnv("CERTCTL_EJBCA_CA_NAME", ""),
|
|
CertProfile: getEnv("CERTCTL_EJBCA_CERT_PROFILE", ""),
|
|
EEProfile: getEnv("CERTCTL_EJBCA_EE_PROFILE", ""),
|
|
},
|
|
ACME: ACMEConfig{
|
|
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
|
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
|
ChallengeType: getEnv("CERTCTL_ACME_CHALLENGE_TYPE", "http-01"),
|
|
DNSPresentScript: getEnv("CERTCTL_ACME_DNS_PRESENT_SCRIPT", ""),
|
|
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
|
|
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
|
|
Profile: getEnv("CERTCTL_ACME_PROFILE", ""),
|
|
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
|
|
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", false),
|
|
},
|
|
Digest: DigestConfig{
|
|
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
|
|
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
|
|
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
|
|
},
|
|
HealthCheck: HealthCheckConfig{
|
|
Enabled: getEnvBool("CERTCTL_HEALTH_CHECK_ENABLED", false),
|
|
CheckInterval: getEnvDuration("CERTCTL_HEALTH_CHECK_INTERVAL", 60*time.Second),
|
|
DefaultInterval: getEnvInt("CERTCTL_HEALTH_CHECK_DEFAULT_INTERVAL", 300),
|
|
DefaultTimeout: getEnvInt("CERTCTL_HEALTH_CHECK_DEFAULT_TIMEOUT", 5000),
|
|
MaxConcurrent: getEnvInt("CERTCTL_HEALTH_CHECK_MAX_CONCURRENT", 20),
|
|
HistoryRetention: getEnvDuration("CERTCTL_HEALTH_CHECK_HISTORY_RETENTION", 30*24*time.Hour),
|
|
AutoCreate: getEnvBool("CERTCTL_HEALTH_CHECK_AUTO_CREATE", true),
|
|
},
|
|
Encryption: EncryptionConfig{
|
|
ConfigEncryptionKey: getEnv("CERTCTL_CONFIG_ENCRYPTION_KEY", ""),
|
|
},
|
|
CloudDiscovery: CloudDiscoveryConfig{
|
|
Enabled: getEnvBool("CERTCTL_CLOUD_DISCOVERY_ENABLED", false),
|
|
Interval: getEnvDuration("CERTCTL_CLOUD_DISCOVERY_INTERVAL", 6*time.Hour),
|
|
AWSSM: AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: getEnvBool("CERTCTL_AWS_SM_DISCOVERY_ENABLED", false),
|
|
Region: getEnv("CERTCTL_AWS_SM_REGION", ""),
|
|
TagFilter: getEnv("CERTCTL_AWS_SM_TAG_FILTER", "type=certificate"),
|
|
NamePrefix: getEnv("CERTCTL_AWS_SM_NAME_PREFIX", ""),
|
|
},
|
|
AzureKV: AzureKVDiscoveryConfig{
|
|
Enabled: getEnvBool("CERTCTL_AZURE_KV_DISCOVERY_ENABLED", false),
|
|
VaultURL: getEnv("CERTCTL_AZURE_KV_VAULT_URL", ""),
|
|
TenantID: getEnv("CERTCTL_AZURE_KV_TENANT_ID", ""),
|
|
ClientID: getEnv("CERTCTL_AZURE_KV_CLIENT_ID", ""),
|
|
ClientSecret: getEnv("CERTCTL_AZURE_KV_CLIENT_SECRET", ""),
|
|
},
|
|
GCPSM: GCPSecretMgrDiscoveryConfig{
|
|
Enabled: getEnvBool("CERTCTL_GCP_SM_DISCOVERY_ENABLED", false),
|
|
Project: getEnv("CERTCTL_GCP_SM_PROJECT", ""),
|
|
Credentials: getEnv("CERTCTL_GCP_SM_CREDENTIALS", ""),
|
|
},
|
|
},
|
|
}
|
|
|
|
// Parse CERTCTL_API_KEYS_NAMED for named key authentication (M-002).
|
|
// Parse errors surface here so invalid config fails fast at startup.
|
|
named, err := ParseNamedAPIKeys(getEnv("CERTCTL_API_KEYS_NAMED", ""))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse CERTCTL_API_KEYS_NAMED: %w", err)
|
|
}
|
|
cfg.Auth.NamedKeys = named
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Validate checks that the configuration is valid.
|
|
func (c *Config) Validate() error {
|
|
// Validate server configuration
|
|
if c.Server.Port < 1 || c.Server.Port > 65535 {
|
|
return fmt.Errorf("invalid server port: %d", c.Server.Port)
|
|
}
|
|
|
|
// HTTPS-everywhere milestone §2.1 + §3 locked decisions: the control plane
|
|
// is TLS-only and refuses to start without a cert. No plaintext HTTP fallback,
|
|
// no auto-generated self-signed cert, no N-release migration window. An empty
|
|
// CertPath or KeyPath is operator-visible misconfiguration, not a soft warning.
|
|
if c.Server.TLS.CertPath == "" {
|
|
return fmt.Errorf("server TLS cert path is required — refuse to start (HTTPS-only: set CERTCTL_SERVER_TLS_CERT_PATH to a PEM-encoded certificate; see docs/tls.md)")
|
|
}
|
|
if c.Server.TLS.KeyPath == "" {
|
|
return fmt.Errorf("server TLS key path is required — refuse to start (HTTPS-only: set CERTCTL_SERVER_TLS_KEY_PATH to the PEM-encoded private key matching CERTCTL_SERVER_TLS_CERT_PATH; see docs/tls.md)")
|
|
}
|
|
|
|
// Files must exist and be readable. Catches typos and missing mount paths
|
|
// up-front so the operator gets a structured error on startup instead of
|
|
// a deferred ListenAndServeTLS failure after the scheduler has already
|
|
// fanned out its goroutines.
|
|
if _, err := os.Stat(c.Server.TLS.CertPath); err != nil {
|
|
return fmt.Errorf("server TLS cert file unreadable at %q: %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, err)
|
|
}
|
|
if _, err := os.Stat(c.Server.TLS.KeyPath); err != nil {
|
|
return fmt.Errorf("server TLS key file unreadable at %q: %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.KeyPath, err)
|
|
}
|
|
|
|
// Parse the cert+key pair up-front. tls.LoadX509KeyPair verifies that the
|
|
// key signs the cert (prevents the classic footgun of shipping a pair
|
|
// whose private key doesn't match). Discard the returned Certificate — the
|
|
// server constructs its own holder from fresh reads so SIGHUP reload is
|
|
// authoritative.
|
|
if _, err := tls.LoadX509KeyPair(c.Server.TLS.CertPath, c.Server.TLS.KeyPath); err != nil {
|
|
return fmt.Errorf("server TLS cert/key pair invalid (cert=%q key=%q): %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, c.Server.TLS.KeyPath, err)
|
|
}
|
|
|
|
// Validate database configuration
|
|
if c.Database.URL == "" {
|
|
return fmt.Errorf("database URL is required")
|
|
}
|
|
|
|
if c.Database.MaxConnections < 1 {
|
|
return fmt.Errorf("database max_connections must be at least 1")
|
|
}
|
|
|
|
// Validate log level
|
|
validLogLevels := map[string]bool{
|
|
"debug": true,
|
|
"info": true,
|
|
"warn": true,
|
|
"error": true,
|
|
}
|
|
if !validLogLevels[c.Log.Level] {
|
|
return fmt.Errorf("invalid log level: %s", c.Log.Level)
|
|
}
|
|
|
|
// Validate log format
|
|
validFormats := map[string]bool{
|
|
"json": true,
|
|
"text": true,
|
|
}
|
|
if !validFormats[c.Log.Format] {
|
|
return fmt.Errorf("invalid log format: %s", c.Log.Format)
|
|
}
|
|
|
|
// Validate auth type.
|
|
//
|
|
// G-1 (P1): the pre-G-1 set was {"api-key", "jwt", "none"} with "jwt"
|
|
// accepted but no JWT middleware shipped — silent auth downgrade.
|
|
// Post-G-1 we route a literal "jwt" value through a dedicated
|
|
// rejection that gives operators actionable guidance (the
|
|
// authenticating-gateway pattern) instead of the generic
|
|
// "invalid auth type". Then we cross-check against ValidAuthTypes()
|
|
// so any value outside {api-key, none} surfaces uniformly.
|
|
if c.Auth.Type == "jwt" {
|
|
return fmt.Errorf(
|
|
"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")
|
|
}
|
|
authTypeValid := false
|
|
for _, t := range ValidAuthTypes() {
|
|
if AuthType(c.Auth.Type) == t {
|
|
authTypeValid = true
|
|
break
|
|
}
|
|
}
|
|
if !authTypeValid {
|
|
return fmt.Errorf("invalid auth type: %s (valid: %v)", c.Auth.Type, ValidAuthTypes())
|
|
}
|
|
|
|
// If using API-key, secret is required. (Secret was previously also
|
|
// required for "jwt"; removed with the jwt rejection above.)
|
|
if c.Auth.Type == string(AuthTypeAPIKey) && c.Auth.Secret == "" {
|
|
return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type)
|
|
}
|
|
|
|
// Validate keygen mode
|
|
validKeygenModes := map[string]bool{
|
|
"agent": true,
|
|
"server": true,
|
|
}
|
|
if !validKeygenModes[c.Keygen.Mode] {
|
|
return fmt.Errorf("invalid keygen mode: %s (must be 'agent' or 'server')", c.Keygen.Mode)
|
|
}
|
|
|
|
// SCEP fail-loud startup gate (H-2, CWE-306).
|
|
//
|
|
// Post-M-001 option (D) routes /scep through the no-auth middleware chain per
|
|
// RFC 8894 §3.2 — SCEP clients authenticate via the challengePassword attribute
|
|
// in the PKCS#10 CSR, not via HTTP Bearer tokens or TLS client certs. That makes
|
|
// CERTCTL_SCEP_CHALLENGE_PASSWORD the sole application-layer authentication
|
|
// boundary for SCEP enrollment. Refuse to start if it is empty when SCEP is
|
|
// enabled: an empty shared secret would allow any client that can reach /scep to
|
|
// enroll a CSR against the configured issuer (anonymous issuance).
|
|
if c.SCEP.Enabled && c.SCEP.ChallengePassword == "" {
|
|
return fmt.Errorf("SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty — refuse to start (CWE-306: anonymous SCEP issuance is insecure; set a non-empty shared secret or disable SCEP with CERTCTL_SCEP_ENABLED=false). This gate duplicates cmd/server/main.go:preflightSCEPChallengePassword for defense in depth")
|
|
}
|
|
|
|
// Validate scheduler intervals
|
|
if c.Scheduler.RenewalCheckInterval < 1*time.Minute {
|
|
return fmt.Errorf("renewal check interval must be at least 1 minute")
|
|
}
|
|
|
|
if c.Scheduler.JobProcessorInterval < 1*time.Second {
|
|
return fmt.Errorf("job processor interval must be at least 1 second")
|
|
}
|
|
|
|
if c.Scheduler.AgentHealthCheckInterval < 1*time.Second {
|
|
return fmt.Errorf("agent health check interval must be at least 1 second")
|
|
}
|
|
|
|
if c.Scheduler.NotificationProcessInterval < 1*time.Second {
|
|
return fmt.Errorf("notification process interval must be at least 1 second")
|
|
}
|
|
|
|
// I-005: guard against a misconfigured retry sweep that would either
|
|
// spin-wait or never fire. Matches the NotificationProcessInterval
|
|
// minimum (1s) so operators can tune both knobs from the same floor.
|
|
if c.Scheduler.NotificationRetryInterval < 1*time.Second {
|
|
return fmt.Errorf("notification retry interval must be at least 1 second")
|
|
}
|
|
|
|
if c.Scheduler.RetryInterval < 1*time.Second {
|
|
return fmt.Errorf("retry interval must be at least 1 second")
|
|
}
|
|
|
|
if c.Scheduler.JobTimeoutInterval < 1*time.Second {
|
|
return fmt.Errorf("job timeout interval must be at least 1 second")
|
|
}
|
|
|
|
if c.Scheduler.AwaitingCSRTimeout < 1*time.Second {
|
|
return fmt.Errorf("awaiting CSR timeout must be at least 1 second")
|
|
}
|
|
|
|
if c.Scheduler.AwaitingApprovalTimeout < 1*time.Second {
|
|
return fmt.Errorf("awaiting approval timeout must be at least 1 second")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getEnv reads a string environment variable with the given key and default value.
|
|
func getEnv(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvInt reads an integer environment variable with the given key and default value.
|
|
func getEnvInt(key string, defaultValue int) int {
|
|
if value := os.Getenv(key); value != "" {
|
|
intVal, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
return intVal
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvInt64 reads an int64 environment variable with the given key and default value.
|
|
func getEnvInt64(key string, defaultValue int64) int64 {
|
|
if value := os.Getenv(key); value != "" {
|
|
intVal, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
return intVal
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvDuration reads a time.Duration environment variable.
|
|
// The value should be a valid Go duration string (e.g., "1h", "30s", "5m").
|
|
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
|
|
if value := os.Getenv(key); value != "" {
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
return duration
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvBool reads a boolean environment variable.
|
|
func getEnvBool(key string, defaultValue bool) bool {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value == "true" || value == "1" || value == "yes"
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvFloat reads a float64 environment variable.
|
|
func getEnvFloat(key string, defaultValue float64) float64 {
|
|
if value := os.Getenv(key); value != "" {
|
|
f, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
return f
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvList reads a comma-separated list environment variable.
|
|
func getEnvList(key string, defaultValue []string) []string {
|
|
if value := os.Getenv(key); value != "" {
|
|
var result []string
|
|
for _, s := range splitComma(value) {
|
|
s = trimSpace(s)
|
|
if s != "" {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// splitComma splits a string by commas (no strings import needed).
|
|
func splitComma(s string) []string {
|
|
var parts []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == ',' {
|
|
parts = append(parts, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
parts = append(parts, s[start:])
|
|
return parts
|
|
}
|
|
|
|
// trimSpace trims leading/trailing whitespace.
|
|
func trimSpace(s string) string {
|
|
start, end := 0, len(s)
|
|
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
|
start++
|
|
}
|
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
|
end--
|
|
}
|
|
return s[start:end]
|
|
}
|
|
|
|
// GetLogLevel returns the appropriate slog.Level from the configured log level.
|
|
func (c *Config) GetLogLevel() slog.Level {
|
|
switch c.Log.Level {
|
|
case "debug":
|
|
return slog.LevelDebug
|
|
case "info":
|
|
return slog.LevelInfo
|
|
case "warn":
|
|
return slog.LevelWarn
|
|
case "error":
|
|
return slog.LevelError
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|
|
|
|
// ParseNamedAPIKeys parses the CERTCTL_API_KEYS_NAMED environment variable.
|
|
// Format: "name1:key1,name2:key2:admin,name3:key3"
|
|
// The ":admin" suffix is optional; if present, the key has admin privileges.
|
|
// Returns a typed []NamedAPIKey so main.go can pass it directly to the
|
|
// middleware layer without type assertion gymnastics.
|
|
func ParseNamedAPIKeys(input string) ([]NamedAPIKey, error) {
|
|
if input == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
parts := splitComma(input)
|
|
var keys []NamedAPIKey
|
|
seen := make(map[string]bool)
|
|
|
|
for _, part := range parts {
|
|
part = trimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
// Split by colon: name:key or name:key:admin
|
|
fields := strings.Split(part, ":")
|
|
if len(fields) < 2 || len(fields) > 3 {
|
|
return nil, fmt.Errorf("invalid named key format: %s (expected name:key or name:key:admin)", part)
|
|
}
|
|
|
|
name := trimSpace(fields[0])
|
|
key := trimSpace(fields[1])
|
|
admin := false
|
|
|
|
if len(fields) == 3 {
|
|
adminStr := trimSpace(fields[2])
|
|
if adminStr == "admin" {
|
|
admin = true
|
|
} else {
|
|
return nil, fmt.Errorf("invalid admin flag: %s (expected 'admin')", adminStr)
|
|
}
|
|
}
|
|
|
|
// Validate name format: alphanumeric, hyphens, underscores
|
|
if !isValidKeyName(name) {
|
|
return nil, fmt.Errorf("invalid key name: %s (must be alphanumeric, hyphens, underscores)", name)
|
|
}
|
|
|
|
if seen[name] {
|
|
return nil, fmt.Errorf("duplicate key name: %s", name)
|
|
}
|
|
seen[name] = true
|
|
|
|
if key == "" {
|
|
return nil, fmt.Errorf("empty key for name: %s", name)
|
|
}
|
|
|
|
keys = append(keys, NamedAPIKey{
|
|
Name: name,
|
|
Key: key,
|
|
Admin: admin,
|
|
})
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// isValidKeyName checks if a key name is valid (alphanumeric, hyphens, underscores).
|
|
func isValidKeyName(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|