From aaddd31d20f06660ee0ce5c1780cd72c86d60077 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 04:45:16 +0000 Subject: [PATCH] refactor(config): extract Server family + isLoopbackAddr helper (Phase 9, 6 of N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing Phase 9 ARCH-M2 closure. Sprint 6 groups the server-tier infrastructure structs (the things that configure HOW the server runs) and the HIGH-12 demo-mode startup-guard helper that exclusively serves the ServerConfig.Host gate. What moved ========== internal/config/server.go (new, 374 lines including BSL header + Phase 9 doc-comment + 2 imports + 7 structs + 1 unexported helper) Seven structs: - ServerConfig (HTTP listener: Host, Port, MaxBodySize, TLS sub-struct, AuditFlushTimeoutSeconds) - ServerTLSConfig (HTTPS-only TLS material: CertPath + KeyPath) - DatabaseConfig (URL + MaxConnections + MigrationsPath + DemoSeed) - SchedulerConfig (all 15 scheduler-loop tunables: RenewalCheck, JobProcessor, RenewalConcurrency, agent-health, notification-process + retry, retry-interval, job-timeout, AwaitingCSR + Approval timeouts, short-lived-expiry, CRL-generation, OCSP-rate- limit, cert-export-rate-limit, deploy-backup- retention, K8s-kubelet-sync-timeout) - LogConfig (Level + Format) - RateLimitConfig (Enabled + RPS + BurstSize + per-user overrides) - CORSConfig (AllowedOrigins — empty deny-by-default) One unexported helper: - isLoopbackAddr() (HIGH-12 demo-mode guard: 127.0.0.1, ::1, and "localhost" return true; 0.0.0.0, ::, and non-localhost hostnames return false. Same-package callers: Validate() in config.go + isLoopbackAddr_test in config_test.go, both unaffected by the move.) Three sed passes (highest line numbers first so positions don't shift) ====================================================================== The edit was performed via three independent sed deletes from highest-line to lowest-line so each delete's range references the file's pre-shift line numbers: 1. sed -i '1924,1963d' — deleted isLoopbackAddr (40 lines) 2. sed -i '834,893d' — deleted LogConfig + RateLimitConfig + CORSConfig (60 lines) 3. sed -i '624,810d' — deleted ServerConfig + ServerTLSConfig + DatabaseConfig + SchedulerConfig (187 lines) Total: 287 lines deleted. Reverse-order matters because each delete shifts subsequent line numbers; doing them top-down would require re-deriving every range mid-edit. Why ApprovalConfig stayed in config.go ======================================= ApprovalConfig (RBAC-related — issuance-approval workflow) sits between SchedulerConfig and LogConfig in the original file ordering. It's NOT server-tier infrastructure — it belongs with the Auth/RBAC surface. Sprint 6's sed ranges deliberately preserve it where it lives. Operator may want to fold it into a future Auth-followup cut if the approval surface needs to live adjacent to AuthConfig. Import-graph hygiene ==================== isLoopbackAddr was the ONLY user of `net` in config.go (verified via `grep -nE '\bnet\.' internal/config/config.go` → 2 hits, both inside isLoopbackAddr's body). After the move, config.go's `net` import becomes unused — would have failed `go vet`. This commit removes the `net` line from config.go's import block. server.go imports `net` directly. The `time` import in config.go stays because the still- in-place OCSPResponderConfig / DigestConfig / HealthCheckConfig / NetworkScanConfig / VerificationConfig / per-vendor-issuer configs all reference `time.Duration`. Public-surface invariant ======================== Every type, exported field, and doc-comment is byte-identical to pre-split. Package stays `config`. Every external caller of `config.ServerConfig` / `config.ServerTLSConfig` / `config.DatabaseConfig` / `config.SchedulerConfig` / `config.LogConfig` / `config.RateLimitConfig` / `config.CORSConfig` resolves the same way. The unexported isLoopbackAddr is invisible to external consumers; its same-package callers (Validate, the test) continue to resolve via the package symbol table. Verification (all clean): gofmt -l internal/config/ → clean go build ./internal/config/... → clean go test ./internal/config/... -count=1 → ok (0.68s) staticcheck ./internal/config/... → clean go build ./cmd/server/... ./internal/auth/... ./internal/api/router/... ./internal/api/handler/... ./internal/scheduler/... → clean (the critical broader-importer check) grep -nE '^type (ServerConfig|ServerTLSConfig|DatabaseConfig|SchedulerConfig|LogConfig|RateLimitConfig|CORSConfig)|^func isLoopbackAddr' internal/config/config.go → empty (none remain in config.go) grep -nE '^type (ServerConfig|ServerTLSConfig|DatabaseConfig|SchedulerConfig|LogConfig|RateLimitConfig|CORSConfig)|^func isLoopbackAddr' internal/config/server.go → 7 types + 1 func (correct) grep -nE '\bnet\.' internal/config/config.go → empty (the import-removal was load-bearing) LOC delta: config.go: 1963 → 1673 (-290 lines: -287 from three sed cuts, -1 from import-block line removal, -2 from misc gofmt cleanup) server.go: new, 374 lines (incl. 87-line Phase 9 doc-comment + BSL header + package decl + 2 imports) Cumulative Phase 9 progress (Sprints 1+2+3+4+5+6 from config.go): Pre-Phase-9: 3403 LOC After Sprint 1 (Notifier): 3335 LOC (-68) After Sprint 2 (ACME): 3108 LOC (-227) After Sprint 3 (SCEP): 2774 LOC (-334) After Sprint 4 (EST): 2467 LOC (-307) After Sprint 5 (Auth): 1963 LOC (-504) After Sprint 6 (Server): 1673 LOC (-290) Total Sprint 1+2+3+4+5+6: -1730 LOC (-50.8%) Notable milestone: config.go has now lost MORE than HALF its original lines (-50.8%). One more cut from config.go remains (Sprint 7 ~600 LOC of per-vendor issuer configs) before the file split moves on to non-config hotspots (Sprints 8-12). Pattern lesson — import-graph cleanup ====================================== Splits that move the LAST consumer of an import need to remove the import from the source file or `go vet` / build will fail. The check is `grep -nE '\bnet\.' internal/config/config.go` (or whichever package) before commit — if empty, drop the import line. Past sprints didn't hit this because the moved-out helpers used only shared packages (`strings`, `os`, `fmt`, `time`) that other code in config.go still uses. Sprint 6's `net` removal is the first import-rebalancing in Phase 9. Three-pass sed pattern (also new in Sprint 6) ============================================= Prior sprints did one or two sed deletes. Sprint 6 needed three because the Server-family structs straddled ApprovalConfig and isLoopbackAddr lived far from the struct block. Doing them highest-line-first means each range references pre-shift line numbers — no mid-edit re-derivation required. Next queued (Sprint 7): Issuers family from config.go → internal/config/issuers.go (~600 LOC). Includes KeygenConfig + CAConfig + the ten per-vendor configs (StepCA, Vault, DigiCert, Sectigo, GoogleCAS, AWSACMPCA, Entrust, GlobalSign, EJBCA, OpenSSL). This is the LAST config.go cut of Phase 9; after Sprint 7 ships, config.go should drop to ~1100-1200 LOC and the remaining splits target non-config hotspots (cmd/server/main.go, service/acme.go, mcp/tools.go, auth_session_oidc.go, cmd/agent/main.go). Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2 (partial — 6 of 12 — full ARCH-M2 closure is the aggregate) --- internal/config/config.go | 290 ----------------------------- internal/config/server.go | 374 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+), 290 deletions(-) create mode 100644 internal/config/server.go diff --git a/internal/config/config.go b/internal/config/config.go index defad6d..6f13f96 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "log/slog" - "net" "os" "strconv" "strings" @@ -621,193 +620,6 @@ type VerificationConfig struct { 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. - - // AuditFlushTimeoutSeconds is the budget (in seconds) main.go gives the - // audit middleware to drain in-flight recordings during graceful - // shutdown. Bundle-5 / Audit M-011: pre-Bundle-5 this was hard-coded - // 30s, which dropped events silently in high-volume environments - // because the same context governed HTTP server shutdown + audit - // flush. Post-Bundle-5: configurable; default 30s preserves prior - // behaviour. WARN-log on deadline exceeded, but never exit hard. - // Setting: CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS environment variable. - AuditFlushTimeoutSeconds int -} - -// 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 - - // DemoSeed, when true, makes the server apply - // `/seed_demo.sql` after the baseline `seed.sql`. Set - // via CERTCTL_DEMO_SEED. The compose demo overlay - // (deploy/docker-compose.demo.yml) sets this to keep the demo path - // alive after U-3 dropped initdb-mounted seed files. The seed file - // uses ON CONFLICT (id) DO NOTHING so re-running on a populated - // database is safe; missing-file is a no-op (returns nil) so a - // minimal-image deploy that strips seed_demo.sql still boots cleanly. - DemoSeed bool -} - -// 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 - - // RenewalConcurrency caps the number of concurrent renewal/issuance/ - // deployment goroutines launched per job-processor tick. Default 25 — - // high enough to make use of HTTP/1.1 connection reuse against an - // upstream CA, low enough to stay under typical per-customer rate - // limits. Operators with permissive upstream limits and large fleets - // (>10k certs) can bump to 100; operators with strict limits or - // async-CA-heavy fleets should keep at 25 or lower. - // - // Values ≤ 0 fall back to 1 (sequential) — fail-safe rather than - // panicking on semaphore.NewWeighted(0) semantics. - // - // Closes the #9 acquisition-readiness blocker from the 2026-05-01 - // issuer coverage audit. Pre-fix the per-tick fan-out had no cap, - // so a 5k-cert sweep launched 5k in-flight HTTP calls to upstream - // CAs and tripped DigiCert/Entrust/Sectigo rate limits. - // - // Setting: CERTCTL_RENEWAL_CONCURRENCY environment variable. - RenewalConcurrency int - - // 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 - - // ShortLivedExpiryCheckInterval is how often the scheduler scans - // short-lived certificates and marks expired rows as Expired. Default: - // 30 seconds (matches the in-memory default in scheduler.NewScheduler). - // C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388): - // pre-C-1 the setter scheduler.SetShortLivedExpiryCheckInterval was - // defined + tested but never called from cmd/server/main.go, so the - // 30-second default was effectively hardcoded. Operators who needed - // to tune the cadence (e.g. a high-churn short-lived cert tenant) - // 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 - - // OCSPRateLimitPerIPMin is the per-source-IP cap on OCSP requests - // per minute. Defaults to 1000 (production hardening II Phase 3 - // frozen decision 0.5). Zero disables the limit. - // Setting: CERTCTL_OCSP_RATE_LIMIT_PER_IP_MIN environment variable. - OCSPRateLimitPerIPMin int - - // CertExportRateLimitPerActorHr is the per-actor cap on cert-export - // requests per hour. Defaults to 50 (production hardening II Phase - // 3 frozen decision 0.6). Zero disables the limit. - // Setting: CERTCTL_CERT_EXPORT_RATE_LIMIT_PER_ACTOR_HR environment variable. - CertExportRateLimitPerActorHr int - - // DeployBackupRetention is the default backup retention applied - // to every connector's deploy.Plan when the per-target config - // doesn't override. Defaults to 3 (deploy-hardening I frozen - // decision 0.2). Set to -1 to disable backups entirely (rollback - // becomes impossible — documented foot-gun). - // Setting: CERTCTL_DEPLOY_BACKUP_RETENTION environment variable. - DeployBackupRetention int - - // K8sDeployKubeletSyncTimeout is how long the k8ssecret connector - // waits for kubelet sync (Pod.Status.ContainerStatuses indicating - // the new Secret has been mounted) after a Secret update before - // timing out the post-deploy verify. Defaults to 60s. - // Setting: CERTCTL_K8S_DEPLOY_KUBELET_SYNC_TIMEOUT environment variable. - // Deploy-hardening I Phase 9. - K8sDeployKubeletSyncTimeout time.Duration -} - // ApprovalConfig contains issuance approval-workflow runtime configuration. // Rank 7 of the 2026-05-03 Infisical deep-research deliverable. type ApprovalConfig struct { @@ -831,67 +643,6 @@ type ApprovalConfig struct { BypassEnabled bool } -// 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 -} - -// RateLimitConfig contains rate limiting configuration. -// -// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1): pre-bundle the rate -// limiter was global (a single token bucket shared across every request); -// post-bundle it is per-key with separate budgets for IP-keyed and -// user-keyed buckets. RPS / BurstSize are PER-KEY budgets. -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 KEY (token bucket - // rate). For unauthenticated callers the key is the source IP; for - // authenticated callers the key is the API-key name (UserKey context - // value populated by NewAuthWithNamedKeys). - // 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 - - // PerUserRPS overrides RPS for authenticated callers. When zero, RPS is - // used for both keying dimensions. Set this higher than RPS to grant - // authenticated clients a more generous budget than anonymous probes. - // Default: 0 (use RPS). - // Setting: CERTCTL_RATE_LIMIT_PER_USER_RPS environment variable. - PerUserRPS float64 - - // PerUserBurstSize overrides BurstSize for authenticated callers. When - // zero, BurstSize is used. Default: 0 (use BurstSize). - // Setting: CERTCTL_RATE_LIMIT_PER_USER_BURST environment variable. - PerUserBurstSize 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. @@ -1920,44 +1671,3 @@ func (c *Config) GetLogLevel() slog.Level { return slog.LevelInfo } } - -// isLoopbackAddr returns true when host is bound to a loopback -// interface only (127.0.0.1, ::1, or "localhost"). Used by the -// HIGH-12 demo-mode startup guard to refuse non-loopback binds when -// CERTCTL_AUTH_TYPE=none is in effect. -// -// "" (unset) AND "0.0.0.0" / "::" / "[::]" return false because those -// surface the listener to every interface — exactly the misconfiguration -// the guard is designed to catch. -// -// Hostnames other than "localhost" return false defensively: a hostname -// could resolve to a non-loopback IP at runtime; we don't perform DNS -// here because the guard runs at startup before any network state is -// available, and we don't want a misconfigured /etc/hosts to silently -// pass the guard. Operators wanting to bind to a non-default loopback -// alias must either use 127.0.0.1 / ::1 directly or set -// CERTCTL_DEMO_MODE_ACK=true. -func isLoopbackAddr(host string) bool { - switch host { - case "": - // Empty / unset host — Go's net/http.Server treats this as - // "all interfaces" (equivalent to 0.0.0.0). Surface it to the - // network → not loopback. - return false - case "0.0.0.0", "::", "[::]": - return false - case "localhost": - return true - } - // Strip a trailing :port if the operator passed a host:port pair - // rather than a bare host (defensive — Server.Host is documented - // as host-only, but be lenient). - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - if ip := net.ParseIP(host); ip != nil { - return ip.IsLoopback() - } - // Hostname that isn't "localhost" — fail closed. - return false -} diff --git a/internal/config/server.go b/internal/config/server.go new file mode 100644 index 0000000..2aede91 --- /dev/null +++ b/internal/config/server.go @@ -0,0 +1,374 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import ( + "net" + "time" +) + +// Phase 9 ARCH-M2 closure Sprint 6 (2026-05-14): extracted from +// config.go. Sprint 6 groups the server-tier infrastructure structs +// — the things that configure HOW the server runs (HTTP listener, +// TLS, DB pool, scheduler loops, log level, rate limiting, CORS) +// rather than WHAT it serves (issuer configs, ACME, SCEP, EST, +// auth identity). +// +// Seven structs + one unexported helper move: +// +// ServerConfig — HTTP listener (Host, Port, MaxBodySize, +// TLS sub-struct, AuditFlushTimeoutSeconds). +// ServerTLSConfig — HTTPS-only TLS material (CertPath + +// KeyPath). HTTPS-everywhere milestone: no +// plaintext fallback, no dual-listener. +// DatabaseConfig — DB connection + pool settings + DemoSeed +// toggle for the compose demo overlay. +// SchedulerConfig — all 15 scheduler-loop tunables + the +// per-tick concurrency cap + the deploy/ +// connector timeouts that ride the same +// env-var family. +// LogConfig — Level + Format (info/json defaults). +// RateLimitConfig — Bundle B / M-025: per-key token bucket +// with separate IP-keyed + user-keyed +// budgets. +// CORSConfig — AllowedOrigins (deny-by-default empty). +// +// isLoopbackAddr() — HIGH-12 demo-mode startup guard helper. +// Returns true ONLY for 127.0.0.1 / ::1 / +// "localhost"; everything else (including +// 0.0.0.0, ::, and hostnames that aren't +// "localhost") returns false. Same-package +// caller is Validate() in config.go which +// gates Type=none on non-loopback binds. +// Test caller in config_test.go is also +// package `config` so the unexported callable +// surface stays accessible. +// +// What stayed in config.go +// ======================== +// - ApprovalConfig — RBAC-related (issuance-approval workflow), not +// server-tier infrastructure. Sits between SchedulerConfig and +// LogConfig in the original file ordering; Sprint 6's two-pass +// sed deliberately preserves it where it is. Candidate for a +// future Auth/RBAC follow-up cut if the operator wants the +// approval surface adjacent to AuthConfig. +// - The Validate() body that uses isLoopbackAddr to gate +// CERTCTL_AUTH_TYPE=none — cross-cutting validation logic stays +// in config.go. +// - The Load() body that synthesizes ServerConfig / ServerTLSConfig +// / DatabaseConfig / SchedulerConfig / LogConfig / RateLimitConfig +// / CORSConfig from env vars via the shared getEnv* helpers. +// - The shared getEnv* helpers (getEnv / getEnvBool / getEnvInt / +// getEnvDuration / getEnvFloat / getEnvInt64 / getEnvList). +// +// Import-graph hygiene +// ==================== +// isLoopbackAddr is the ONLY user of the `net` package in config.go. +// After this move, config.go's `net` import becomes unused; the +// Sprint 6 commit removes it from config.go's import block. server.go +// imports `net` directly. The `time` import in config.go stays +// because other configs (notably ApprovalConfig isn't time-typed but +// SCEP/EST helpers in their respective .go files import their own +// `time`; config.go retains `time.Duration` uses in OCSPResponderConfig, +// DigestConfig, HealthCheckConfig, NetworkScanConfig, VerificationConfig, +// and the various issuer-specific configs that haven't been split yet). +// +// Public-surface invariant +// ======================== +// Every type, exported field, and doc-comment is byte-identical to +// pre-split. Package stays `config`. Every external caller of +// `config.ServerConfig` / `config.ServerTLSConfig` / etc. resolves +// the same way. The unexported `isLoopbackAddr` is invisible to +// package consumers; its same-package caller (Validate in config.go) +// + its test (config_test.go in package `config`) continue to +// resolve via the package symbol table. + +// 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. + + // AuditFlushTimeoutSeconds is the budget (in seconds) main.go gives the + // audit middleware to drain in-flight recordings during graceful + // shutdown. Bundle-5 / Audit M-011: pre-Bundle-5 this was hard-coded + // 30s, which dropped events silently in high-volume environments + // because the same context governed HTTP server shutdown + audit + // flush. Post-Bundle-5: configurable; default 30s preserves prior + // behaviour. WARN-log on deadline exceeded, but never exit hard. + // Setting: CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS environment variable. + AuditFlushTimeoutSeconds int +} + +// 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 + + // DemoSeed, when true, makes the server apply + // `/seed_demo.sql` after the baseline `seed.sql`. Set + // via CERTCTL_DEMO_SEED. The compose demo overlay + // (deploy/docker-compose.demo.yml) sets this to keep the demo path + // alive after U-3 dropped initdb-mounted seed files. The seed file + // uses ON CONFLICT (id) DO NOTHING so re-running on a populated + // database is safe; missing-file is a no-op (returns nil) so a + // minimal-image deploy that strips seed_demo.sql still boots cleanly. + DemoSeed bool +} + +// 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 + + // RenewalConcurrency caps the number of concurrent renewal/issuance/ + // deployment goroutines launched per job-processor tick. Default 25 — + // high enough to make use of HTTP/1.1 connection reuse against an + // upstream CA, low enough to stay under typical per-customer rate + // limits. Operators with permissive upstream limits and large fleets + // (>10k certs) can bump to 100; operators with strict limits or + // async-CA-heavy fleets should keep at 25 or lower. + // + // Values ≤ 0 fall back to 1 (sequential) — fail-safe rather than + // panicking on semaphore.NewWeighted(0) semantics. + // + // Closes the #9 acquisition-readiness blocker from the 2026-05-01 + // issuer coverage audit. Pre-fix the per-tick fan-out had no cap, + // so a 5k-cert sweep launched 5k in-flight HTTP calls to upstream + // CAs and tripped DigiCert/Entrust/Sectigo rate limits. + // + // Setting: CERTCTL_RENEWAL_CONCURRENCY environment variable. + RenewalConcurrency int + + // 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 + + // ShortLivedExpiryCheckInterval is how often the scheduler scans + // short-lived certificates and marks expired rows as Expired. Default: + // 30 seconds (matches the in-memory default in scheduler.NewScheduler). + // C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388): + // pre-C-1 the setter scheduler.SetShortLivedExpiryCheckInterval was + // defined + tested but never called from cmd/server/main.go, so the + // 30-second default was effectively hardcoded. Operators who needed + // to tune the cadence (e.g. a high-churn short-lived cert tenant) + // 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 + + // OCSPRateLimitPerIPMin is the per-source-IP cap on OCSP requests + // per minute. Defaults to 1000 (production hardening II Phase 3 + // frozen decision 0.5). Zero disables the limit. + // Setting: CERTCTL_OCSP_RATE_LIMIT_PER_IP_MIN environment variable. + OCSPRateLimitPerIPMin int + + // CertExportRateLimitPerActorHr is the per-actor cap on cert-export + // requests per hour. Defaults to 50 (production hardening II Phase + // 3 frozen decision 0.6). Zero disables the limit. + // Setting: CERTCTL_CERT_EXPORT_RATE_LIMIT_PER_ACTOR_HR environment variable. + CertExportRateLimitPerActorHr int + + // DeployBackupRetention is the default backup retention applied + // to every connector's deploy.Plan when the per-target config + // doesn't override. Defaults to 3 (deploy-hardening I frozen + // decision 0.2). Set to -1 to disable backups entirely (rollback + // becomes impossible — documented foot-gun). + // Setting: CERTCTL_DEPLOY_BACKUP_RETENTION environment variable. + DeployBackupRetention int + + // K8sDeployKubeletSyncTimeout is how long the k8ssecret connector + // waits for kubelet sync (Pod.Status.ContainerStatuses indicating + // the new Secret has been mounted) after a Secret update before + // timing out the post-deploy verify. Defaults to 60s. + // Setting: CERTCTL_K8S_DEPLOY_KUBELET_SYNC_TIMEOUT environment variable. + // Deploy-hardening I Phase 9. + K8sDeployKubeletSyncTimeout 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 +} + +// RateLimitConfig contains rate limiting configuration. +// +// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1): pre-bundle the rate +// limiter was global (a single token bucket shared across every request); +// post-bundle it is per-key with separate budgets for IP-keyed and +// user-keyed buckets. RPS / BurstSize are PER-KEY budgets. +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 KEY (token bucket + // rate). For unauthenticated callers the key is the source IP; for + // authenticated callers the key is the API-key name (UserKey context + // value populated by NewAuthWithNamedKeys). + // 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 + + // PerUserRPS overrides RPS for authenticated callers. When zero, RPS is + // used for both keying dimensions. Set this higher than RPS to grant + // authenticated clients a more generous budget than anonymous probes. + // Default: 0 (use RPS). + // Setting: CERTCTL_RATE_LIMIT_PER_USER_RPS environment variable. + PerUserRPS float64 + + // PerUserBurstSize overrides BurstSize for authenticated callers. When + // zero, BurstSize is used. Default: 0 (use BurstSize). + // Setting: CERTCTL_RATE_LIMIT_PER_USER_BURST environment variable. + PerUserBurstSize 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 +} + +// isLoopbackAddr returns true when host is bound to a loopback +// interface only (127.0.0.1, ::1, or "localhost"). Used by the +// HIGH-12 demo-mode startup guard to refuse non-loopback binds when +// CERTCTL_AUTH_TYPE=none is in effect. +// +// "" (unset) AND "0.0.0.0" / "::" / "[::]" return false because those +// surface the listener to every interface — exactly the misconfiguration +// the guard is designed to catch. +// +// Hostnames other than "localhost" return false defensively: a hostname +// could resolve to a non-loopback IP at runtime; we don't perform DNS +// here because the guard runs at startup before any network state is +// available, and we don't want a misconfigured /etc/hosts to silently +// pass the guard. Operators wanting to bind to a non-default loopback +// alias must either use 127.0.0.1 / ::1 directly or set +// CERTCTL_DEMO_MODE_ACK=true. +func isLoopbackAddr(host string) bool { + switch host { + case "": + // Empty / unset host — Go's net/http.Server treats this as + // "all interfaces" (equivalent to 0.0.0.0). Surface it to the + // network → not loopback. + return false + case "0.0.0.0", "::", "[::]": + return false + case "localhost": + return true + } + // Strip a trailing :port if the operator passed a host:port pair + // rather than a bare host (defensive — Server.Host is documented + // as host-only, but be lenient). + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + // Hostname that isn't "localhost" — fail closed. + return false +}