mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 01:32:17 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36e722ba12 |
+9
-57
@@ -7,78 +7,30 @@
|
||||
# ==============================================================================
|
||||
POSTGRES_DB=certctl
|
||||
POSTGRES_USER=certctl
|
||||
POSTGRES_PASSWORD=replace-with-openssl-rand-hex-32
|
||||
POSTGRES_PASSWORD=change-me-in-production
|
||||
|
||||
# ==============================================================================
|
||||
# Certctl Server
|
||||
# All server vars use the CERTCTL_ prefix (see internal/config/config.go)
|
||||
# ==============================================================================
|
||||
# IMPORTANT: keep the password segment of CERTCTL_DATABASE_URL in sync with
|
||||
# POSTGRES_PASSWORD above. If you deploy via `deploy/docker-compose.yml`,
|
||||
# this value is *overridden* by the compose file's
|
||||
# `postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/...`
|
||||
# interpolation — but if you run the binary directly with this .env loaded
|
||||
# (e.g. `set -a; source .env; ./certctl-server`), update *both* lines.
|
||||
# Background: editing POSTGRES_PASSWORD after the postgres data directory
|
||||
# has been initialized once does NOT rotate the password — initdb only
|
||||
# seeds pg_authid on first boot of an empty volume. See docs/quickstart.md
|
||||
# "Warning" callout and `internal/repository/postgres/db.go::wrapPingError`
|
||||
# for the SQLSTATE 28P01 diagnostic that fires when the two drift.
|
||||
CERTCTL_DATABASE_URL=postgres://certctl:replace-with-openssl-rand-hex-32@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL=postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_SERVER_HOST=0.0.0.0
|
||||
CERTCTL_SERVER_PORT=8443
|
||||
CERTCTL_LOG_LEVEL=info
|
||||
CERTCTL_LOG_FORMAT=json
|
||||
|
||||
# Auth type: "api-key" (production), "none" (demo/development), or
|
||||
# "oidc" (Auth Bundle 2 - native OIDC SSO via coreos/go-oidc/v3, ships
|
||||
# in Bundle 2 phases 5+6; setting CERTCTL_AUTH_TYPE=oidc on a build
|
||||
# without Bundle 2 wired triggers a clear refuse-to-start error rather
|
||||
# than a silent fallback to api-key). For JWT / SAML / LDAP, continue to
|
||||
# run an authenticating gateway in front of certctl (oauth2-proxy /
|
||||
# Envoy ext_authz / Traefik ForwardAuth / Pomerium) and set
|
||||
# CERTCTL_AUTH_TYPE=none on the upstream - see docs/architecture.md
|
||||
# "Authenticating-gateway pattern". G-1 removed the in-process "jwt"
|
||||
# option (no JWT middleware shipped - silent auth downgrade); see
|
||||
# docs/upgrade-to-v2-jwt-removal.md if you previously set
|
||||
# CERTCTL_AUTH_TYPE=jwt.
|
||||
#
|
||||
# Bundle 2 closure (2026-05-12): the docker-compose base file no longer
|
||||
# defaults to AUTH_TYPE=none. The base ships production-shaped; the demo
|
||||
# overlay (deploy/docker-compose.demo.yml) flips this baseline into the
|
||||
# populated-dashboard demo path.
|
||||
CERTCTL_AUTH_TYPE=api-key
|
||||
# Required when CERTCTL_AUTH_TYPE is "api-key". Generate with:
|
||||
# openssl rand -base64 32
|
||||
# The Bundle 2 fail-closed Validate() REFUSES TO START if this value
|
||||
# equals the placeholder string "change-me-in-production" outside of
|
||||
# demo mode (CERTCTL_DEMO_MODE_ACK=true).
|
||||
CERTCTL_AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||
|
||||
# Bundle 2 closure: AES-256-GCM key for encrypting issuer/target config
|
||||
# secrets at rest. Required for any deployment that uses the dynamic
|
||||
# config GUI to store issuer credentials. Generate with:
|
||||
# openssl rand -base64 32
|
||||
# Minimum 32 bytes. The Bundle 2 fail-closed Validate() REFUSES TO
|
||||
# START if this value equals the placeholder string
|
||||
# "change-me-32-char-encryption-key" outside of demo mode.
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY=replace-with-openssl-rand-base64-32
|
||||
# Auth type: "api-key", "jwt", or "none" (for demo/development)
|
||||
CERTCTL_AUTH_TYPE=none
|
||||
# Required when CERTCTL_AUTH_TYPE is "api-key" or "jwt"
|
||||
# Generate with: openssl rand -base64 32
|
||||
# CERTCTL_AUTH_SECRET=change-me-in-production
|
||||
|
||||
# ==============================================================================
|
||||
# Certctl Agent
|
||||
# ==============================================================================
|
||||
# HTTPS-only as of v2.2 (TLS 1.3 pinned). Agents reject http:// URLs at
|
||||
# startup. Use the docker-compose self-signed bootstrap CA bundle from
|
||||
# `deploy/test/certs/ca.crt` or supply your own via CERTCTL_SERVER_CA_BUNDLE_PATH.
|
||||
CERTCTL_SERVER_URL=https://localhost:8443
|
||||
# Matches one of the server's CERTCTL_AUTH_SECRET rotation values. The
|
||||
# placeholder is rejected outside demo mode (Bundle 2 fail-closed guard).
|
||||
CERTCTL_API_KEY=replace-with-openssl-rand-base64-32
|
||||
CERTCTL_SERVER_URL=http://localhost:8443
|
||||
CERTCTL_API_KEY=change-me-in-production
|
||||
CERTCTL_AGENT_NAME=local-agent
|
||||
# Returned from `POST /api/v1/agents` during agent enrollment. The agent
|
||||
# fail-fasts at startup with "agent-id flag or CERTCTL_AGENT_ID env var
|
||||
# is required" if this is unset.
|
||||
# CERTCTL_AGENT_ID=agent-from-registration-response
|
||||
|
||||
# ==============================================================================
|
||||
# Optional: Scheduler Tuning (defaults are usually fine)
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
# Coverage floors per gated package.
|
||||
#
|
||||
# Each entry: floor: <integer percentage>, why: <load-bearing context>.
|
||||
# Adding a new gated package: one entry here; CI's `Check Coverage Thresholds`
|
||||
# step auto-picks up. Lowering a floor REQUIRES corresponding code-side test
|
||||
# work — never lower the gate to make CI green.
|
||||
#
|
||||
# Per ci-pipeline-cleanup bundle Phase 2 / frozen decision 0.3.
|
||||
|
||||
internal/service:
|
||||
floor: 70
|
||||
why: |
|
||||
Bundle R-CI-extended raise (post-Bundle-N.C-extended): service
|
||||
55 → 70. HEAD 73.4% (3pp margin). Prescribed Bundle R target
|
||||
was 80; held lower to avoid false-positives on single low-
|
||||
coverage files dragging the global per-file-average down.
|
||||
|
||||
internal/api/handler:
|
||||
floor: 75
|
||||
why: |
|
||||
Bundle R-CI-extended raise: handler 60 → 75. HEAD 79.8% (4pp
|
||||
margin). Prescribed Bundle R target was 80; held lower for
|
||||
same reason as service layer.
|
||||
|
||||
internal/domain:
|
||||
floor: 40
|
||||
why: |
|
||||
Domain layer is mostly type definitions + validators; 40% is
|
||||
the load-bearing-paths floor.
|
||||
|
||||
internal/api/middleware:
|
||||
floor: 30
|
||||
why: |
|
||||
Middleware coverage is per-handler-test-driven. 30% is the
|
||||
floor that catches the wired-up middleware paths; the
|
||||
unwired paths (alternative auth providers not currently
|
||||
enabled) sit below.
|
||||
|
||||
internal/crypto:
|
||||
floor: 88
|
||||
why: |
|
||||
Bundle R closure CI checkpoint #3: crypto floor lifted 85 → 88.
|
||||
Post-Bundle-Q package-scoped coverage at HEAD: 88.2%. The
|
||||
remaining ~12% gap is platform-failure branches (rand.Reader /
|
||||
aes.NewCipher) that require interface seams the production
|
||||
code doesn't use; closing them is tracked as R-CI-extended,
|
||||
not Bundle R scope.
|
||||
|
||||
internal/connector/issuer/local:
|
||||
floor: 86
|
||||
why: |
|
||||
Bundle R closure CI checkpoint #3: local-issuer floor lifted
|
||||
85 → 86. Post-Bundle-Q package-scoped coverage at HEAD: 86.7%.
|
||||
The prescribed Bundle R target was 92, but reaching it
|
||||
requires interface seams for crypto/x509 signing-error
|
||||
branches — tracked as R-CI-extended.
|
||||
|
||||
internal/connector/issuer/acme:
|
||||
floor: 80
|
||||
why: |
|
||||
Bundle R-CI-extended threshold raise (post-Bundle-J-extended):
|
||||
ACME 50 → 80. The Pebble-style mock + per-CA failure tests
|
||||
lift package-scoped ACME to 85.4%; gate at 80 with 5pp margin
|
||||
to absorb the global-run per-file-average dip.
|
||||
|
||||
internal/connector/issuer/stepca:
|
||||
floor: 80
|
||||
why: |
|
||||
Bundle L.B / Coverage-Audit C-005 — StepCA failure-mode + JWE
|
||||
round-trip tests lift package from 52.1% to 90.4% (per-package
|
||||
run). Floor at 80 with margin.
|
||||
|
||||
internal/mcp:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle K / Coverage-Audit C-002 — MCP per-tool dispatch via
|
||||
in-memory transport lifts package from 28.0% to 93.1% (per-
|
||||
package run). Floor at 85.
|
||||
|
||||
internal/auth:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle 1 Phase 12 — RBAC primitive coverage gate.
|
||||
internal/auth ships keystore + middleware + RequirePermission +
|
||||
bootstrap + the Phase-3 context keys + the protocol-endpoint
|
||||
allowlist. Negative-test coverage (no actor → 401, no role →
|
||||
403, wrong scope → 403, bootstrap-token-wrong → 401, bootstrap-
|
||||
used-twice → 410, admin-already-exists → 410, zero-length token
|
||||
rejection) is now in place. Prescribed Bundle 1 target was 90;
|
||||
held at 85 to absorb the per-file-average dip from the
|
||||
middleware shim files (testfixtures.go) which CI runs but only
|
||||
test fixtures exercise. Sub-package internal/auth/bootstrap
|
||||
inherits this floor.
|
||||
|
||||
internal/service/auth:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle 1 Phase 12 — RBAC service-layer coverage gate.
|
||||
PermissionService + RoleService + ActorRoleService + Authorizer
|
||||
each have positive + negative tests covering the
|
||||
privilege-escalation guard (auth.role.assign required for
|
||||
Grant/Revoke), the reserved-actor invariant (actor-demo-anon
|
||||
cannot be mutated), the canonical-permission validation, the
|
||||
role-in-use guard on Delete, and every sentinel-error path
|
||||
(ErrUnauthenticated / ErrForbidden / ErrSelfRoleAssignment /
|
||||
ErrAuthReservedActor / ErrAuthUnknownPermission /
|
||||
ErrAuthRoleInUse).
|
||||
|
||||
internal/auth/oidc:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 3 — OIDC service coverage gate. Phase 3 spec
|
||||
pins the floor at 90 explicitly because every fail-closed
|
||||
branch is load-bearing for the security posture: alg pinning
|
||||
(deny-list HS*/none + allow-list RS*/ES*/EdDSA), audience
|
||||
re-check, azp enforcement on multi-aud tokens, at_hash
|
||||
REQUIRED-when-access-token-present (Phase 3 lifts the OIDC
|
||||
core "MAY" to a service-level "MUST"), iat-window window,
|
||||
nonce constant-time-compare, single-use state replay defense,
|
||||
PKCE-S256 mandatory, IdP downgrade-attack defense at
|
||||
provider-load + RefreshKeys time, JWKS-fail-closed semantics,
|
||||
group-claim resolution + userinfo-fallback fail-closed
|
||||
semantics, token-leak hygiene. A regression in any one of
|
||||
these branches is a security incident; the floor catches it
|
||||
before the commit lands. The mock-IdP fixture in
|
||||
service_test.go is the load-bearing harness.
|
||||
|
||||
internal/auth/oidc/groupclaim:
|
||||
floor: 95
|
||||
why: |
|
||||
Bundle 2 Phase 3 — group-claim resolver. Hand-rolled (no
|
||||
JSON-path dep per Decision 10); ~150 LOC, every branch
|
||||
exercised by 19 unit tests covering the documented IdP shapes
|
||||
(Okta string array, Keycloak realm_access.roles, Auth0
|
||||
namespaced URL claim, single-string normalization,
|
||||
deeply-nested 3-segment walks) plus every fail-closed branch
|
||||
(empty path, missing key, missing nested key, non-object
|
||||
intermediate, bool/number/object/nil values, array with
|
||||
non-string element, URL-shape with dots-in-path treated as
|
||||
literal). Resolver should be at 100%; floor at 95 leaves a
|
||||
1-statement margin for future error-message refactors.
|
||||
|
||||
internal/auth/oidc/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — OIDCProvider + GroupRoleMapping domain.
|
||||
Validation-heavy package; constructors + Validate methods
|
||||
cover all canonical IdP shapes (Okta / Azure AD / Google
|
||||
Workspace / Keycloak / Authentik / Auth0). Floor at 90 to
|
||||
catch any future field that ships without a validator.
|
||||
|
||||
internal/auth/session:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 4 — session lifecycle service. Phase 4 spec
|
||||
pins the floor at 90 because every fail-closed branch carries
|
||||
a security invariant: HMAC-SHA256 cookie signing with a
|
||||
LENGTH-PREFIXED canonical input (defeats the
|
||||
`<a, bc>`-vs-`<ab, c>` concatenation collision attack on the
|
||||
bare-concat form), v1. version-prefix lock, idle expiry,
|
||||
absolute expiry, revocation, retired-but-in-retention key
|
||||
success path, retired-past-retention failure path, CSRF
|
||||
constant-time compare against the SHA-256-hashed copy on the
|
||||
session row, optional IP/UA-bind defense-in-depth gates,
|
||||
fail-fatal initial-key bootstrap. A regression in any one of
|
||||
these branches is a security incident; the floor catches it
|
||||
before the commit lands. The 15-case negative-test matrix in
|
||||
service_test.go is the load-bearing harness; the in-memory
|
||||
stubs of SessionRepo + SigningKeyRepo + AuditRecorder let the
|
||||
state machine be exercised without the postgres testcontainer
|
||||
overhead (which Phase 2's integration tests already cover).
|
||||
|
||||
internal/auth/session/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — Session + SessionSigningKey domain. Both
|
||||
types ship Validate() with full invariant coverage: ID prefix
|
||||
enforcement (ses-/sk-), expiry-order CHECK (absolute > idle >
|
||||
created), CSRFTokenHash format pin (64 lowercase hex chars),
|
||||
KeyMaterialEncrypted non-empty, retired-before-created
|
||||
rejection, TenantID defaulting. Cookie naming constants are
|
||||
pinned by TestCookieNamingConstants because the GUI's
|
||||
web/src/api/client.ts will read `certctl_csrf` by string.
|
||||
Floor at 90 to catch any future field that ships without a
|
||||
validator.
|
||||
|
||||
internal/auth/breakglass:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 7.5 — break-glass admin service (Argon2id +
|
||||
lockout state machine + constant-time-via-verifyDummy). Phase
|
||||
13 Pre-merge audit: floor at 90 with no carve-out. Phase 7.5
|
||||
spec ships the package at 91.5%, validated by 8 mandated
|
||||
negatives + ~12 coverage-lift tests. Every fail-closed branch
|
||||
is load-bearing for the security surface (default-OFF posture
|
||||
only matters if every "disabled" path returns ErrDisabled
|
||||
BEFORE any DB lookup; constant-time defense only matters if
|
||||
every path goes through verifyDummy on the no-credential leg).
|
||||
A regression that drops a fail-closed branch's coverage below
|
||||
90 is a real security risk — gate trips, operator audits.
|
||||
|
||||
internal/auth/breakglass/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — BreakglassCredential domain. Argon2id PHC
|
||||
format pinned ($argon2id$ prefix), MinPasswordLengthBytes (12)
|
||||
+ MaxPasswordLengthBytes (256) constants pinned by dedicated
|
||||
test, IsLocked(now) state machine helper. The package ships
|
||||
at 100% coverage; floor at 90 is the standing-room floor for
|
||||
any future field added without a validator.
|
||||
|
||||
internal/auth/user/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — User domain (federated-human identity).
|
||||
OIDCSubject + OIDCProviderID unique-index per the Phase 2
|
||||
schema, WebAuthnCredentials JSONB reserved for v3, Validate()
|
||||
enforces every on-disk invariant. The package ships at 96.4%
|
||||
coverage. Floor at 90 to catch any future field added without
|
||||
a validator.
|
||||
|
||||
Phase 13 prompt explicitly enumerates internal/auth/user/ at
|
||||
floor 90. The parent (non-domain) directory has no Go source —
|
||||
the user upsert lives in internal/auth/oidc/service.go alongside
|
||||
group resolution + role mapping (cohesive sequence within the
|
||||
OIDC callback). Splitting upsertUser into a separate
|
||||
internal/auth/user/ service package would harm cohesion without
|
||||
adding test value; the domain layer's invariant coverage is
|
||||
where the floor actually applies.
|
||||
+63
-628
@@ -14,17 +14,12 @@ jobs:
|
||||
name: Go Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
# Phase 3 TEST-L1 closure (2026-05-13): enable Go's module +
|
||||
# build cache so re-runs hit the cache instead of recompiling
|
||||
# the world. setup-go v5 cache: true by default; making it
|
||||
# explicit so a future setup-go upgrade can't silently flip it.
|
||||
cache: true
|
||||
go-version: '1.25.9'
|
||||
|
||||
- name: Go Build
|
||||
run: |
|
||||
@@ -33,28 +28,6 @@ jobs:
|
||||
go build ./cmd/mcp-server/...
|
||||
go build ./cmd/cli/...
|
||||
|
||||
- name: gofmt drift (Makefile::verify parity)
|
||||
# ci-pipeline-cleanup Phase 4 / frozen decision 0.13: Makefile::verify
|
||||
# checks gofmt + vet + golangci-lint + go test. CI runs vet, lint, test
|
||||
# already — but NOT gofmt. This step closes the parity gap.
|
||||
# Mirrors the Makefile::verify shape: any gofmt output means the
|
||||
# source needs reformatting.
|
||||
run: |
|
||||
out=$(gofmt -l .)
|
||||
if [ -n "$out" ]; then
|
||||
echo "::error::gofmt would reformat these files (run 'gofmt -w' locally):"
|
||||
echo "$out"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: go mod tidy drift
|
||||
# ci-pipeline-cleanup Phase 4: catches PRs that import a package
|
||||
# without committing the go.mod / go.sum update. Standard Go-CI
|
||||
# gate; absent before this bundle.
|
||||
run: |
|
||||
go mod tidy
|
||||
git diff --exit-code go.mod go.sum
|
||||
|
||||
- name: Go Vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -68,353 +41,83 @@ jobs:
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Run govulncheck (M-024 hard gate)
|
||||
# Bundle-7 / D-001 partial: govulncheck distinguishes called-vs-uncalled
|
||||
# advisories. Default exit code is non-zero only when YOUR code calls
|
||||
# the vulnerable function — deferred-call advisories show up in the
|
||||
# output but don't fail the gate.
|
||||
#
|
||||
# Bundle F / Audit M-024 (NIST SSDF PW.7.2): the govulncheck step
|
||||
# is now a hard CI gate (no `continue-on-error`). Bundle E's
|
||||
# transitive bumps (x/net 0.42→0.47, x/crypto 0.41→0.45) cleared
|
||||
# the 5 deferred-call advisories that were previously on the
|
||||
# exception list, so the carve-out the original Bundle F prompt
|
||||
# designed is unnecessary — a clean `govulncheck ./...` is the
|
||||
# right gate. If a future advisory lands in a function our code
|
||||
# does call, this step fails the build until either upstream
|
||||
# ships a fix OR we cut the dep. Deferred-call advisories that
|
||||
# legitimately can't be remediated yet should be added to the
|
||||
# NIST SSDF deviation log in docs/operator/security.md, not silenced here.
|
||||
- name: Run govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
- name: Install staticcheck (Bundle-7 / D-001)
|
||||
run: go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
|
||||
- name: Run staticcheck
|
||||
# Bundle-7 / D-001: Go static analysis additive to vet. Suppressed
|
||||
# rules live in staticcheck.conf with documented justifications;
|
||||
# adding a new entry requires an explicit security review.
|
||||
#
|
||||
# ci-pipeline-cleanup Phase 3 / frozen decision 0.7: HARD gate.
|
||||
# M-028 SA1019 sites verified closed at HEAD 1de61e91:
|
||||
# - middleware.NewAuth: zero callers (all migrated to
|
||||
# NewAuthWithNamedKeys in cmd/server/{main,main_test}.go)
|
||||
# - csr.Attributes (internal/api/handler/scep.go × 2): inline
|
||||
# //lint:ignore SA1019 with load-bearing rationale (RFC 2985
|
||||
# challengePassword has no non-deprecated stdlib API)
|
||||
# - elliptic.Marshal: only in bundle9_coverage_test.go × 1 as
|
||||
# deliberate byte-equivalence regression oracle, suppressed
|
||||
# with //lint:ignore SA1019
|
||||
run: staticcheck ./...
|
||||
|
||||
- name: Race Detection
|
||||
# Phase 3 TEST-H1 closure (2026-05-13): the pre-Phase-3 invocation
|
||||
# listed 9 explicit package roots, excluding internal/auth/*,
|
||||
# internal/repository/*, internal/mcp, internal/scep, internal/pkcs7,
|
||||
# internal/api/router, internal/api/acme, internal/cli, internal/cms,
|
||||
# internal/config, internal/deploy, internal/integration,
|
||||
# internal/ratelimit, internal/secret, internal/trustanchor, plus
|
||||
# all of cmd/. Audit finding TEST-H1 flagged this as silent
|
||||
# race-detection drift — packages added after the original list
|
||||
# was authored were never covered.
|
||||
#
|
||||
# Post-Phase-3: ./... with -short. The 76 testing.Short() guards
|
||||
# already in the integration-test surface (testcontainers, live-DB,
|
||||
# multi-process) gate behind this flag, so race detection runs
|
||||
# across every package without dragging in long-running suites.
|
||||
# Timeout doubled from 300s to 600s because ./... is broader; the
|
||||
# broader scope is what makes race coverage trustworthy.
|
||||
run: go test -race -short ./... -count=1 -timeout 600s
|
||||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
|
||||
|
||||
- name: Go Test with Coverage
|
||||
# internal/ciparity/... — post-v2.1.0 anti-rot item 2 surface-
|
||||
# parity tests; stdlib-only so they always pass in this job.
|
||||
run: |
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/api/router/... ./internal/auth/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... ./internal/ciparity/... -count=1 -cover -coverprofile=coverage.out
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -cover -coverprofile=coverage.out
|
||||
|
||||
- name: Check Coverage Thresholds
|
||||
# ci-pipeline-cleanup Phase 2: per-package floors moved to
|
||||
# .github/coverage-thresholds.yml. Each entry has `floor:` +
|
||||
# `why:` (load-bearing context). Logic in
|
||||
# scripts/check-coverage-thresholds.sh — operator runs the same
|
||||
# script locally via `make verify`-equivalent loop.
|
||||
run: bash scripts/check-coverage-thresholds.sh
|
||||
run: |
|
||||
# Extract per-package coverage from test output
|
||||
echo "=== Coverage Report ==="
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
|
||||
# Check service layer coverage (target: 60%+)
|
||||
SERVICE_COV=$(go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Service layer coverage: ${SERVICE_COV}%"
|
||||
|
||||
# Check handler layer coverage (target: 60%+)
|
||||
HANDLER_COV=$(go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Handler layer coverage: ${HANDLER_COV}%"
|
||||
|
||||
# Check domain layer coverage (target: 40%+)
|
||||
DOMAIN_COV=$(go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Domain layer coverage: ${DOMAIN_COV}%"
|
||||
|
||||
# Check middleware layer coverage (target: 50%+)
|
||||
MIDDLEWARE_COV=$(go tool cover -func=coverage.out | grep 'internal/api/middleware' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Middleware layer coverage: ${MIDDLEWARE_COV}%"
|
||||
|
||||
# Check crypto package coverage (target: 85%+)
|
||||
# M-8 rationale: encryption primitives are a security-critical gate.
|
||||
# v2 format, key-derivation, fallback, and fail-closed sentinel paths
|
||||
# all need exhaustive coverage to avoid silent regressions (CWE-916 / CWE-329).
|
||||
CRYPTO_COV=$(go tool cover -func=coverage.out | grep 'internal/crypto' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Crypto package coverage: ${CRYPTO_COV}%"
|
||||
|
||||
# Fail if thresholds not met
|
||||
if [ "$(echo "$SERVICE_COV < 55" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Service layer coverage ${SERVICE_COV}% is below 55% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$HANDLER_COV < 60" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Handler layer coverage ${HANDLER_COV}% is below 60% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$DOMAIN_COV < 40" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Domain layer coverage ${DOMAIN_COV}% is below 40% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$MIDDLEWARE_COV < 30" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Middleware layer coverage ${MIDDLEWARE_COV}% is below 30% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$CRYPTO_COV < 85" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Crypto package coverage ${CRYPTO_COV}% is below 85% threshold"
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage thresholds passed!"
|
||||
|
||||
- name: Upload Coverage Report
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: go-coverage
|
||||
path: coverage.out
|
||||
retention-days: 30
|
||||
|
||||
- name: Coverage PR comment
|
||||
# ci-pipeline-cleanup Phase 10 / frozen decision 0.9: self-hosted
|
||||
# alternative to Codecov / Coveralls. Posts a per-package coverage
|
||||
# delta as a PR comment; updates in place on subsequent pushes.
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: bash scripts/coverage-pr-comment.sh
|
||||
|
||||
# Bundle Q / I-001 closure — test-naming convention guard (informational).
|
||||
# The convention is `Test<Func>_<Scenario>_<ExpectedResult>`. This step
|
||||
# prints any non-conformant tests but does NOT fail the build until the
|
||||
# Bundle I-001-extended (2026-04-27) — promoted from informational
|
||||
# to hard-fail. The convention is now: every `func TestXxx(...)` MUST
|
||||
# match Go's standard test-runner pattern (`^func Test[A-Z]`). Tests
|
||||
# whose name starts with `func Test<lowercase>` are silently SKIPPED
|
||||
# by `go test` (Go only runs `Test[A-Z]...`) — those are the real
|
||||
# bugs this guard catches.
|
||||
#
|
||||
# The original audit's `Test<Func>_<Scenario>_<ExpectedResult>` triple-
|
||||
# token prescription has been relaxed: single-function pin tests like
|
||||
# `TestNewAgent` or `TestSplitPEMChain` are valid Go convention, with
|
||||
# internal scenarios expressed via `t.Run` subtests. Requiring the
|
||||
# underscore-Scenario-Result triple repo-wide would mean renaming
|
||||
# 167 legitimate tests for no observable behavior change. The
|
||||
# Test<Func>_<Scenario>_<ExpectedResult> form remains the
|
||||
# recommended pattern for parameterized scenarios, but is not gated.
|
||||
# Phase 4 DEPL-* prerequisite (2026-05-14): helm-templates-lint.sh
|
||||
# needs the `helm` CLI on PATH to run helm lint + helm template
|
||||
# against the chart. The official azure/setup-helm action installs
|
||||
# a SHA-pinned helm binary into the runner.
|
||||
- name: Install Helm (for helm-templates-lint guard)
|
||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
with:
|
||||
version: v3.16.0
|
||||
|
||||
- name: Regression guards (extracted to scripts/ci-guards/)
|
||||
# All named regression guards live at scripts/ci-guards/<id>.sh per
|
||||
# ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally:
|
||||
# bash scripts/ci-guards/G-3-env-docs-drift.sh
|
||||
# Adding a new guard: drop a new <id>.sh; this loop auto-picks it up.
|
||||
# Contract: each guard MUST exit 0 on clean repo, non-zero with
|
||||
# ::error:: prefix on regression. See scripts/ci-guards/README.md.
|
||||
#
|
||||
run: |
|
||||
set -e
|
||||
fail=0
|
||||
for g in scripts/ci-guards/*.sh; do
|
||||
echo "::group::$(basename "$g")"
|
||||
if ! bash "$g"; then
|
||||
fail=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $fail
|
||||
|
||||
cross-platform-build:
|
||||
# Phase 3 TEST-H2 closure (2026-05-13): the pre-Phase-3 CI ran
|
||||
# exclusively on ubuntu-latest, leaving Windows-specific bugs
|
||||
# (path separators, file permissions, exec.Command semantics)
|
||||
# undetected. The agent + CLI binaries ship for Windows + macOS
|
||||
# users; this matrix asserts they at least BUILD on every OS we
|
||||
# claim to support.
|
||||
#
|
||||
# Build-only — no test run. Full test parity across OSes is a
|
||||
# larger investment (testcontainers is Linux-only on Windows CI
|
||||
# runners, file-permission tests differ, etc.). The build gate
|
||||
# is the minimum that catches the cross-platform regressions
|
||||
# we've seen in practice.
|
||||
name: Cross-platform build (ubuntu / windows / macos)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Build server + agent + CLI + mcp-server
|
||||
run: |
|
||||
go build ./cmd/server
|
||||
go build ./cmd/agent
|
||||
go build ./cmd/cli
|
||||
go build ./cmd/mcp-server
|
||||
|
||||
cold-db-compose-smoke:
|
||||
# Per post-v2.1.0 anti-rot item 6 (Auditable Codebase Bundle).
|
||||
#
|
||||
# Catches migration-on-cold-DB regressions: wipe the postgres
|
||||
# volume, bring the stack up cold, mint a day-0 admin, issue +
|
||||
# renew + revoke a test certificate, assert audit rows, tear down.
|
||||
# Targets the bug class that the warm-DB integration suite misses
|
||||
# (canonical case: 2026-05-09 migration 000045 broken INSERT,
|
||||
# fixed in commit 6444e13).
|
||||
name: Cold-DB compose smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs: go-build-and-test
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Show Docker versions
|
||||
run: |
|
||||
docker --version
|
||||
docker compose version
|
||||
|
||||
- name: Cold-DB compose smoke
|
||||
# The smoke deliberately focuses on the bug class that ONLY a
|
||||
# cold boot can catch: stack-startup correctness against a
|
||||
# blank database. It is intentionally NOT a functional API
|
||||
# walkthrough — the integration test suite under
|
||||
# 'Go Test with Coverage' already covers issue / renew /
|
||||
# revoke / audit-row plumbing against a warm DB.
|
||||
#
|
||||
# The bugs this gate is uniquely positioned to catch:
|
||||
# - Missing required env vars that fail Config.Validate()
|
||||
# at startup (e.g. CERTCTL_DEMO_MODE_ACK gap, 2026-05-12).
|
||||
# - Non-idempotent migrations that crash on the second boot
|
||||
# (e.g. migration 000043 CHECK constraint, 2026-05-12).
|
||||
# - Documented manual flows that don't work end-to-end on
|
||||
# a clean compose (e.g. CERTCTL_BOOTSTRAP_TOKEN
|
||||
# interpolation gap, 2026-05-12).
|
||||
#
|
||||
# Bugs OUTSIDE the scope of this smoke (covered elsewhere):
|
||||
# - API request/response contract changes (integration suite).
|
||||
# - Cert lifecycle correctness (integration suite + handler
|
||||
# tests).
|
||||
# - Audit row plumbing (handler tests).
|
||||
#
|
||||
# 10-min wall-clock cap covers cold image pull + compose-up +
|
||||
# force-recreate + admin bootstrap + teardown. Increase only
|
||||
# if the underlying steps legitimately grow.
|
||||
#
|
||||
# The smoke is inlined here on purpose — it is NOT a script in
|
||||
# scripts/ci-guards/, because there is no value in a developer
|
||||
# running this locally. The whole point of the gate is that CI
|
||||
# owns the cold-DB state; the operator never has to remember to
|
||||
# run it.
|
||||
timeout-minutes: 10
|
||||
working-directory: deploy
|
||||
env:
|
||||
STARTUP_TIMEOUT_SECONDS: 300
|
||||
run: |
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
SERVER_URL="https://localhost:8443"
|
||||
CACERT_PATH="${GITHUB_WORKSPACE}/deploy/test/certs/ca.crt"
|
||||
|
||||
log() { echo "[cold-db-smoke] $*"; }
|
||||
|
||||
wait_for_service_healthy() {
|
||||
local svc="$1" deadline=$(( $(date +%s) + STARTUP_TIMEOUT_SECONDS ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local state
|
||||
state="$(docker compose ps --format json "$svc" 2>/dev/null | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
line = sys.stdin.read().strip()
|
||||
if not line:
|
||||
print("not-up"); sys.exit(0)
|
||||
rows = json.loads(line) if line.startswith("[") else [json.loads(l) for l in line.splitlines() if l.strip()]
|
||||
if not rows:
|
||||
print("not-up")
|
||||
else:
|
||||
print(rows[0].get("Health", rows[0].get("State", "?")))
|
||||
except Exception as e:
|
||||
print(f"err: {e}")
|
||||
')"
|
||||
if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then
|
||||
log " $svc → $state"; return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
log " $svc did NOT reach healthy within ${STARTUP_TIMEOUT_SECONDS}s (last: $state)"
|
||||
return 1
|
||||
}
|
||||
|
||||
http_call() {
|
||||
local method="$1" path="$2" data="${3:-}"
|
||||
local args=(--silent --show-error --max-time 30 -X "$method" "$SERVER_URL$path")
|
||||
[ -f "$CACERT_PATH" ] && args+=(--cacert "$CACERT_PATH") || args+=(--insecure)
|
||||
[ -n "$data" ] && args+=(-H "Content-Type: application/json" -d "$data")
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
# Bundle 2 closure (2026-05-12): the base compose is now
|
||||
# production-shaped — auth=api-key + agent-keygen + fail-closed
|
||||
# placeholder guards. The cold-DB smoke layers in the demo
|
||||
# overlay so the boot path remains zero-config: the overlay
|
||||
# supplies AUTH_TYPE=none + DEMO_MODE_ACK=true + the matching
|
||||
# placeholder creds the fail-closed guards accept under
|
||||
# DEMO_MODE_ACK. The agent service in the overlay also
|
||||
# pre-seeds CERTCTL_AGENT_ID=agent-demo-1 so the bundled
|
||||
# agent doesn't restart-loop. The smoke's purpose (catch
|
||||
# migration-on-cold-DB regressions + verify bootstrap-token
|
||||
# endpoint mints a day-0 admin against a freshly migrated
|
||||
# schema) is orthogonal to whether the auth posture is
|
||||
# demo-mode or api-key, so the overlay is acceptable here.
|
||||
COMPOSE_FILES=(-f docker-compose.yml -f docker-compose.demo.yml)
|
||||
|
||||
# Phase 2 SEC-H3 (2026-05-13): the demo overlay sets
|
||||
# CERTCTL_DEMO_MODE_ACK=true; the SEC-H3 fail-closed guard
|
||||
# requires a paired CERTCTL_DEMO_MODE_ACK_TS within the last
|
||||
# 24h (a static YAML value would rot). The overlay reads
|
||||
# ${CERTCTL_DEMO_MODE_ACK_TS:-} from the shell, so we mint a
|
||||
# fresh timestamp here and export it for every compose
|
||||
# invocation in this job (initial up-d AND the force-recreate
|
||||
# at step 4).
|
||||
export CERTCTL_DEMO_MODE_ACK_TS="$(date +%s)"
|
||||
|
||||
log "1/4 down -v --remove-orphans"
|
||||
docker compose "${COMPOSE_FILES[@]}" down -v --remove-orphans 2>&1 | tail -3 || true
|
||||
|
||||
log "2/4 up -d (cold boot)"
|
||||
docker compose "${COMPOSE_FILES[@]}" up -d 2>&1 | tail -3
|
||||
|
||||
log "3/4 wait for healthchecks"
|
||||
wait_for_service_healthy postgres
|
||||
wait_for_service_healthy certctl-server
|
||||
wait_for_service_healthy certctl-agent || log " (agent skipped)"
|
||||
|
||||
log "4/4 minting day-0 admin (proves migration ladder + bootstrap path)"
|
||||
TOKEN="$(openssl rand -base64 32 | tr -d '\n')"
|
||||
{
|
||||
echo "CERTCTL_BOOTSTRAP_TOKEN=$TOKEN"
|
||||
# Re-emit the demo-mode ACK TS into the --env-file so the
|
||||
# force-recreate at step 4 inherits it. `--env-file` REPLACES
|
||||
# the shell-env source for variable interpolation on compose
|
||||
# operations that use it, so omitting this line would re-trip
|
||||
# the SEC-H3 guard.
|
||||
echo "CERTCTL_DEMO_MODE_ACK_TS=$CERTCTL_DEMO_MODE_ACK_TS"
|
||||
} > /tmp/_smoke.env
|
||||
docker compose "${COMPOSE_FILES[@]}" --env-file /tmp/_smoke.env up -d --force-recreate certctl-server 2>&1 | tail -2
|
||||
sleep 5
|
||||
wait_for_service_healthy certctl-server
|
||||
BODY="$(http_call POST /api/v1/auth/bootstrap "{\"token\":\"$TOKEN\",\"actor_name\":\"smoke-admin\"}")"
|
||||
KEY="$(echo "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["key_value"])')"
|
||||
[ -n "$KEY" ] || { log "bootstrap failed: $BODY"; exit 1; }
|
||||
|
||||
log "PASS — cold boot + force-recreate + admin bootstrap all green"
|
||||
log "tearing down"
|
||||
docker compose "${COMPOSE_FILES[@]}" down -v 2>&1 | tail -2
|
||||
|
||||
- name: Dump compose logs on failure
|
||||
if: failure()
|
||||
working-directory: deploy
|
||||
run: |
|
||||
for svc in postgres certctl-server certctl-agent certctl-tls-init; do
|
||||
echo "==== $svc ===="
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml logs --no-color --tail 200 "$svc" || true
|
||||
done
|
||||
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -422,17 +125,6 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: npm audit (production deps, high+critical)
|
||||
# Phase 1 TEST-L2 closure (2026-05-13):
|
||||
# Production frontend dependencies must not carry high or
|
||||
# critical CVEs. Dev-only deps (vitest, vite, eslint, etc.)
|
||||
# are excluded via --omit=dev since they never ship to
|
||||
# operators. If this gate fires, triage each finding via npm
|
||||
# overrides, dep upgrade, or a tracked --ignore with an issue
|
||||
# link. Do not mass-silence findings.
|
||||
working-directory: web
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
- name: TypeScript Check
|
||||
working-directory: web
|
||||
run: npx tsc --noEmit
|
||||
@@ -445,59 +137,30 @@ jobs:
|
||||
working-directory: web
|
||||
run: npx vite build
|
||||
|
||||
- name: Regression guards (extracted to scripts/ci-guards/)
|
||||
# All named regression guards live at scripts/ci-guards/<id>.sh per
|
||||
# ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally:
|
||||
# bash scripts/ci-guards/G-3-env-docs-drift.sh
|
||||
# Adding a new guard: drop a new <id>.sh; this loop auto-picks it up.
|
||||
# Contract: each guard MUST exit 0 on clean repo, non-zero with
|
||||
# ::error:: prefix on regression. See scripts/ci-guards/README.md.
|
||||
run: |
|
||||
set -e
|
||||
fail=0
|
||||
for g in scripts/ci-guards/*.sh; do
|
||||
echo "::group::$(basename "$g")"
|
||||
if ! bash "$g"; then
|
||||
fail=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $fail
|
||||
|
||||
helm-lint:
|
||||
name: Helm Chart Validation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: '3.13.0'
|
||||
|
||||
# HTTPS-Everywhere (v2.0.47): the chart fails render when no TLS source is
|
||||
# configured. Every lint/template invocation below must pick exactly one
|
||||
# provisioning mode — see deploy/helm/certctl/templates/_helpers.tpl
|
||||
# (certctl.tls.required) and docs/operator/tls.md.
|
||||
#
|
||||
# Bundle 3 closure (2026-05-12, commit f1fa311): the chart now ALSO
|
||||
# fails render when (a) server.auth.type=api-key + apiKey empty, or
|
||||
# (b) postgresql.enabled=true + postgresql.auth.password empty.
|
||||
# Every positive render below MUST pass both secrets; inverse tests
|
||||
# at the bottom of this job pin the fail-fast guards in place.
|
||||
# (certctl.tls.required) and docs/tls.md.
|
||||
- name: Lint Helm Chart
|
||||
run: |
|
||||
helm lint deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder \
|
||||
--set postgresql.auth.password=ci-postgres-placeholder
|
||||
--set server.tls.existingSecret=certctl-tls-ci
|
||||
|
||||
- name: Template Helm Chart (existingSecret mode)
|
||||
run: |
|
||||
helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder \
|
||||
--set postgresql.auth.password=ci-postgres-placeholder \
|
||||
> /dev/null
|
||||
|
||||
- name: Template Helm Chart (cert-manager mode)
|
||||
@@ -505,30 +168,8 @@ jobs:
|
||||
helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.certManager.enabled=true \
|
||||
--set server.tls.certManager.issuerRef.name=letsencrypt-prod \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder \
|
||||
--set postgresql.auth.password=ci-postgres-placeholder \
|
||||
> /dev/null
|
||||
|
||||
- name: Template Helm Chart (external Postgres mode — Bundle 3 D2)
|
||||
run: |
|
||||
# Closes Bundle 3 D2: postgresql.enabled=false must (a) render
|
||||
# cleanly with externalDatabase.url and (b) emit ZERO postgres-*
|
||||
# templates. The render output is grep-checked below.
|
||||
out=$(helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci \
|
||||
--set postgresql.enabled=false \
|
||||
--set externalDatabase.url='postgres://u:p@db.example.com:5432/certctl?sslmode=require' \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder)
|
||||
# Bundled-Postgres resources must not appear when postgresql.enabled=false.
|
||||
if echo "$out" | grep -qE "^kind: StatefulSet$"; then
|
||||
echo "::error::Bundle 3 D2 regression: postgres StatefulSet rendered with postgresql.enabled=false"
|
||||
exit 1
|
||||
fi
|
||||
if echo "$out" | grep -q "postgres-secret.yaml"; then
|
||||
echo "::error::Bundle 3 D2 regression: postgres-secret rendered with postgresql.enabled=false"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails without TLS)
|
||||
run: |
|
||||
# Inverse test: the chart MUST refuse to render when no TLS source is
|
||||
@@ -538,209 +179,3 @@ jobs:
|
||||
echo "::error::Helm chart rendered without a TLS source — fail-loud guard regressed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D7 TLS both-set)
|
||||
run: |
|
||||
# Bundle 3 D7: setting BOTH existingSecret AND certManager.enabled
|
||||
# creates two conflicting TLS sources of truth. Chart must refuse.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set server.tls.certManager.enabled=true \
|
||||
--set server.tls.certManager.issuerRef.name=foo \
|
||||
--set server.auth.apiKey=k \
|
||||
--set postgresql.auth.password=p \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D7 regression: chart rendered with BOTH TLS sources configured"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D1 missing apiKey)
|
||||
run: |
|
||||
# Bundle 3 D1: missing server.auth.apiKey when auth.type=api-key
|
||||
# must fail at template time, not silently render an empty Secret.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set postgresql.auth.password=p \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D1 regression: chart rendered with empty server.auth.apiKey"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D1 missing pg password)
|
||||
run: |
|
||||
# Bundle 3 D1: missing postgresql.auth.password when postgresql.enabled=true
|
||||
# must fail at template time, not silently use a fallback default.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set server.auth.apiKey=k \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D1 regression: chart rendered with empty postgresql.auth.password"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D1 missing external DB URL)
|
||||
run: |
|
||||
# Bundle 3 D1: missing externalDatabase.url when postgresql.enabled=false
|
||||
# must fail at template time.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set postgresql.enabled=false \
|
||||
--set server.auth.apiKey=k \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D1 regression: chart rendered with postgresql.enabled=false + empty externalDatabase.url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# deploy-vendor-e2e — single-job (collapsed from 12-job matrix)
|
||||
# =============================================================================
|
||||
# Per ci-pipeline-cleanup bundle Phase 5 / frozen decision 0.4 (revises
|
||||
# Bundle II decision 0.9): the per-vendor matrix produced 12 status-check
|
||||
# rows for ~1 real assertion (115/116 vendor-edge tests are t.Log
|
||||
# placeholders). Collapsed to one job that brings up all 11 sidecars
|
||||
# at once and runs the full VendorEdge_ test set.
|
||||
#
|
||||
# Skip-detection guard (scripts/vendor-e2e-skip-check.sh)
|
||||
# enforces that no test SKIPs except the documented allowlist
|
||||
# (windows-iis-requiring tests on Linux). If a sidecar fails to come
|
||||
# up, requireSidecar() in deploy/test/vendor_e2e_helpers.go calls
|
||||
# t.Skipf() — the guard catches that.
|
||||
#
|
||||
# RAM headroom on ubuntu-latest (16 GB ceiling) — operator-confirmed
|
||||
# in Phase 0 / frozen decision 0.14 prototype-branch run. If RAM
|
||||
# regresses, fall back to bucketed matrix per
|
||||
# the project's frozen-decisions log.
|
||||
#
|
||||
# The Windows matrix (deploy-vendor-e2e-windows) was deleted entirely
|
||||
# per Phase 6 / frozen decision 0.5 (revises Bundle II decision 0.4).
|
||||
# IIS + WinCertStore validation moved to the operator playbook at
|
||||
# docs/connector-iis.md::Operator validation playbook.
|
||||
deploy-vendor-e2e:
|
||||
name: deploy-vendor-e2e
|
||||
runs-on: ubuntu-latest
|
||||
needs: [go-build-and-test]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Build f5-mock-icontrol sidecar
|
||||
# The only sidecar without a published image; built from the in-tree
|
||||
# Go server at deploy/test/f5-mock-icontrol/.
|
||||
run: docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml build f5-mock-icontrol
|
||||
|
||||
- name: Bring up all vendor sidecars
|
||||
# Brings up the 11 deploy-e2e sidecars (apache-test, haproxy-test,
|
||||
# traefik-test, caddy-test, envoy-test, postfix-test, dovecot-test,
|
||||
# openssh-test, f5-mock-icontrol, k8s-kind-test, windows-iis-test
|
||||
# which is gated by a separate windows-only profile and won't
|
||||
# actually start) plus the always-on legacy nginx.
|
||||
run: |
|
||||
docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml up -d
|
||||
sleep 15
|
||||
|
||||
- name: Run all vendor-edge e2e
|
||||
# Captures test output for skip-count enforcement (next step).
|
||||
env:
|
||||
INTEGRATION: "1"
|
||||
run: |
|
||||
go test -tags integration -race -count=1 -run 'VendorEdge_' \
|
||||
./deploy/test/... 2>&1 | tee test-output.log
|
||||
|
||||
- name: Skip-count enforcement
|
||||
# ci-pipeline-cleanup Phase 5 / frozen decision 0.6:
|
||||
# requireSidecar uses t.Skipf (not t.Fatal) when a sidecar isn't
|
||||
# reachable — collapsing the per-vendor matrix removes the implicit
|
||||
# guard each per-job matrix entry provided. This step counts SKIP
|
||||
# lines in the test output and fails the build if it exceeds the
|
||||
# allowlist (windows-iis-requiring tests; legitimately skipped
|
||||
# on Linux per Phase 6 / frozen decision 0.5).
|
||||
run: bash scripts/vendor-e2e-skip-check.sh test-output.log
|
||||
|
||||
- name: Diagnostic dump on failure
|
||||
# Prints container status + last 200 log lines from the certctl-server
|
||||
# and base-stack containers when ANY previous step in this job fails.
|
||||
# The matrix-collapse (Phase 5) brings up ~18 containers concurrently
|
||||
# (vs 1 vendor sidecar at a time pre-collapse); transient failures
|
||||
# surface most often as "container certctl-test-server is unhealthy"
|
||||
# without any visible reason because compose only reports the
|
||||
# dependency-chain symptom, not the root cause. Dumping logs here
|
||||
# makes the underlying error (DB migration crash, port bind failure,
|
||||
# entrypoint stall, OOM kill) visible in the GitHub Actions log
|
||||
# without requiring a workstation reproduction.
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== docker compose ps -a ==="
|
||||
docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml ps -a || true
|
||||
echo ""
|
||||
echo "=== certctl-test-server logs (last 200 lines) ==="
|
||||
docker logs --tail 200 certctl-test-server 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-tls-init logs ==="
|
||||
docker logs certctl-test-tls-init 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-postgres logs (last 100 lines) ==="
|
||||
docker logs --tail 100 certctl-test-postgres 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-stepca logs (last 100 lines) ==="
|
||||
docker logs --tail 100 certctl-test-stepca 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-pebble logs (last 50 lines) ==="
|
||||
docker logs --tail 50 certctl-test-pebble 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-agent logs (last 100 lines) ==="
|
||||
docker logs --tail 100 certctl-test-agent 2>&1 || true
|
||||
|
||||
- name: Tear down sidecars
|
||||
if: always()
|
||||
run: docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml down -v
|
||||
|
||||
# =============================================================================
|
||||
# image-and-supply-chain — digest validity + Docker build smoke + OpenAPI parity
|
||||
# =============================================================================
|
||||
# Per ci-pipeline-cleanup bundle Phases 7-9 / frozen decision 0.8.
|
||||
# Three checks bundled into one job (parallel to go-build-and-test):
|
||||
# 1. Digest validity — every @sha256 ref in deploy/* + Dockerfiles must
|
||||
# resolve on its registry. Closes the H-001 lying-field gap (H-001
|
||||
# verifies digest *presence* but not *resolution* — Bundle II shipped
|
||||
# 11 fabricated digests that passed H-001 and failed `docker pull`).
|
||||
# 2. Docker build smoke — all 4 Dockerfiles in the repo must build.
|
||||
# Catches syntax errors / COPY path drift before tag-time release.yml.
|
||||
# 3. OpenAPI ↔ handler parity — every router route has a matching
|
||||
# operationId or is documented in api/openapi-handler-exceptions.yaml.
|
||||
image-and-supply-chain:
|
||||
name: image-and-supply-chain
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Digest validity (every @sha256 ref must resolve)
|
||||
run: bash scripts/ci-guards/digest-validity.sh
|
||||
|
||||
- name: Docker build smoke (all 4 Dockerfiles)
|
||||
# Per frozen decision 0.10: build all 4 Dockerfiles in the repo,
|
||||
# not just production server + agent. The test-sidecar Dockerfiles
|
||||
# are load-bearing for vendor-e2e — a syntax error there silently
|
||||
# breaks the e2e suite.
|
||||
run: |
|
||||
set -e
|
||||
docker build -f Dockerfile -t certctl:smoke .
|
||||
docker build -f Dockerfile.agent -t certctl-agent:smoke .
|
||||
docker build -f deploy/test/f5-mock-icontrol/Dockerfile -t f5-mock:smoke .
|
||||
docker build -f deploy/test/libest/Dockerfile -t libest:smoke .
|
||||
echo "All 4 Dockerfiles build clean."
|
||||
|
||||
- name: OpenAPI ↔ handler operationId parity
|
||||
run: bash scripts/ci-guards/openapi-handler-parity.sh
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
name: CodeQL
|
||||
|
||||
# Public-facing SAST baseline that complements the existing security-deep-scan
|
||||
# workflow (gosec, osv-scanner, trivy, ZAP, semgrep, schemathesis, nuclei,
|
||||
# testssl) with cross-file Go and JavaScript dataflow analysis. Results land
|
||||
# in the repository's Security → Code scanning tab as a public signal — any
|
||||
# operator/security team auditing certctl can see the scan history and
|
||||
# triage state without asking.
|
||||
#
|
||||
# Why CodeQL in addition to gosec:
|
||||
# - gosec is single-file pattern matching (catches obvious issues like
|
||||
# `os/exec.Command(userInput)`); CodeQL does interprocedural taint
|
||||
# tracking (catches the same issue when the userInput is laundered
|
||||
# through several function calls or struct fields).
|
||||
# - GitHub-native; no third-party SaaS license gate (works for BSL 1.1
|
||||
# and other source-available licenses, unlike Aikido / Snyk / SonarCloud
|
||||
# free tiers which require OSI-approved licenses).
|
||||
# - SARIF results auto-deduplicate and persist on PRs, so reviewers see
|
||||
# "this PR introduces N new findings" rather than re-running ad hoc.
|
||||
#
|
||||
# Findings that are intentional (e.g., the SSH connector's
|
||||
# InsecureIgnoreHostKey, ACME DNS solver's intentional shell-out to operator-
|
||||
# supplied scripts) get suppressed via inline `// codeql[<rule-id>]`
|
||||
# comments OR via a `.github/codeql/codeql-config.yml` query-pack tweak —
|
||||
# document the rationale in the same commit that adds the suppression so
|
||||
# the public scan-tab readers see the threat-model justification.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
# Weekly Sunday 06:00 UTC, in addition to push/PR coverage. Catches
|
||||
# rule-pack updates from CodeQL upstream (their Go/JS rulesets ship
|
||||
# new queries on a roughly-monthly cadence).
|
||||
- cron: '0 6 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # SARIF upload to GitHub code scanning
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [go, javascript-typescript]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
# Match ci.yml + release.yml + security-deep-scan.yml.
|
||||
go-version: '1.25.10'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# Use the security-and-quality query suite — security finds plus
|
||||
# maintainability/correctness issues that the smaller security-extended
|
||||
# suite skips. Comparable scope to what Aikido / SonarCloud run.
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# SARIF upload is implicit (and is what populates the Security tab).
|
||||
@@ -1,139 +0,0 @@
|
||||
# Load-test workflow — closes the #8 acquisition-readiness blocker from
|
||||
# the 2026-05-01 issuer coverage audit (see
|
||||
# the 2026-05-01 issuer coverage audit).
|
||||
#
|
||||
# CADENCE: workflow_dispatch + weekly cron, NOT per-push. Load tests
|
||||
# are minutes long and don't provide useful per-PR signal — per-push
|
||||
# pressure goes through ci.yml. This workflow exists to (a) catch
|
||||
# gradual regressions from cumulative changes that no single PR
|
||||
# triggered, and (b) give an operator a one-click way to capture
|
||||
# numbers before tagging a release.
|
||||
#
|
||||
# THRESHOLDS: defined in deploy/test/loadtest/k6.js (p99 < 5s for
|
||||
# issuance-acceptance, p99 < 2s for list, error rate < 1%). k6 exits
|
||||
# non-zero on any breach, which propagates through `docker compose up
|
||||
# --exit-code-from k6` → `make loadtest` → this workflow's exit.
|
||||
|
||||
name: loadtest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# Manual trigger from the Actions tab. Use before tagging a
|
||||
# release or after a meaningful tuning commit.
|
||||
|
||||
schedule:
|
||||
# Mondays at 06:00 UTC. Off-peak; catches regressions accumulated
|
||||
# over the previous week's merges. Once a baseline is committed
|
||||
# in deploy/test/loadtest/README.md, drift relative to that
|
||||
# baseline is the signal — diff the captured summary.json
|
||||
# against the committed numbers.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
# Reduce permissions — this workflow doesn't write to PRs or push tags.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
k6:
|
||||
name: k6 throughput run
|
||||
runs-on: ubuntu-latest
|
||||
# 25-minute hard cap. Pre-Bundle-10: 15min was enough for the API
|
||||
# tier alone (~7 minutes total). Post-Bundle-10 the harness boots
|
||||
# four additional target sidecars (nginx, apache, haproxy, f5-mock)
|
||||
# before the k6 run; their healthchecks add ~30-60s. The k6 scenarios
|
||||
# themselves are still 5 minutes (run in parallel with the API
|
||||
# scenarios, not serially). 25 minutes absorbs that plus slow CI
|
||||
# runners and cold image caches without letting a stuck container
|
||||
# consume the runner indefinitely.
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
# The compose stack builds the certctl image from the repo
|
||||
# root Dockerfile. Buildx gives the build a usable cache and
|
||||
# works with newer compose versions.
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Run loadtest
|
||||
run: make loadtest
|
||||
env:
|
||||
# Disable BuildKit progress noise so the run log is
|
||||
# diff-able against past runs.
|
||||
BUILDKIT_PROGRESS: plain
|
||||
|
||||
- name: Upload summary
|
||||
# Always upload the summary so a regression has a diffable
|
||||
# artifact even when k6 exited non-zero. summary.json is the
|
||||
# authoritative machine-readable form; summary.txt is the
|
||||
# human-readable text the README baseline tracks.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: k6-summary-${{ github.run_id }}
|
||||
path: deploy/test/loadtest/results/
|
||||
retention-days: 90
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 8 SCALE-H2 — scale-tier scenarios. Three new k6 drivers:
|
||||
# - bulk-renewal: 10K-cert seed + criteria-mode POST /bulk-renew
|
||||
# - acme-burst: 200 concurrent VUs against directory/nonce/ARI
|
||||
# - agent-storm: 5K-agent seed + 167 heartbeats/sec sustained
|
||||
#
|
||||
# Matrix dispatch so each scenario runs on its own runner and a
|
||||
# regression in one doesn't mask another. The matrix runs in parallel,
|
||||
# which keeps total wall time around the existing 25-minute cap rather
|
||||
# than ~70 minutes serialised. Each scenario brings up the full
|
||||
# loadtest compose stack independently — there's no shared state
|
||||
# between scenarios that would benefit from a single-runner serial
|
||||
# invocation.
|
||||
#
|
||||
# Cadence: same as the API + connector tier job above (workflow_dispatch
|
||||
# + Mondays 06:00 UTC). The scale scenarios DO produce useful per-PR
|
||||
# signal in theory, but the per-run cost (image build + 5min run × 3)
|
||||
# is too high to gate on every PR; weekly is the right trade-off.
|
||||
# ---------------------------------------------------------------------------
|
||||
k6-scale:
|
||||
name: k6 scale tier (${{ matrix.scenario }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: k6
|
||||
strategy:
|
||||
# Parallel: a failure in one scenario shouldn't cancel the others.
|
||||
# Each scenario's threshold breach is independent diagnostic data.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
scenario:
|
||||
- bulk-renewal
|
||||
- acme-burst
|
||||
- agent-storm
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Run scale loadtest (${{ matrix.scenario }})
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
run: |
|
||||
case "${{ matrix.scenario }}" in
|
||||
bulk-renewal) make loadtest-scale-bulk ;;
|
||||
acme-burst) make loadtest-scale-acme ;;
|
||||
agent-storm) make loadtest-scale-agent ;;
|
||||
*) echo "::error::unknown scenario ${{ matrix.scenario }}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Upload summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
# Per-scenario artifact name so the three matrix runs don't
|
||||
# collide on upload.
|
||||
name: k6-scale-${{ matrix.scenario }}-${{ github.run_id }}
|
||||
path: deploy/test/loadtest/results/
|
||||
retention-days: 90
|
||||
@@ -1,12 +1,5 @@
|
||||
name: Release
|
||||
|
||||
# Override the auto-generated run name (which would otherwise default to
|
||||
# the most recent commit subject + a #NN run number) so the Actions tab
|
||||
# shows "Release v2.0.69" instead of "chore: rename Go module path... #73".
|
||||
# `github.ref_name` resolves to the tag name (e.g., `v2.0.69`) for tag-triggered
|
||||
# workflows, which is the only trigger we set below.
|
||||
run-name: Release ${{ github.ref_name }}
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -15,8 +8,8 @@ on:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# Keep in lock-step with .github/workflows/ci.yml (M-3).
|
||||
GO_VERSION: '1.25.10'
|
||||
IMAGE_NAMESPACE: certctl-io
|
||||
GO_VERSION: '1.25.9'
|
||||
IMAGE_NAMESPACE: shankar0123
|
||||
|
||||
jobs:
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -39,10 +32,10 @@ jobs:
|
||||
os: [linux, darwin]
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
@@ -50,23 +43,6 @@ jobs:
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install govulncheck
|
||||
# Bundle D / Audit L-008: release.yml previously had no vulnerability
|
||||
# scan, so a release tag could in principle ship a binary with a
|
||||
# known CVE in transitive deps that ci.yml's govulncheck would have
|
||||
# caught on master. Pre-build scan blocks the release if anything
|
||||
# surfaced post-merge. Pinned to the same major as ci.yml.
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Run govulncheck (release gate)
|
||||
# govulncheck distinguishes called-vs-uncalled vulnerable functions.
|
||||
# Default exit code (0 unless an actual call site lands in a vuln
|
||||
# function) is the right gate for release; deferred-call advisories
|
||||
# are tracked separately on master via L-021. If a release-time
|
||||
# scan surfaces a NEW called-vuln, the release is blocked until the
|
||||
# bump lands on master and a new tag is cut.
|
||||
run: govulncheck ./...
|
||||
|
||||
- name: Build binary
|
||||
id: build
|
||||
env:
|
||||
@@ -123,7 +99,7 @@ jobs:
|
||||
cat "${OUTPUT_NAME}.sha256"
|
||||
|
||||
- name: Upload build artefacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ steps.build.outputs.output_name }}
|
||||
path: |
|
||||
@@ -151,7 +127,7 @@ jobs:
|
||||
hashes: ${{ steps.hashes.outputs.hashes }}
|
||||
steps:
|
||||
- name: Download binary artefacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: binary-*
|
||||
path: artifacts
|
||||
@@ -191,7 +167,7 @@ jobs:
|
||||
checksums.txt
|
||||
|
||||
- name: Upload artefacts to GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
@@ -212,24 +188,11 @@ jobs:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
|
||||
with:
|
||||
base64-subjects: "${{ needs.aggregate-checksums.outputs.hashes }}"
|
||||
upload-assets: true
|
||||
provenance-name: multiple.intoto.jsonl
|
||||
# Phase 1 RED-2 compat (2026-05-14): the SLSA reusable workflow's
|
||||
# default path downloads a pre-built generator binary from a
|
||||
# GitHub *release* of slsa-framework/slsa-github-generator —
|
||||
# releases are keyed by tag name (vX.Y.Z), and the workflow
|
||||
# rejects SHA-form refs with "Expected ref of the form
|
||||
# refs/tags/vX.Y.Z". Phase 1 RED-2 SHA-pinned every Actions
|
||||
# uses: line, so the default path errors out. Setting
|
||||
# compile-generator: true instead builds the generator from the
|
||||
# pinned-SHA source inside the workflow run — preserves
|
||||
# supply-chain integrity (SHA pin retained), adds ~1 min build
|
||||
# time. This is the SLSA project's documented escape hatch for
|
||||
# SHA-pinned reusable-workflow consumers.
|
||||
compile-generator: true
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# build-and-push-docker: push container images to GHCR with native
|
||||
@@ -248,10 +211,10 @@ jobs:
|
||||
id-token: write # Cosign keyless OIDC identity token
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -262,14 +225,14 @@ jobs:
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Build and push server image
|
||||
id: server-push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -304,7 +267,7 @@ jobs:
|
||||
|
||||
- name: Build and push agent image
|
||||
id: agent-push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.agent
|
||||
@@ -347,33 +310,82 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release with notes
|
||||
# generate_release_notes: true asks GitHub to auto-generate the
|
||||
# "What's Changed" section from PRs+commits between this tag and the
|
||||
# previous one. The hardcoded body below appends a per-release
|
||||
# supply-chain verification block (Cosign / SLSA / SBOM steps with the
|
||||
# current version baked into the commands) plus a single link to the
|
||||
# README's Quick Start section for install/upgrade instructions.
|
||||
# We deliberately do NOT duplicate install instructions here — the
|
||||
# README is the source of truth for those, and inlining them in every
|
||||
# release page produces the kind of "every release looks identical"
|
||||
# noise that gives operators no signal about what actually changed.
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Pin the release title to the tag name. softprops/action-gh-release@v2
|
||||
# falls back to the most recent commit subject when `name:` is omitted,
|
||||
# which produces ugly titles like "chore: rename Go module path..." on
|
||||
# the Releases page. `github.ref_name` evaluates to the tag (`v2.0.69`).
|
||||
name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
> **Install / upgrade:** see the [Quick Start section in the README](https://github.com/certctl-io/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions.
|
||||
## Installation
|
||||
|
||||
### Quick Install (Linux/macOS)
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||
```
|
||||
|
||||
### Manual Binary Download
|
||||
|
||||
Download the appropriate binary for your OS and architecture:
|
||||
|
||||
- **Linux x86_64**: `certctl-agent-linux-amd64`
|
||||
- **Linux ARM64**: `certctl-agent-linux-arm64`
|
||||
- **macOS x86_64**: `certctl-agent-darwin-amd64`
|
||||
- **macOS ARM64 (Apple Silicon)**: `certctl-agent-darwin-arm64`
|
||||
|
||||
Then make it executable and start the service:
|
||||
|
||||
```bash
|
||||
chmod +x certctl-agent-linux-amd64
|
||||
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
|
||||
```
|
||||
|
||||
## Docker Images
|
||||
|
||||
Pull pre-built Docker images for server and agent:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
docker pull ghcr.io/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
```
|
||||
|
||||
Or use the latest tag:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/shankar0123/certctl-server:latest
|
||||
docker pull ghcr.io/shankar0123/certctl-agent:latest
|
||||
```
|
||||
|
||||
## Docker Compose Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
cp deploy/.env.example deploy/.env
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Server Binaries
|
||||
|
||||
Pre-compiled server binaries are also available for direct installation:
|
||||
|
||||
- **Linux x86_64**: `certctl-server-linux-amd64`
|
||||
- **Linux ARM64**: `certctl-server-linux-arm64`
|
||||
- **macOS x86_64**: `certctl-server-darwin-amd64`
|
||||
- **macOS ARM64 (Apple Silicon)**: `certctl-server-darwin-arm64`
|
||||
|
||||
## CLI & MCP Server Binaries
|
||||
|
||||
The `certctl-cli` (REST API wrapper) and `certctl-mcp-server` (Model Context
|
||||
Protocol bridge) binaries ship for all four platforms as well:
|
||||
|
||||
- `certctl-cli-{linux,darwin}-{amd64,arm64}`
|
||||
- `certctl-mcp-server-{linux,darwin}-{amd64,arm64}`
|
||||
|
||||
## Verifying this release
|
||||
|
||||
@@ -394,7 +406,7 @@ jobs:
|
||||
```bash
|
||||
cosign verify-blob \
|
||||
--bundle checksums.txt.sigstore.json \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
checksums.txt
|
||||
```
|
||||
@@ -408,7 +420,7 @@ jobs:
|
||||
```bash
|
||||
slsa-verifier verify-artifact \
|
||||
--provenance-path multiple.intoto.jsonl \
|
||||
--source-uri github.com/certctl-io/certctl \
|
||||
--source-uri github.com/shankar0123/certctl \
|
||||
--source-tag ${{ steps.version.outputs.VERSION }} \
|
||||
certctl-agent-linux-amd64
|
||||
```
|
||||
@@ -416,21 +428,33 @@ jobs:
|
||||
**4. Verify container image signature and attestations:**
|
||||
|
||||
```bash
|
||||
IMAGE=ghcr.io/certctl-io/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
IMAGE=ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
cosign verify \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
|
||||
# SBOM attestation (SPDX-JSON) emitted by docker/build-push-action
|
||||
cosign verify-attestation --type spdxjson \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/' \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
|
||||
# SLSA provenance attestation (mode=max)
|
||||
cosign verify-attestation --type slsaprovenance \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/' \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
```
|
||||
|
||||
## Helm Chart
|
||||
|
||||
Deploy certctl to Kubernetes using Helm:
|
||||
|
||||
```bash
|
||||
helm repo add certctl https://github.com/shankar0123/certctl/tree/master/deploy/helm
|
||||
helm repo update
|
||||
helm install certctl certctl/certctl
|
||||
```
|
||||
|
||||
See `deploy/helm/certctl/` for values customization.
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
name: security-deep-scan
|
||||
|
||||
# Bundle-7 / Audit D-001..D-007:
|
||||
# Slow / containerized scans on a daily schedule + manual dispatch.
|
||||
# Per-PR fast gates live in ci.yml; this workflow runs the heavyweight
|
||||
# tools that need docker, network egress to scanner registries, or
|
||||
# longer wall-clock budgets than a per-PR check tolerates.
|
||||
#
|
||||
# Scope:
|
||||
# trivy image container CVE + secret scan
|
||||
# syft SBOM CycloneDX SBOM artefact upload
|
||||
# ZAP baseline DAST baseline against a live deploy_test stack (D-004)
|
||||
# nuclei template-based vuln scan against the same stack
|
||||
# schemathesis OpenAPI fuzz against the running server
|
||||
# testssl.sh TLS configuration audit (D-005)
|
||||
# race detector x10 full -count=10 race run on the entire test suite (D-002)
|
||||
# gosec Go security static analysis (slow first run)
|
||||
# go-mutesting mutation testing on crypto cluster (D-003)
|
||||
# semgrep p/react-security frontend XSS / dangerouslySetInnerHTML / target=_blank ruleset (D-007)
|
||||
#
|
||||
# Each step is best-effort — failures are uploaded as artefacts but do
|
||||
# NOT block the workflow. Triage happens via the Bundle-7 receipt
|
||||
# the project's comprehensive-audit tool-output directory.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *' # daily 06:00 UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # SARIF upload to GitHub code scanning
|
||||
|
||||
jobs:
|
||||
deep-scan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Install Go-based tools
|
||||
run: bash scripts/install-security-tools.sh
|
||||
continue-on-error: true
|
||||
|
||||
# --- Static analysis (slow paths) ---
|
||||
|
||||
- name: gosec (G201/G202/G304/G108 subset — Phase 3 TEST-M2 hard gate)
|
||||
# Phase 3 TEST-M2 closure (2026-05-13): gosec promoted from
|
||||
# continue-on-error (advisory) to blocking on the 4 high-signal
|
||||
# rule subset that targets real prod-bug classes:
|
||||
# G201 = SQL string formatting (SQL injection)
|
||||
# G202 = SQL string concatenation (SQL injection)
|
||||
# G304 = file-path traversal via tainted input
|
||||
# G108 = profiling endpoint exposed
|
||||
# Other gosec rules (G1xx-G7xx broadly) remain in the SARIF
|
||||
# report but don't gate the build — they have higher false-
|
||||
# positive rates than these 4.
|
||||
run: $(go env GOPATH)/bin/gosec -fmt sarif -out gosec.sarif -include=G201,G202,G304,G108 ./...
|
||||
|
||||
- name: osv-scanner (multi-ecosystem CVE — Phase 3 TEST-M2 hard gate)
|
||||
# Phase 3 TEST-M2 closure (2026-05-13): osv-scanner promoted from
|
||||
# advisory to blocking. Complements govulncheck (already blocking
|
||||
# in ci.yml) by covering non-Go dependencies (npm under web/,
|
||||
# any docker base image deps). Findings fail the build; the
|
||||
# exact CVE list lands in osv-scanner.json as a receipt either way.
|
||||
run: $(go env GOPATH)/bin/osv-scanner -r --format json --output osv-scanner.json .
|
||||
|
||||
# --- Race detector at -count=10 (D-002) ---
|
||||
|
||||
- name: go test -race -count=10 (full suite)
|
||||
run: |
|
||||
go test -race -count=10 -short ./... 2>&1 | tee go-test-race.txt
|
||||
continue-on-error: true
|
||||
|
||||
# --- Coverage receipts for crypto cluster (H-005) ---
|
||||
|
||||
- name: go test -cover (crypto cluster)
|
||||
run: |
|
||||
go test -cover -covermode=atomic \
|
||||
./internal/crypto/... \
|
||||
./internal/pkcs7/... \
|
||||
./internal/connector/issuer/local/... \
|
||||
2>&1 | tee go-test-cover.txt
|
||||
|
||||
# --- Mutation testing on crypto cluster (D-003) ---
|
||||
#
|
||||
# Operator runbook: docs/testing-strategy.md::Mutation testing.
|
||||
# Tool: go-mutesting (https://github.com/zimmski/go-mutesting). Each
|
||||
# package is mutated independently; the per-package summary line
|
||||
# (`The mutation score is X.YZ`) is grep-extracted into the receipt.
|
||||
# Acceptance threshold: ≥80% kill ratio per package; surviving
|
||||
# mutants get triaged in the project's comprehensive-audit notes/
|
||||
# d003-mutation-results.md (per-mutant action item or
|
||||
# equivalent-mutation justification).
|
||||
|
||||
- name: Install go-mutesting
|
||||
run: go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest
|
||||
continue-on-error: true
|
||||
|
||||
- name: go-mutesting (crypto cluster — Phase 3 TEST-M1 hard gate at 55%)
|
||||
# Phase 3 TEST-M1 closure (2026-05-13): go-mutesting promoted
|
||||
# from advisory (continue-on-error + per-package `|| true`) to
|
||||
# blocking with an explicit mutation-score floor of 55%.
|
||||
# Per-package summary lines emit `The mutation score is X.YZ`;
|
||||
# the awk filter extracts each, and the post-loop check fails
|
||||
# the step if any package drops below 0.55.
|
||||
#
|
||||
# Floor rationale: 55% is the starter ratio that catches major
|
||||
# regressions without rejecting the audit's "this is OK" steady
|
||||
# state. Raise quarterly as the test suite hardens; the floor
|
||||
# change ships in the same commit that adds the strengthening
|
||||
# tests so the ratchet is documented.
|
||||
run: |
|
||||
set -e
|
||||
: > go-mutesting.txt
|
||||
for pkg in ./internal/crypto/... ./internal/pkcs7/... ./internal/connector/issuer/local/...; do
|
||||
echo "=== $pkg ===" | tee -a go-mutesting.txt
|
||||
$(go env GOPATH)/bin/go-mutesting "$pkg" 2>&1 | tee -a go-mutesting.txt
|
||||
done
|
||||
# Extract every "The mutation score is X.YZ" line; fail on any
|
||||
# score below 0.55. The check works against floats via awk so
|
||||
# 0.55 is the literal threshold (not a percentage).
|
||||
floor=0.55
|
||||
fail=0
|
||||
while IFS= read -r score; do
|
||||
ok=$(awk -v s="$score" -v f="$floor" 'BEGIN{print (s>=f) ? 1 : 0}')
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::mutation score $score below floor $floor"
|
||||
fail=1
|
||||
fi
|
||||
done < <(grep -oE "The mutation score is [0-9.]+" go-mutesting.txt | awk '{print $NF}')
|
||||
exit $fail
|
||||
|
||||
# --- Container + supply chain (D-001 partial, D-006 partial) ---
|
||||
|
||||
- name: Build certctl image
|
||||
run: docker build -t certctl:deep-scan .
|
||||
continue-on-error: true
|
||||
|
||||
- name: trivy image scan (HIGH+CRITICAL — Phase 3 TEST-M2 hard gate)
|
||||
# Phase 3 TEST-M2 closure (2026-05-13): trivy promoted from
|
||||
# advisory to blocking. --severity filter keeps the gate
|
||||
# noise-free (LOW + MEDIUM findings stay in the JSON receipt
|
||||
# but don't fail the build); --exit-code 1 makes HIGH+CRITICAL
|
||||
# findings the actual gate. Trivy is the third hard deep-scan
|
||||
# gate (alongside gosec + osv-scanner); ZAP / schemathesis /
|
||||
# nuclei / testssl stay advisory because their false-positive
|
||||
# rates on https://localhost:8443-targeted DAST runs are high.
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/src aquasec/trivy:latest image \
|
||||
--format json --output /src/trivy.json \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
certctl:deep-scan
|
||||
|
||||
- name: syft SBOM
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/src anchore/syft:latest dir:/src \
|
||||
-o cyclonedx-json > syft.cyclonedx.json || true
|
||||
continue-on-error: true
|
||||
|
||||
# --- DAST against a live stack (D-004) ---
|
||||
|
||||
- name: docker compose up (test stack)
|
||||
run: |
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
sleep 20
|
||||
continue-on-error: true
|
||||
|
||||
- name: ZAP baseline
|
||||
uses: zaproxy/action-baseline@1e1871e84428617b969d4a1f981a8255630d54b0 # v0.10.0
|
||||
with:
|
||||
target: 'https://localhost:8443'
|
||||
continue-on-error: true
|
||||
|
||||
- name: schemathesis (OpenAPI fuzz)
|
||||
run: |
|
||||
pip install schemathesis
|
||||
schemathesis run --base-url https://localhost:8443 \
|
||||
--hypothesis-max-examples=50 api/openapi.yaml || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: nuclei
|
||||
run: |
|
||||
docker run --rm --network host projectdiscovery/nuclei:latest \
|
||||
-u https://localhost:8443 -j -o nuclei.json || true
|
||||
continue-on-error: true
|
||||
|
||||
# --- TLS audit (D-005) ---
|
||||
|
||||
- name: testssl.sh
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/data drwetter/testssl.sh:latest \
|
||||
--jsonfile /data/testssl.json https://localhost:8443 || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: docker compose down
|
||||
run: docker compose -f deploy/docker-compose.yml down || true
|
||||
if: always()
|
||||
|
||||
# --- Frontend XSS / unsafe-link ruleset (D-007) ---
|
||||
#
|
||||
# Operator runbook: docs/testing-strategy.md::Frontend semgrep.
|
||||
# Bundle 8 already verified `dangerouslySetInnerHTML` count at
|
||||
# zero and the `target="_blank"` rel-noopener pin via grep
|
||||
# guards in ci.yml — semgrep p/react-security adds defence in
|
||||
# depth (it catches escape patterns the grep guards don't see,
|
||||
# e.g., href={user_input}, eval, document.write).
|
||||
|
||||
- name: semgrep p/react-security (frontend)
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/src returntocorp/semgrep:latest \
|
||||
semgrep --config=p/react-security --json /src/web/src \
|
||||
> semgrep-react.json 2>semgrep-react.stderr || true
|
||||
continue-on-error: true
|
||||
|
||||
# --- Upload everything as artefacts ---
|
||||
|
||||
- name: Upload deep-scan receipts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always()
|
||||
with:
|
||||
name: security-deep-scan-${{ github.run_id }}
|
||||
path: |
|
||||
gosec.sarif
|
||||
osv-scanner.json
|
||||
go-test-race.txt
|
||||
go-test-cover.txt
|
||||
go-mutesting.txt
|
||||
trivy.json
|
||||
syft.cyclonedx.json
|
||||
nuclei.json
|
||||
testssl.json
|
||||
semgrep-react.json
|
||||
semgrep-react.stderr
|
||||
retention-days: 30
|
||||
-14
@@ -88,17 +88,3 @@ Thumbs.db
|
||||
# CERTCTL_TEST_CA_BUNDLE=./certs/ca.crt. Material is regenerated on every
|
||||
# `docker compose up` and never belongs in git.
|
||||
/deploy/test/certs/
|
||||
|
||||
# Phase 1 RED-1 closure (2026-05-13): the f5-mock-icontrol Dockerfile
|
||||
# rebuilds from source via multi-stage build (deploy/test/f5-mock-icontrol/
|
||||
# Dockerfile line 13). The compiled ELF must not be tracked.
|
||||
deploy/test/f5-mock-icontrol/f5-mock-icontrol
|
||||
|
||||
# Phase 0 closure (2026-05-13): cowork/ holds the operator's internal
|
||||
# legal / audit / strategy artifacts (counsel-signed AI-authorship
|
||||
# declaration, filter-repo callback, pre-rewrite bundle, audit HTML
|
||||
# scratch). It is private operator scratch space and must never
|
||||
# accidentally land in the public repo. See
|
||||
# docs/history-normalization.md for the public-facing description of
|
||||
# the Phase 0 git-history rewrite.
|
||||
cowork/
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Bundle-7 / Audit D-001 / govulncheck suppressions.
|
||||
#
|
||||
# Format: one OSV ID per line, with a comment justifying the suppression.
|
||||
# Every entry needs:
|
||||
# - the OSV ID (GO-YYYY-NNNN)
|
||||
# - one-line "what is it"
|
||||
# - one-line "why we're not affected" (must reference call-graph evidence)
|
||||
# - "review-by" date (YYYY-MM-DD) — re-triage on/after this date
|
||||
#
|
||||
# Triage rule: only suppress an advisory if `govulncheck ./...` (NOT
|
||||
# verbose) reports it as a deferred-call vulnerability ("packages you
|
||||
# import" or "modules you require", not "Your code is affected by").
|
||||
#
|
||||
# At Bundle-7 time (2026-04-26): the 5 advisories surfaced are all in
|
||||
# transitive deps and govulncheck confirms our code does not call them.
|
||||
# Documented here for tracking; no entries needed because the default
|
||||
# fail-on-non-zero gate already passes (govulncheck distinguishes
|
||||
# called vs uncalled and only exits non-zero when the latter calls in).
|
||||
#
|
||||
# Example (do not enable unless the advisory becomes call-affected):
|
||||
# GO-2026-4441 # transitive: golang.org/x/crypto pre-v0.40 — net/ssh terrapin downgrade; we don't use net/ssh; review 2026-07-01
|
||||
+34
-786
@@ -1,802 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
### Breaking changes (scheduled for v2.2.0)
|
||||
## [2.2.0] — 2026-04-19
|
||||
|
||||
- **SEC-H1 staged: `CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY` opt-in flag.**
|
||||
Phase 2 of the architecture diligence remediation (2026-05-13) introduces
|
||||
a new env var that, when set to `true`, makes the server refuse to start
|
||||
unless `CERTCTL_AGENT_BOOTSTRAP_TOKEN` is also set to a real value.
|
||||
Default in this release: `false` (preserves the v2.1.x warn-mode
|
||||
pass-through behavior for backward compatibility). Default flip to
|
||||
`true` is scheduled for v2.2.0 per `WORKSPACE-ROADMAP.md`.
|
||||
### HTTPS Everywhere — The Irony
|
||||
|
||||
**Operator action before the v2.2.0 upgrade:** generate a real
|
||||
bootstrap token (`openssl rand -base64 32`) and set
|
||||
`CERTCTL_AGENT_BOOTSTRAP_TOKEN` in your env. When v2.2.0 ships, the
|
||||
deny-empty default flips to `true` and a missing or empty token will
|
||||
fail closed at boot. Operators with the token already set: no action
|
||||
required.
|
||||
> certctl manages other teams' certificates. Until v2.2, it didn't terminate TLS on its own control plane. We treated the server as an internal service sitting behind whatever TLS-terminating infrastructure the operator already owned — reverse proxies, Kubernetes Ingress controllers, service mesh sidecars. Working through an EST coverage-gap audit surfaced this as a credibility problem we wanted to fix head-on: a cert-lifecycle product should ship with HTTPS by default. This release flips that. Self-signed bootstrap for docker-compose demos, operator-supplied Secret for Helm (with optional cert-manager integration), and a one-step cutover with no backward-compat bridge. Out-of-date agents will fail at the TLS handshake layer on upgrade; the upgrade guide walks operators through the roll.
|
||||
|
||||
- **SEC-M4: `CERTCTL_ACME_INSECURE` now requires explicit ACK.**
|
||||
Pre-Phase-2, `CERTCTL_ACME_INSECURE=true` produced only a boot-time
|
||||
WARN log. Post-Phase-2 (THIS release), the server refuses to start
|
||||
unless `CERTCTL_ACME_INSECURE_ACK=true` is set alongside it. ACME
|
||||
directory TLS verification is the load-bearing defense against a
|
||||
network attacker intercepting ACME enrollment; the existing flag was
|
||||
too easy to flip via a copy-pasted Pebble runbook.
|
||||
### Breaking Changes
|
||||
|
||||
**Operator action:** if you intentionally run against a self-signed
|
||||
ACME server (Pebble, step-ca, internal dev), add
|
||||
`CERTCTL_ACME_INSECURE_ACK=true` to your env. Production deploys
|
||||
MUST never set either flag.
|
||||
- **HTTPS-only control plane. The plaintext HTTP listener is gone.** There is no `CERTCTL_TLS_ENABLED=false` escape hatch and no `:8080` fallback. Operators who were running certctl behind their own TLS terminator must either (a) continue doing so and let the downstream TLS terminator talk to certctl's HTTPS listener, or (b) bring their own cert/key and terminate on certctl directly. Either path requires config changes — see `docs/upgrade-to-tls.md` for a one-step cutover.
|
||||
- **Agents reject `CERTCTL_SERVER_URL=http://...` at startup.** This is a pre-flight config validation failure with a fail-loud diagnostic pointing at `docs/upgrade-to-tls.md`. Not a TCP-refused, not a TLS-handshake-error — the agent will not even attempt the network call. Every agent deployment must be reconfigured before upgrading the server.
|
||||
- **CLI and MCP clients require `https://` URLs.** Same pre-flight rejection of plaintext schemes.
|
||||
- **TLS 1.2 is not supported. TLS 1.3 only.** The server's `tls.Config.MinVersion` is pinned to `tls.VersionTLS13`. Any client still negotiating TLS 1.2 will fail at the handshake. Modern curl, Go stdlib, browsers, and Kubernetes tooling all default to 1.3-capable; legacy clients may need an upgrade.
|
||||
- **Helm chart requires a TLS source.** `helm install` without one of `server.tls.existingSecret`, `server.tls.certManager.enabled`, or (for eval only) `server.tls.selfSigned.enabled` fails at template time with a diagnostic pointing at `docs/tls.md`. There is no default-to-plaintext path.
|
||||
|
||||
- **SEC-H3: `CERTCTL_DEMO_MODE_ACK` is no longer sticky — 24h re-ack required.**
|
||||
Pre-Phase-2, setting `CERTCTL_DEMO_MODE_ACK=true` was sticky for the
|
||||
lifetime of the container. Post-Phase-2, operators must ALSO set
|
||||
`CERTCTL_DEMO_MODE_ACK_TS=$(date +%s)` to a unix epoch within the
|
||||
last 24h. The next container restart past 24h refuses to start
|
||||
unless a fresh TS is supplied. Catches the "forgotten demo deployment
|
||||
promoted to production" failure mode.
|
||||
### Added
|
||||
|
||||
**Operator action:** demo deploys must set `CERTCTL_DEMO_MODE_ACK_TS`
|
||||
at every `docker compose up`. The demo Compose helper script handles
|
||||
this automatically when wired; standalone demo deploys add it
|
||||
manually. Production deploys: this guard is irrelevant
|
||||
(`CERTCTL_DEMO_MODE_ACK` should not be set in production).
|
||||
- **Self-signed bootstrap for Docker Compose demos.** A `certctl-tls-init` init container runs before the server on first boot, generates a SAN-valid self-signed cert into `deploy/test/certs/`, and exits. The server mounts the resulting cert/key. Every curl in the demo stack pins against `./deploy/test/certs/ca.crt` with `--cacert`.
|
||||
- **Helm chart TLS provisioning — three modes.** Operator-supplied Secret (`server.tls.existingSecret`), cert-manager integration (`server.tls.certManager.enabled` with issuer selection), or self-signed (`server.tls.selfSigned.enabled` — eval only, not supported for production). Chart templates enforce exactly one is active.
|
||||
- **Hot-reload of TLS cert/key on `SIGHUP`.** Overwrite the cert/key on disk, send `SIGHUP` to the server PID, watch the `slog.Info("tls.reload", ...)` log line, and new TLS connections use the new cert. Failure during reload is logged and does not crash the server; the previous cert remains in use.
|
||||
- **Agent CA-bundle env vars.** `CERTCTL_SERVER_CA_BUNDLE_PATH` points at a PEM file the agent's HTTP client will trust. `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` disables verification (development only — the agent logs a loud warning at startup). `install-agent.sh` writes both as commented template lines into the generated `agent.env`.
|
||||
- **Integration test suite runs over HTTPS.** `go test -tags=integration ./deploy/test/...` stands up the full Compose stack, extracts the self-signed CA bundle, and exercises every certctl API over `https://localhost:8443`. All 34 subtests green.
|
||||
- **`docs/tls.md`** — cert provisioning patterns: bring-your-own Secret, cert-manager, self-signed bootstrap, SAN requirements, rotation workflows, SIGHUP reload semantics, troubleshooting.
|
||||
- **`docs/upgrade-to-tls.md`** — one-step cutover guide for existing v2.1 operators. Walks through the agent fleet roll, Helm upgrade sequencing, downgrade-is-not-supported warnings, and cert-provisioning decision tree.
|
||||
|
||||
### Changed
|
||||
|
||||
- `cmd/server/main.go` now calls `http.Server.ListenAndServeTLS(certFile, keyFile)`. The plaintext `ListenAndServe` code path is deleted — `grep -rn "ListenAndServe[^T]" cmd/ internal/` returns zero hits.
|
||||
- All documentation curls (`docs/testing-guide.md`, `docs/quickstart.md`, `deploy/helm/INSTALLATION.md`, `deploy/helm/DEPLOYMENT_GUIDE.md`, `deploy/ENVIRONMENTS.md`, `docs/openapi.md`, migration guides, example READMEs) use `https://localhost:8443` and `--cacert` against the demo stack's bundle.
|
||||
- OpenAPI spec (`api/openapi.yaml`) `servers` blocks default to `https://localhost:8443`.
|
||||
|
||||
### Security
|
||||
|
||||
- **Alg-downgrade defense relaxed for Keycloak-shape IdPs (v2.1.0 pre-tag fix).**
|
||||
Pre-fix, the IdP-bind alg-downgrade check at `internal/auth/oidc/service.go`
|
||||
refused to load any OIDC provider whose discovery doc advertised HS256 /
|
||||
HS384 / HS512 / `none` in `id_token_signing_alg_values_supported` —
|
||||
even if RS256 was ALSO advertised. This broke binding against
|
||||
Keycloak 26.x (and a handful of other real IdPs) which list every alg
|
||||
the codebase is capable of in their discovery doc, regardless of which
|
||||
one the realm actually signs with. The v2.1.0 Phase-10 live-IdP smoke
|
||||
surfaced the regression: 6 testcontainers-Keycloak integration tests
|
||||
failed with `oidc: IdP advertises weak signing algorithms (HS*/none); refusing to use as defense against downgrade attacks: HS256`.
|
||||
**Fix:** the check now refuses only when the intersection of advertised
|
||||
vs `DefaultAllowedAlgs` is EMPTY — an IdP advertising HS256 alongside
|
||||
RS256 binds successfully, but an IdP advertising HS-only / none-only
|
||||
still fails closed. The per-token alg pin at sig-verify time
|
||||
(`isDisallowedAlg`, service.go ~L1177) remains the load-bearing defense
|
||||
against the actual algorithm-confusion attack (forged HS256 token
|
||||
signed with the IdP's RS256 pubkey as HMAC secret) — go-oidc/v3's
|
||||
verifier rejects any token whose `alg` header isn't in the configured
|
||||
allow-list, regardless of what the discovery doc claims. Updates:
|
||||
`Service.getOrLoad` alg-check loop rewritten to compute intersection;
|
||||
`ErrIdPDowngradeAdvertised` docstring reflects new semantics;
|
||||
`TestDiscovery` dry-run validator surfaces HS*/none alongside RS* as
|
||||
an informational note (not a hard fail); `docs/operator/auth-threat-model.md`
|
||||
alg-allow-list section updated to call out the load-bearing-defense
|
||||
hierarchy. Tests: `TestService_IdPDowngradeDefense_RS256PlusHS256_BindsSuccessfully`
|
||||
(positive — Keycloak-shape) + `TestService_IdPDowngradeDefense_RejectsHSOnlyAdvertised`
|
||||
(negative — pathological intersection-empty case) +
|
||||
`TestService_RefreshKeys_CatchesPostLoadDowngrade` updated to assert
|
||||
intersection-empty post-rotation; `TestTestDiscovery_AlgDowngrade_HS256AlongsideRS256_BindsWithNote`
|
||||
+ `TestTestDiscovery_AlgDowngrade_HSOnly_StillTrips_HardFail` pin the
|
||||
dry-run validator's new behavior.
|
||||
- TLS 1.3 pinned via `tls.Config.MinVersion = tls.VersionTLS13`.
|
||||
- Plaintext HTTP listener removed entirely — no port 8080, no `Upgrade-Insecure-Requests`, no HSTS-required redirect dance. There is only one port: 8443, TLS 1.3.
|
||||
- `grep -rn "http://" cmd/ internal/` returns zero hits outside test fixtures and the agent-side URL-scheme rejection error message.
|
||||
|
||||
### Tests
|
||||
### Upgrade Notes
|
||||
|
||||
- **Vitest coverage for the 2026-05-10/11 GUI batch (Audit 2026-05-11 Fix 12).**
|
||||
The original GUI-batch commit `661b6db` claimed `npx tsc --noEmit PASS`
|
||||
but shipped no Vitest cases for the new surfaces. The regression-
|
||||
prevention layer was missing — a future refactor of `KeysPage`'s
|
||||
assign modal could silently drop scope_type handling, the LOW-1 demo
|
||||
banner could be hidden by a stray predicate flip, the LOW-11 hide of
|
||||
the delete button on default roles could disappear and let operators
|
||||
click straight into a backend 409, and nothing would surface in CI.
|
||||
This closure adds 35 new test cases across five files:
|
||||
`web/src/pages/auth/UsersPage.test.tsx` (new, 8 cases pinning the
|
||||
active/deactivated/reactivate flow + provider filter + empty state +
|
||||
loading state), `web/src/pages/auth/AuthSettingsPage.test.tsx`
|
||||
(extended +4 cases pinning the MED-12 runtime-config panel —
|
||||
alphabetical sort, `(empty)` placeholder, 403 silent-hide),
|
||||
`web/src/pages/auth/KeysPage.test.tsx` (extended +8 cases pinning
|
||||
the HIGH-10 GUI half — scope_type=global/profile/issuer body shape,
|
||||
expires_at omission vs RFC3339 promotion, whitespace-only scope_id
|
||||
rejection, demo-anon row mutation-button hide),
|
||||
`web/src/pages/auth/RoleDetailPage.test.tsx` (new, 9 cases pinning
|
||||
the MED-8 scope picker + the LOW-11 default-role delete-button hide
|
||||
via the `DEFAULT_ROLE_IDS` set against `r-admin` + `r-auditor`),
|
||||
`web/src/components/AuthProvider.test.tsx` (new, 5 cases pinning the
|
||||
LOW-1 demo-banner visibility predicate — `authType==='none' &&
|
||||
!loading` — across happy/api-key/oidc/loading/rejected branches; the
|
||||
rejected-fetch path keeps the banner visible because the catch
|
||||
treats it as an old-server-fallback to demo-mode, and that behavior
|
||||
is pinned here so a future change surfaces in the diff). 40/40
|
||||
test-file-scoped pass; `tsc --noEmit` clean.
|
||||
Read `docs/upgrade-to-tls.md` before upgrading. The short version:
|
||||
|
||||
### Security
|
||||
1. Pick a TLS source — bring-your-own cert, cert-manager, or self-signed bootstrap.
|
||||
2. Upgrade the server with TLS configured. First boot over HTTPS.
|
||||
3. Roll the agent fleet: set `CERTCTL_SERVER_URL=https://...` and, if using a private CA, `CERTCTL_SERVER_CA_BUNDLE_PATH`. Old agents will fail loud at startup — expected.
|
||||
4. Roll CLI/MCP clients the same way.
|
||||
|
||||
- **CSRF rotation on logout closes HIGH-2 fourth call site (Audit 2026-05-11 Fix 13).**
|
||||
The HIGH-2 closure (`dev/auth-bundle-2`) documented four
|
||||
`RotateCSRFTokenForActor` call sites: login completion (fresh by
|
||||
construction), Assign/RevokeRole on role-mutation (wired), Logout, and
|
||||
an explicit operator endpoint. The 2026-05-11 review verified only 3
|
||||
of the 4 — Logout did NOT rotate the actor's sibling sessions
|
||||
post-revoke, leaving a window where a token captured pre-logout
|
||||
(browser DevTools, malicious extension, session-storage leak) could
|
||||
be replayed against the user's other-device/other-browser sessions
|
||||
until those sessions hit their own idle/absolute expiry.
|
||||
`SessionMinter` interface extended with `RotateCSRFTokenForActor`;
|
||||
`Logout` invokes it after `Revoke(sess.ID)` succeeds. The
|
||||
`auth.session_revoked` audit row gains a `csrf_rotated` detail key
|
||||
carrying the rotated count so SOC / SIEM can correlate logout events
|
||||
with CSRF churn. The no-cookie + invalid-cookie 204 short-circuit
|
||||
paths skip rotation (no session row to rotate against). 3 regression
|
||||
tests in `internal/api/handler/auth_session_oidc_test.go` pin the
|
||||
happy path + the two short-circuit branches. The explicit operator
|
||||
endpoint (4) remains intentionally unbuilt — the three automatic
|
||||
triggers (login + role-mutation + logout) cover the threat model;
|
||||
operators who want a nuclear option can use the existing
|
||||
`RevokeAllForActor` flow which forces re-login → fresh session →
|
||||
fresh CSRF. **HIGH-2 fully closed across all four documented call
|
||||
sites.**
|
||||
|
||||
- **Demo-mode residual-grants detector + cleanup endpoint + CI guard (Audit 2026-05-11 A-8).**
|
||||
HIGH-12 (closure `b81588e`) added a fail-closed bind-address guard
|
||||
that refuses startup when `CERTCTL_AUTH_TYPE=none` binds non-loopback
|
||||
without `CERTCTL_DEMO_MODE_ACK=true`. The Phase 2 leg of that spec —
|
||||
production-startup banner when `actor-demo-anon` has residual role
|
||||
grants in `actor_roles` plus a CI guard banning new synthetic-admin
|
||||
code paths — was deferred. This closure lands all three deferred
|
||||
legs. (1) `cmd/server/preflight_demo_residual.go` runs after the DB
|
||||
is open + audit service is constructed, before the HTTPS listener
|
||||
starts; under any non-`none` auth type it queries `actor_roles` for
|
||||
`actor-demo-anon` and emits a WARN log + `auth.demo_residual_grants_detected`
|
||||
audit row when the row is present. The migration 000029 baseline
|
||||
unconditionally seeds the `ar-demo-anon-admin` row at install time,
|
||||
so EVERY production deploy will see this WARN on first boot — the
|
||||
intended cutover workflow is documented at `docs/operator/security.md`.
|
||||
(2) `POST /api/v1/auth/demo-residual/cleanup` is an admin-class
|
||||
(`auth.role.assign`) cleanup endpoint that removes every
|
||||
`actor-demo-anon` row from `actor_roles` and returns
|
||||
`{"removed": <int64>}`; idempotent (a second call returns
|
||||
`removed:0`), refuses 503 under `Auth.Type=none` (deleting the row
|
||||
would break the demo path), audit-logs every invocation. (3) New
|
||||
env var `CERTCTL_DEMO_MODE_RESIDUAL_STRICT` (default `false`)
|
||||
pivots the WARN to fail-closed startup refusal for operators who
|
||||
want a paranoid hostile-environment posture. (4) CI guard
|
||||
`scripts/ci-guards/no-new-synthetic-admin.sh` pins the 17-entry
|
||||
allowlist of source files that may reference the `actor-demo-anon`
|
||||
literal; new runtime code paths that resolve to the synthetic actor
|
||||
are rejected at PR time so the credibility gap stays closed. The
|
||||
closure was framed as "credibility gap, not exploitable
|
||||
vulnerability" — the residue requires a regression elsewhere in the
|
||||
middleware chain to be exploitable. After this fix, the canonical
|
||||
acquisition-readiness narrative ("RBAC primitive with no
|
||||
synthetic-admin fallback") is fully true. Operator runbook at
|
||||
`docs/operator/security.md#demo-to-production-cutover-audit-2026-05-11-a-8`.
|
||||
|
||||
- **OIDC provider "Test connection" panel (Audit 2026-05-11 Fix 09 — MED-5 GUI half).**
|
||||
MED-5's backend dry-run endpoint (`POST /api/v1/auth/oidc/test`, gated
|
||||
`auth.oidc.create`) shipped on `dev/auth-bundle-2` but had no GUI caller —
|
||||
the `authOIDCTestProvider` function in `web/src/api/client.ts` was dead
|
||||
code. Operators had to complete the create form blind, save, then click
|
||||
"Refresh" to discover whether the issuer URL worked; failures left a
|
||||
broken provider row in the database that had to be deleted before
|
||||
retrying. New shared component
|
||||
`web/src/pages/auth/OIDCTestConnectionPanel.tsx` calls the backend
|
||||
against the live form state and renders a four-row status panel inline:
|
||||
Discovery fetched, JWKS reachable, supported algs (warns when the IdP
|
||||
advertises none), and RFC 9207 iss-parameter advertisement (informational
|
||||
`·` glyph, not ✗, because the spec is SHOULD). Backend per-leg `errors[]`
|
||||
flow into an inline bullet list. The panel is mounted in the
|
||||
OIDCProvidersPage create modal AND the OIDCProviderDetailPage edit form —
|
||||
the edit-form half is load-bearing for verifying IdP rotations (Keycloak
|
||||
realm rename, Okta tenant move) without committing first. Run button is
|
||||
disabled until the issuer URL is non-empty (whitespace-trimmed); the
|
||||
component is read-only — safe to run repeatedly. 8 Vitest tests pin the
|
||||
glyph-vs-glyph contract (✓/✗/⚠/·), the button-disabled-without-issuer
|
||||
shape, and the test-id-suffix collision-prevention when the panel is
|
||||
mounted twice on the same page.
|
||||
|
||||
- **OIDC JWKS health panel + Refresh-now button (Audit 2026-05-11 Fix 10 — MED-7 GUI half).**
|
||||
MED-7's backend endpoint `GET /api/v1/auth/oidc/providers/{id}/jwks-status`
|
||||
(commit `d85114f`) shipped the per-provider verifier counters on
|
||||
`dev/auth-bundle-2` but the GUI never called it. The audit doc had
|
||||
prematurely flipped the row to CLOSED; `authOIDCJWKSStatus` in the
|
||||
API client was dead code. Operators investigating "why is login
|
||||
failing for this IdP" couldn't see `last_refresh_at`,
|
||||
`rejected_jws_count`, or `last_error` from the GUI — they had to
|
||||
drop to curl. New shared component
|
||||
`web/src/pages/auth/OIDCJWKSStatusPanel.tsx` queries the endpoint
|
||||
via TanStack Query (30s `staleTime`, `retry: 0` so a 403 hides the
|
||||
panel silently for callers without `auth.oidc.list`) and renders
|
||||
six dt/dd rows: Last refresh (with `(never — cold cache)` sentinel
|
||||
when the timestamp is empty), Refresh count, Rejected JWS count,
|
||||
Last error (red treatment when non-empty, `(none)` sentinel
|
||||
otherwise), RFC 9207 iss param ("supported by IdP" / "not
|
||||
advertised"), and Current KIDs (`(not exposed — query jwks_uri
|
||||
directly)` sentinel when the backend declines to expose the list).
|
||||
A "Refresh now" button invokes the existing
|
||||
`POST .../refresh` (RefreshKeys path) and invalidates the panel's
|
||||
query so the freshly-updated counters render without a page
|
||||
reload. The button is hidden for callers without `auth.oidc.edit`
|
||||
via the panel's optional `canRefresh` prop. Mounted on
|
||||
`OIDCProviderDetailPage.tsx` between the read-only field display
|
||||
and the Actions section. 9 Vitest tests pin: loading state,
|
||||
happy-path-all-six-rows, 403-hides-panel, refresh-invalidates-
|
||||
query, refresh-failure-surfaces-inline-without-hiding-panel,
|
||||
never-refreshed-cold-cache-sentinel, current-kids-empty-not-
|
||||
exposed-sentinel, last-error-red-treatment, and canRefresh=false-
|
||||
hides-the-button.
|
||||
|
||||
- **UsersPage sidebar nav entry (Audit 2026-05-11 Fix 11 — MED-11
|
||||
discoverability).** The MED-11 closure shipped `UsersPage.tsx` + wired
|
||||
the `/auth/users` route in `web/src/main.tsx`, but the sidebar
|
||||
navigation never gained a corresponding entry. Operators reached the
|
||||
federated-user-admin surface (used during compliance audits — "show
|
||||
me last login for every IdP-federated user") only by knowing the URL.
|
||||
A page that exists but isn't navigable is a half-finished page. New
|
||||
Users entry under the Auth section in `web/src/components/Layout.tsx`
|
||||
sits between Sessions and Roles (federated-identity grouping). Three
|
||||
Vitest tests in `Layout.test.tsx` pin the link's presence, the
|
||||
`/auth/users` destination, and the DOM ordering relative to Sessions
|
||||
so a future refactor that re-orders or removes the entry surfaces in
|
||||
the diff.
|
||||
|
||||
- **Scope-aware actor-role revoke (Audit 2026-05-11 A-4).**
|
||||
HIGH-10 made it possible to grant the same role to the same actor at
|
||||
multiple scopes (e.g. `r-operator` on `profile=p-acme` AND `profile=p-globex`)
|
||||
via the unique constraint extension on `actor_roles`, but
|
||||
`ActorRoleRepository.Revoke` ignored `(scope_type, scope_id)` and
|
||||
unconditionally deleted every variant. Operators who wanted to drop
|
||||
one scoped grant had to nuke them all and re-grant the remainder —
|
||||
a race window where the actor's access was briefly different. The
|
||||
`DELETE /v1/auth/keys/{id}/roles/{role_id}` endpoint now accepts
|
||||
optional `?scope_type=` / `?scope_id=` query params that narrow the
|
||||
revoke to a single variant; no-match returns 404. The legacy "revoke
|
||||
every variant" semantic is preserved when the query params are
|
||||
absent, so existing CLI / GUI buttons keep working unchanged. The
|
||||
audit row's `details` payload records which mode fired so SOC / SIEM
|
||||
can distinguish wide cleanups from targeted demotions. MCP tool
|
||||
`certctl_auth_revoke_role_from_key` gains optional `scope_type` +
|
||||
`scope_id` input fields with matching semantics. Documented in
|
||||
`docs/operator/rbac.md` under "Revoke: legacy 'all variants' vs
|
||||
scope-selective."
|
||||
|
||||
### Security (BREAKING — silent-elevation closure)
|
||||
|
||||
- **HIGH-10 actor-role scope is now enforced (Audit 2026-05-11 A-1).**
|
||||
Pre-fix, `actor_roles.scope_type` / `scope_id` (added in migration 000043
|
||||
by the HIGH-10 closure) were persisted by Grant + accepted on the handler
|
||||
body + surfaced through the GUI/MCP — but the load-bearing
|
||||
`EffectivePermissions` SQL never read them. A profile-scoped grant
|
||||
silently elevated to global at authorization time. Canonical CRIT-5
|
||||
lying-field shape, replicated. **The post-fix authorization narrows
|
||||
correctly**: every existing `actor_roles` row with `scope_type != 'global'`
|
||||
now takes effect.
|
||||
|
||||
> **Operator advisory:** if you used the HIGH-10 scope-bound role-grant
|
||||
> API between commit `551812b` and the v2.1.0 tag (the column was
|
||||
> populated but ignored), the grants were silently global. After
|
||||
> upgrading, audit `SELECT actor_id, role_id, scope_type, scope_id FROM
|
||||
> actor_roles WHERE scope_type != 'global'` and confirm the narrowing
|
||||
> reflects intent. If an actor was granted a scoped role but expected
|
||||
> global behavior, re-grant with `scope_type=global`.
|
||||
|
||||
### Security (BREAKING)
|
||||
|
||||
- **Federated-user deactivation now actually blocks login (Audit 2026-05-11 A-2).**
|
||||
The MED-11 closure shipped `users.deactivated_at` + `DELETE /api/v1/auth/users/{id}`
|
||||
+ cascade-session-revoke, but the column was a "lying field" three legs over: the
|
||||
postgres user repository never SELECTed it (so `User.DeactivatedAt` always read
|
||||
nil), the `Update` SQL never wrote it (so the handler's mutation was a no-op),
|
||||
and the OIDC `upsertUser` path never checked it (so the next login under the
|
||||
same `(provider, subject)` tuple re-minted a session and re-elevated the user).
|
||||
The cascade-revoke remained correct for the current cookie only. **Operator
|
||||
advisory: if you deactivated a federated user between the MED-11 closure
|
||||
(Bundle 2 merge `dea5053`) and the v2.1.0 release tag, verify the user cannot
|
||||
OIDC-log-in after upgrading — the column took no effect at login time before
|
||||
this fix. If needed, re-run the deactivation against the upgraded server.**
|
||||
Closure: `userColumns` + `scanUser` now read `deactivated_at` via `sql.NullTime`;
|
||||
`Create` + `Update` write it explicitly; `upsertUser` returns the new
|
||||
`ErrUserDeactivated` sentinel before mutating fields (preserves `last_login_at`
|
||||
forensics on rejected logins); `classifyOIDCFailure` surfaces the rejection
|
||||
as audit category `user_deactivated`. Self-deactivate guard on
|
||||
`DELETE /api/v1/auth/users/{id}` returns HTTP 409 + audit row
|
||||
`auth.user_deactivate_self_rejected` (prevents an admin from one-way-door
|
||||
locking themselves out via the standard handler — break-glass remains the
|
||||
recovery path). New inverse endpoint `POST /api/v1/auth/users/{id}/reactivate`
|
||||
(gated `auth.user.deactivate` — reactivation is the inverse op, not a separate
|
||||
privilege) clears `deactivated_at`; emits audit row `auth.user_reactivated`.
|
||||
Sessions revoked at deactivation stay revoked across reactivation — the user
|
||||
must complete a fresh OIDC login. GUI: `UsersPage.tsx` now renders a Reactivate
|
||||
button on deactivated rows. CWE-862 (missing authorization at the user-state
|
||||
boundary). SOC 2 CC6.3 + ISO 27001 A.9.2.6 compliance-table-flipping fix.
|
||||
- **`__Host-` cookie prefix on all three auth cookies (Audit 2026-05-10 MED-14).**
|
||||
The session cookie, CSRF cookie, and OIDC pre-login cookie are renamed from
|
||||
`certctl_session` / `certctl_csrf` / `certctl_oidc_pending` to
|
||||
`__Host-certctl_session` / `__Host-certctl_csrf` / `__Host-certctl_oidc_pending`
|
||||
to gain browser-enforced subdomain-takeover protection (a `__Host-*` cookie can
|
||||
only be set with `Path=/` + `Secure` + no `Domain` attribute, and the browser
|
||||
rejects subdomain attempts to overwrite it). **Active sessions invalidate on
|
||||
the rolling deploy that lands this change** — operators must re-authenticate
|
||||
once after upgrading. The GUI's CSRF cookie reader was updated in lockstep.
|
||||
See `docs/migration/oidc-enable.md` for operator-facing detail.
|
||||
|
||||
### Security
|
||||
|
||||
- **OIDC `allowed_email_domains` now editable in the GUI (Audit 2026-05-11 A-3).**
|
||||
The backend gate that rejects logins whose email domain is outside the
|
||||
configured allowlist landed in v2.1.0 (CRIT-5 closure, 2026-05-10), but the
|
||||
GUI never exposed the field — GUI-driven operators had to use the API
|
||||
directly to configure tenant isolation against multi-tenant IdPs (Auth0,
|
||||
Azure AD common endpoint, Google Workspace). The OIDCProvidersPage create
|
||||
modal and OIDCProviderDetailPage detail view now render a chip-style
|
||||
multi-input with client-side validation that mirrors the backend rules
|
||||
(no `@`, no whitespace, no wildcards, lowercase-only FQDNs). The read-only
|
||||
view renders an explicit "any (no gate configured)" sentinel when the list
|
||||
is empty so operators can tell "not configured" apart from "field is
|
||||
invisible." A "Clear all" button on the edit form is gated by a confirm
|
||||
dialog that warns about removing the tenant gate. **Operator advisory: if
|
||||
you provisioned OIDC providers via the GUI between v2.1.0 and this fix,
|
||||
verify `allowed_email_domains` matches your tenant policy — the field was
|
||||
configurable only via API / MCP / direct SQL during that window.** Per-IdP
|
||||
runbooks for multi-tenant IdPs in `docs/operator/oidc-runbooks/` already
|
||||
documented the field; the GUI now matches.
|
||||
|
||||
- **Approval payload preview (Audit 2026-05-11 A-5).**
|
||||
The MED-10 closure claim ("PARTIAL: raw JSON preview; diff library
|
||||
deferred") was inaccurate — `ApprovalsPage.tsx` rendered no payload
|
||||
at all, so approvers were clicking Approve / Reject without seeing
|
||||
the change they were authorizing. That defeats the entire four-eyes
|
||||
primitive: an approver who can't see what they're approving is
|
||||
rubber-stamping. Each row now carries a Preview toggle that expands
|
||||
an inline panel dispatching by kind: `profile_edit` shows a
|
||||
field-level before/after diff (changed-only rows, red/green cells,
|
||||
`(unset)` sentinel for added/removed fields); `cert_issuance` shows
|
||||
a definition list of CN / SANs / profile / key algo / must-staple /
|
||||
validity (catches the wildcard-against-corp-internal-profile attack
|
||||
at review time); unknown kinds render a generic JSON preview for
|
||||
forward-compat with future approval kinds. The base64-encoded JSON
|
||||
payload is decoded via the new `decodePayload` helper; malformed
|
||||
inputs render an explicit decode-error fallback — silent failure on
|
||||
the payload preview is what produced this bug in the first place.
|
||||
|
||||
- **Strict pre-login UA/IP binding (Audit 2026-05-11 A-6).**
|
||||
The MED-16 closure left a request-side empty-header bypass: when the
|
||||
pre-login row carried a User-Agent or client-IP binding but the
|
||||
`/auth/oidc/callback` request omitted the corresponding value, the
|
||||
binding check was silently skipped. `curl` doesn't send User-Agent
|
||||
by default; many programmatic clients omit it. An attacker who
|
||||
acquired a pre-login cookie could replay it without the bound
|
||||
header and bypass the RFC 9700 §4.7.1 defense. The check is now
|
||||
strict-when-stored — an empty request-side value with a non-empty
|
||||
stored binding rejects with HTTP 400 and the new audit failure
|
||||
categories `prelogin_ua_missing` / `prelogin_ip_missing` (distinct
|
||||
from the existing `*_mismatch` categories so SIEM rules can alert
|
||||
specifically on bypass attempts). **Operator advisory:** environments
|
||||
where the User-Agent is stripped in transit (some debug proxies, a
|
||||
handful of CDN configurations) must set
|
||||
`CERTCTL_OIDC_PRELOGIN_REQUIRE_UA=false` to keep logins working;
|
||||
symmetric `CERTCTL_OIDC_PRELOGIN_REQUIRE_IP=false` exists for the
|
||||
IP-side. The legacy-row compat window — pre-migration rows with no
|
||||
stored binding — still passes through unchecked, but that window is
|
||||
bounded by the 10-minute pre-login TTL.
|
||||
|
||||
- **OIDC provider Advanced fields are now editable in the GUI (Audit 2026-05-11 A-7).**
|
||||
The MED-4 row had been DEFERRED to v3 with the rationale "backend
|
||||
already accepts these fields." The verifier hit the GUI and found
|
||||
that the read-only display claimed the values were editable, but the
|
||||
edit form had no inputs — the save handler passed `provider.scopes`
|
||||
/ `provider.groups_claim_path` / `provider.groups_claim_format` /
|
||||
`provider.iat_window_seconds` / `provider.jwks_cache_ttl_seconds`
|
||||
unchanged from the loaded object. Operators who wanted to bump the
|
||||
IAT window or change the groups-claim path had to drop to curl /
|
||||
MCP and trust the GUI's display matched what they'd set elsewhere.
|
||||
Lying UX. The OIDCProviderDetailPage edit form now has a collapsible
|
||||
Advanced section with five inputs (scopes as a space-separated text
|
||||
field; groups-claim path; groups-claim format select with the
|
||||
backend's `string-array` / `json-path` enum; IAT window number input
|
||||
bounded 1–600; JWKS cache TTL number input with floor 60). Client-side
|
||||
validation mirrors the backend `Validate` rules so common operator
|
||||
mistakes (IAT > 600, JWKS TTL < 60, empty scopes, empty groups-claim-path)
|
||||
reject inline instead of round-tripping a 400. The read-only `<dl>`
|
||||
also gained the previously-invisible `jwks_cache_ttl_seconds` row.
|
||||
|
||||
- **Pre-login cookie Path widened from `/auth/oidc/` to `/` (Audit MED-14
|
||||
follow-on).** Required to satisfy the `__Host-` prefix's `Path=/` rule. The
|
||||
cookie lifetime is unchanged (10 minutes) and only the callback handler
|
||||
consumes it; the wider path scope is harmless.
|
||||
|
||||
- **RFC 9207 `iss` URL parameter check on OIDC callback (Audit 2026-05-10
|
||||
MED-17).** When the matched IdP's discovery doc advertises
|
||||
`authorization_response_iss_parameter_supported: true`, certctl now requires
|
||||
the `iss` query parameter on `/auth/oidc/callback` and enforces a
|
||||
constant-time compare against the configured provider's `IssuerURL`. Mismatch
|
||||
rejects with HTTP 400; the audit row's `failure_category` distinguishes
|
||||
`iss_param_missing` / `iss_param_mismatch` (RFC 9207 leg) from the existing
|
||||
`id_token_iss_mismatch` (in-token iss claim leg). Closes the mix-up-attack
|
||||
defense for modern Keycloak, Authentik, and public-trust CAs that ship
|
||||
RFC-9207 discovery. Providers that don't advertise support (the majority
|
||||
today) keep pre-fix behavior — back-compat is preserved.
|
||||
|
||||
- **Auth GUI batch (Audit 2026-05-10 MED-4/7/8/10/11/12 + LOW-1/11/12 +
|
||||
HIGH-10 GUI).** New backend endpoints land alongside their GUI
|
||||
consumers: `GET /api/v1/auth/users` + `DELETE /api/v1/auth/users/{id}`
|
||||
(auth.user.read / auth.user.deactivate; migration 000045 adds
|
||||
`users.deactivated_at` plus the two new permissions); `GET
|
||||
/api/v1/auth/runtime-config` (auth.role.assign) returning a sanitized
|
||||
flat-map of deployed CERTCTL_* values (no secrets leaked — only
|
||||
set/unset booleans and counts); `GET
|
||||
/api/v1/auth/oidc/providers/{id}/jwks-status` (auth.oidc.list)
|
||||
returning the per-provider verifier counters (refresh count, last
|
||||
refresh / error timestamps, rejected JWS count, RFC 9207 iss-param
|
||||
flag). New `UsersPage` lists federated identities + soft-deactivates.
|
||||
`AuthSettingsPage` gains the runtime-config panel. `KeysPage`'s
|
||||
assign-role modal now collects `scope_type` / `scope_id` /
|
||||
`expires_at`. `RoleDetailPage`'s add-permission form gains the same
|
||||
scope picker, and the Delete button is hidden on the 7 default
|
||||
system roles (server already rejected, this is pure UX).
|
||||
`AuthProvider` renders a sticky red demo-mode banner when
|
||||
`auth_type=none`. `actor-demo-anon` rows on `KeysPage` already had
|
||||
buttons disabled.
|
||||
|
||||
- **11 new MCP tools (Audit 2026-05-10 MED-13).** Approval workflow
|
||||
(`certctl_approval_list` / `_get` / `_approve` / `_reject`), break-glass
|
||||
credential admin (`certctl_breakglass_list` / `_set_password` /
|
||||
`_unlock` / `_remove`), bootstrap status + consume
|
||||
(`certctl_bootstrap_status` / `_consume`), and audit category filter
|
||||
(`certctl_audit_list_with_category`). All route through the existing
|
||||
HTTP client so server-side permission gates fire unchanged.
|
||||
`certctl_bootstrap_consume`'s tool description carries an explicit
|
||||
"NEVER WIRE THIS TO AUTONOMOUS OPERATION" warning — a leaked
|
||||
bootstrap token mints a fresh admin API key bypassing every other
|
||||
access-control gate, so the tool is for one-shot manual operator
|
||||
invocation only.
|
||||
|
||||
- **JWKS auto-refresh on cache-miss (Audit 2026-05-10 MED-6).** When
|
||||
the IdP rotates its signing key between pre-login + callback, the
|
||||
cached JWKS no longer contains the kid referenced by the inbound ID
|
||||
token's JWS header. Pre-fix, the verify failed with a generic error
|
||||
and the operator had to manually call `POST
|
||||
/api/v1/auth/oidc/providers/{id}/refresh`. The service now detects
|
||||
the kid-not-in-cache shape (`isKidMismatchError`) and runs a
|
||||
one-shot `RefreshKeys` (evict cache → re-fetch discovery + JWKS →
|
||||
re-run alg-downgrade defense) before retrying the verify exactly
|
||||
once. Bounded recovery: a second failure surfaces as
|
||||
`ErrJWKSUnreachable` per the original branches; no retry loop. A
|
||||
separate matcher (`isKidMismatchError`) is intentionally narrow
|
||||
so generic signature failures don't trigger refresh.
|
||||
|
||||
- **OIDC provider test endpoint (Audit 2026-05-10 MED-5).** New
|
||||
`POST /api/v1/auth/oidc/test` dry-runs an OIDC provider configuration
|
||||
without persisting: fetches the discovery doc, runs the alg-downgrade
|
||||
defense, detects RFC 9207 iss-parameter advertisement, and confirms
|
||||
JWKS reachability. Returns `TestDiscoveryResult{discovery_succeeded,
|
||||
jwks_reachable, supported_alg_values, iss_param_supported, errors[]}`
|
||||
so the GUI (forthcoming) can render per-check status rows. Per-leg
|
||||
failures ride in the response body's `errors` array; only a malformed
|
||||
request body trips 400. Gate: `auth.oidc.create`. Audit row
|
||||
`auth.oidc_provider_tested` carries the success/failure summary.
|
||||
|
||||
- **Pre-login UA / source-IP binding on OIDC callback (Audit 2026-05-10
|
||||
MED-16).** RFC 9700 §4.7.1 defense against stolen-pre-login-cookie replay
|
||||
by a different browser / source. Migration `000044_prelogin_uaip` adds
|
||||
`client_ip` + `user_agent` to `oidc_pre_login_sessions`; values captured at
|
||||
`/auth/oidc/login` are constant-time compared at `/auth/oidc/callback`.
|
||||
Mismatches return HTTP 400 with audit `failure_category` =
|
||||
`prelogin_ua_mismatch` or `prelogin_ip_mismatch`. Two operator escape
|
||||
hatches: `CERTCTL_OIDC_PRELOGIN_REQUIRE_UA` and
|
||||
`CERTCTL_OIDC_PRELOGIN_REQUIRE_IP` (both default `true`) — operators on
|
||||
enterprise proxies that rewrite UA, or dual-stack v4/v6 environments where
|
||||
source IP routinely flips, can disable the affected leg. The binding column
|
||||
is persisted even when enforcement is off, so retroactive forensics remain
|
||||
possible. Empty values on either side pass through (rolling-deploy +
|
||||
headless-proxy compat).
|
||||
|
||||
## v2.1.0 - Auth Bundles 1 + 2: RBAC primitive + OIDC SSO + sessions ⚠️
|
||||
|
||||
> **SECURITY: AUDIT YOUR API KEYS.**
|
||||
>
|
||||
> Bundle 1 ships role-based authorization. Every existing API key
|
||||
> configured via `CERTCTL_API_KEYS_NAMED` (or the legacy
|
||||
> `CERTCTL_AUTH_SECRET`) is mapped to the **r-admin role on the first
|
||||
> upgrade boot** so existing automation keeps working unchanged. Most
|
||||
> keys do NOT need full admin power; downgrade them before tagging
|
||||
> the next release.
|
||||
>
|
||||
> Recommended post-upgrade flow:
|
||||
>
|
||||
> ```bash
|
||||
> # 1. List every key with its current role:
|
||||
> certctl-cli auth keys list
|
||||
>
|
||||
> # 2. Walk an interactive prompt that downgrades each key:
|
||||
> certctl-cli auth keys scope-down
|
||||
>
|
||||
> # 3. Or get a heuristic suggestion based on 30 days of audit history:
|
||||
> certctl-cli auth keys scope-down --suggest
|
||||
> certctl-cli auth keys scope-down --suggest --apply # applies the suggestion
|
||||
>
|
||||
> # 4. Or drive scope-down from a JSON config (Helm post-upgrade hook):
|
||||
> certctl-cli auth keys scope-down --non-interactive ./scope-down.json
|
||||
> ```
|
||||
>
|
||||
> The synthetic `actor-demo-anon` actor (used when
|
||||
> `CERTCTL_AUTH_TYPE=none` is configured) is system-managed and
|
||||
> excluded from the prompt loop.
|
||||
|
||||
What else changed in v2.1.0:
|
||||
|
||||
- **Audit 2026-05-10 CRIT-1 closure — wire-layer RBAC enforcement.**
|
||||
The Bundle 1 + Bundle 2 audit surfaced that the permission catalogue
|
||||
was enforced on ~24 admin-only routes only; the bulk of state-changing
|
||||
routes (`POST /api/v1/certificates`, `PUT /api/v1/profiles/{id}`,
|
||||
`DELETE /api/v1/issuers/{id}`, `POST /api/v1/agents/{id}/csr`, even
|
||||
`POST /api/v1/auth/roles` + `POST /api/v1/auth/keys/{id}/roles`) had
|
||||
no `rbacGate` wrap. A `r-viewer` Bearer was essentially `r-admin`
|
||||
minus five fine-grained verbs at the wire layer (CWE-862). This
|
||||
release wraps every state-changing + read endpoint with
|
||||
`rbacGate` (global scope) or `rbacGateScoped` (per-profile / per-
|
||||
issuer scope-bound grants), and adds an AST-level CI guard
|
||||
(`TestRouterRBACGateCoverage`) that fails when a new route is
|
||||
registered without enforcement. Catalogue extended via migration
|
||||
000039 with 30 permissions covering `cert.edit`, `job.*`,
|
||||
`approval.*`, `policy.*`, `team.*`, `owner.*`, `notification.*`,
|
||||
`discovery.*`, `network_scan.*`, `healthcheck.*`, `digest.*`,
|
||||
`verification.*`, `stats.read`, `metrics.read`. **AUDIT YOUR
|
||||
KEYS** (the scope-down call-out above) now translates to real
|
||||
reduction in blast radius. Auditor pin preserved at exactly
|
||||
`{audit.read, audit.export}`.
|
||||
|
||||
- **RBAC primitive shipped.** `tenants`, `roles`, `permissions`,
|
||||
`role_permissions`, `actor_roles` tables (migration 000029); 33-permission
|
||||
canonical catalogue; 7 default roles (`admin`, `operator`, `viewer`,
|
||||
`agent`, `mcp`, `cli`, `auditor`); per-handler permission gates via
|
||||
`auth.RequirePermission` middleware (replaces the legacy
|
||||
`IsAdmin` boolean check on the 5 admin-only handlers).
|
||||
- **Day-0 admin bootstrap.** Set `CERTCTL_BOOTSTRAP_TOKEN` on a fresh
|
||||
deploy and POST a single curl call against `/api/v1/auth/bootstrap` to
|
||||
mint the first admin API key; one-shot, never logged, and locks
|
||||
closed once any admin actor exists. Migration 000031 ships the
|
||||
`api_keys` table that stores the SHA-256 hash; the plaintext is
|
||||
shown in the response body once and never persisted.
|
||||
- **Auditor role split.** New `auditor` role holds only `audit.read`
|
||||
+ `audit.export`. Compliance reviewers can read the audit trail
|
||||
without holding mutation power. Migration 000032 adds
|
||||
`audit_events.event_category` so auditors can filter to
|
||||
authentication-related events specifically.
|
||||
- **`/v1/auth/check` enrichment.** Response now includes the actor's
|
||||
standing roles and effective permissions, so the GUI gates
|
||||
affordances from a single fetch on app boot.
|
||||
- **Approval-bypass closure.** Edits to a profile that has (or
|
||||
would have) `RequiresApproval=true` now route through the
|
||||
`ApprovalService` two-person integrity gate (Phase 9). Migration
|
||||
000033 adds `approval_kind` + `payload` to
|
||||
`issuance_approval_requests` so cert-issuance and profile-edit
|
||||
approvals share the same workflow. Same-actor self-approve is
|
||||
rejected with `ErrApproveBySameActor` for both kinds. Closes the
|
||||
flip-flop loophole where an admin could disable approval, mutate,
|
||||
re-enable. Documented at
|
||||
[`docs/reference/profiles.md`](docs/reference/profiles.md).
|
||||
- **GUI: Roles / API Keys / Auth Settings / Approvals queue.**
|
||||
Four new pages under `/auth/*` consume `/v1/auth/me` for
|
||||
permission-aware rendering. The Approvals queue blocks
|
||||
self-approve at the client layer (Approve/Reject buttons hidden
|
||||
when requested_by == current actor_id) on top of the server-side
|
||||
enforcement. AuditPage gains a category filter (cert_lifecycle /
|
||||
auth / config) for the auditor view.
|
||||
- **MCP server gains 12 RBAC tools.** Operators driving certctl
|
||||
from Claude / VS Code / any MCP client get parity with the GUI
|
||||
+ CLI. Each tool routes through the same HTTP handler; permission
|
||||
gates fire server-side.
|
||||
- **OpenAPI catalogues every new route.** Every Bundle 1 endpoint
|
||||
ships with an `operationId`; the parity test guards against drift.
|
||||
- **Coverage gates.** `internal/auth/` and `internal/service/auth/`
|
||||
now have ≥85% coverage floors in `.github/coverage-thresholds.yml`.
|
||||
The 12-path negative-test list from the Bundle 1 prompt is
|
||||
fully covered (path #12 deferred with in-tree TODO).
|
||||
- **Protocol-endpoint allowlist pinned at three layers.** The
|
||||
middleware bypass (`auth.IsProtocolEndpoint`), the router-level
|
||||
`AuthExemptRouterRoutes` constant, and a new
|
||||
`phase12_protocol_allowlist_test.go` AST scan all guard against
|
||||
accidentally wrapping ACME / SCEP / EST / OCSP / CRL routes in
|
||||
`rbacGate`.
|
||||
- **Bundle 2: OIDC + sessions + back-channel logout + break-glass.**
|
||||
Auth Bundle 2 ships in the same v2.1.0 release. Operators get OIDC
|
||||
SSO support for Keycloak / Authentik / Okta / Auth0 / Microsoft
|
||||
Entra ID / Google Workspace (via Keycloak broker), HMAC-signed
|
||||
session cookies with idle/absolute timeouts + CSRF defense,
|
||||
back-channel logout per OpenID Connect Back-Channel Logout 1.0,
|
||||
and a default-OFF break-glass admin path with Argon2id passwords
|
||||
for SSO-broken incidents. API-key auth keeps working unchanged
|
||||
alongside; existing automation needs no changes. Migration walkthrough
|
||||
at [`docs/migration/oidc-enable.md`](docs/migration/oidc-enable.md);
|
||||
per-IdP setup guides at
|
||||
[`docs/operator/oidc-runbooks/index.md`](docs/operator/oidc-runbooks/index.md).
|
||||
- **OIDC token validation pinned at three layers.** Algorithm
|
||||
allow-list (RS256/RS512/ES256/ES384/EdDSA only) with HS-family + `none`
|
||||
rejected at the service-layer sentinel; IdP-downgrade-attack defense
|
||||
at provider creation AND every JWKS RefreshKeys (intersects the IdP's
|
||||
advertised `id_token_signing_alg_values_supported` against the allow-
|
||||
list, rejects providers that advertise weak algs even before any
|
||||
token is signed); OIDC Core §3.1.3.7 re-verification of `iss` /
|
||||
`aud` / `azp` / `at_hash` (REQUIRED-when-access_token-present per
|
||||
Phase 3 tightening of the spec MAY → MUST) / `exp` / `iat` window
|
||||
/ `nonce` constant-time-compare. PKCE-S256 mandatory; `plain`
|
||||
rejected. Single-use state + nonce via atomic `DELETE...RETURNING`
|
||||
on consume.
|
||||
- **Session cookies use length-prefixed HMAC.** The cookie wire format
|
||||
is `v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>`
|
||||
with HMAC input `len:sid:len:kid` (NOT bare-concat) to defeat
|
||||
concatenation collisions. `HttpOnly` + `Secure` + `SameSite=Lax`
|
||||
default; `SameSite=Strict` configurable via `CERTCTL_SESSION_SAMESITE`.
|
||||
Idle timeout 1h / absolute 8h defaults; scheduler GC sweeps expired
|
||||
rows hourly. Signing keys rotate via the new `RotateSigningKey`
|
||||
primitive; the old key stays valid for `CERTCTL_SESSION_SIGNING_KEY_RETENTION`
|
||||
(default 24h) so existing cookies validate during rollover.
|
||||
- **CSRF defense via double-submit-cookie + hashed-token-on-row.**
|
||||
Plaintext CSRF token in the JS-readable `certctl_csrf` cookie
|
||||
(intentionally `HttpOnly=false` for the GUI to echo into the
|
||||
`X-CSRF-Token` header); SHA-256 hash on the session row;
|
||||
`subtle.ConstantTimeCompare` in the new `CSRFMiddleware`. API-key
|
||||
actors are CSRF-exempt (no session row in context).
|
||||
- **OIDC `client_secret` encrypted at rest.** AES-256-GCM v3 blob
|
||||
format (magic 0x03 + salt(16) + nonce(12) + ciphertext+tag) using
|
||||
the existing `CERTCTL_CONFIG_ENCRYPTION_KEY`. Encryption invariant
|
||||
pinned by an integration test asserting ciphertext != plaintext +
|
||||
v3 blob shape + round-trip recovery + wrong-passphrase fails.
|
||||
- **OIDC first-admin bootstrap.** New `CERTCTL_BOOTSTRAP_ADMIN_GROUPS`
|
||||
+ `CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID` env vars: the first
|
||||
OIDC-authenticated user with a matching group claim becomes admin
|
||||
per tenant. Coexists with the Bundle 1 env-var-token bootstrap;
|
||||
the admin-existence probe ensures only one wins. Audit row
|
||||
(`bootstrap.oidc_first_admin`) on every grant.
|
||||
- **Break-glass admin (default-OFF).** New `CERTCTL_BREAKGLASS_ENABLED`
|
||||
env var (default `false`). When enabled, the local Argon2id-password
|
||||
admin path bypasses OIDC + group-claim layers — intended ONLY for
|
||||
SSO-broken incidents. Argon2id with OWASP 2024 params (m=64 MiB,
|
||||
t=3, p=4); lockout after 5 failures (configurable); constant-time
|
||||
across all failure paths via `verifyDummy`; surface invisibility
|
||||
(HTTP 404 on every endpoint when disabled, NOT 403). WARN log at
|
||||
server boot when enabled. WebAuthn/FIDO2 second factor pairing on
|
||||
the v3 roadmap (Decision 12).
|
||||
- **GUI: OIDC Providers + Group → Role Mappings + Sessions + login
|
||||
buttons.** Four new pages under `/auth/*` consume the Bundle 2 API
|
||||
surface. Login page renders one "Sign in with X" button per
|
||||
configured OIDC provider (in addition to the API-key form, which
|
||||
remains as a fallback for Bearer-mode + break-glass paths). Sessions
|
||||
page exposes own-sessions + admin all-actors view. Every actionable
|
||||
element is permission-gated server-side via `auth.oidc.*` and
|
||||
`auth.session.*` perms; client-side hide is UX layer. Logout button
|
||||
in the sidebar fires `POST /auth/logout` to clear the session
|
||||
server-side before redirecting to login.
|
||||
- **MCP server gains 11 OIDC + session tools.** `certctl_auth_list_oidc_providers`,
|
||||
`_get_oidc_provider`, `_create_oidc_provider`, `_update_oidc_provider`,
|
||||
`_delete_oidc_provider`, `_refresh_oidc_provider`,
|
||||
`_list_group_mappings`, `_add_group_mapping`, `_remove_group_mapping`,
|
||||
`_list_sessions`, `_revoke_session`. Operator-facing MCP tool count
|
||||
goes 12 (Bundle 1 RBAC) → 23 across the auth surface. Total MCP
|
||||
tool count: `grep -cE 'mcp\.AddTool\(' internal/mcp/tools*.go` ≈ 150.
|
||||
- **Per-IdP runbooks: 6 production-tier setup guides** at
|
||||
`docs/operator/oidc-runbooks/`. Each runbook follows a consistent
|
||||
five-section layout (Prerequisites / IdP-side config / certctl-side
|
||||
config / Verification / Troubleshooting + Validation checklist with
|
||||
operator sign-off line). Keycloak is the canonical reference;
|
||||
Authentik / Okta / Auth0 / Entra ID / Google Workspace document the
|
||||
IdP-specific deltas (Auth0's namespaced custom claims; Entra ID's
|
||||
group OBJECT IDs; Google Workspace's missing-groups-claim limitation
|
||||
+ the recommended Keycloak broker pattern).
|
||||
- **Threat model extended.** [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md)
|
||||
ships 5 new "Defenses Bundle 2 ships" subsections + 8 new threat-
|
||||
catalogue subsections (OIDC token forgery / session hijacking / IdP
|
||||
compromise / back-channel logout failure modes / group-claim
|
||||
manipulation / bootstrap risks / break-glass risks / token-leak
|
||||
hygiene). 6 new SQL-shaped operator-facing checks. New "Threats
|
||||
Bundle 2 does NOT close" section enumerating the 8 v3-backlog items
|
||||
(WebAuthn / JIT elevation / SAML / multi-tenant activation /
|
||||
HSM-FIPS / OIDC RP-initiated logout / Playwright / per-IdP
|
||||
external-tester sign-off).
|
||||
- **Performance baselines documented.** [`docs/operator/auth-benchmarks.md`](docs/operator/auth-benchmarks.md)
|
||||
ships four benchmarks with measured baselines on a 4 vCPU /
|
||||
8 GiB / Postgres 16 / Go 1.25 floor: `BenchmarkSession_SteadyState`
|
||||
p99 5 µs (target < 1 ms; 200× under), `BenchmarkSession_ColdProcess`
|
||||
p99 7.1 ms (target < 10 ms), `BenchmarkOIDC_SteadyState` p99 1.5 ms
|
||||
(target < 5 ms), `BenchmarkOIDC_ColdCache` operator-runs against
|
||||
live Keycloak via `make benchmark-auth-coldcache`.
|
||||
- **Standards + RFC implementation table.** [`docs/reference/auth-standards-implemented.md`](docs/reference/auth-standards-implemented.md)
|
||||
ships 13 RFC / standard rows + 14 CWE rows with concrete file paths
|
||||
+ negative-test anchors per row. NOT a compliance-mapping doc per
|
||||
the operator's 2026-05-05 retired-compliance-docs decision; the
|
||||
doc explicitly says "build the framework mapping yourself against
|
||||
the rows here using the framework-mapping methodology your audit
|
||||
firm prescribes; this project does not own that mapping."
|
||||
- **Coverage gates held at floor 90 across all four Bundle 2
|
||||
packages.** `internal/auth/oidc/` 93.7%, `internal/auth/session/`
|
||||
94.9%, `internal/auth/breakglass/` 91.5%, `internal/auth/user/domain/`
|
||||
96.4%. NO held-low-with-rationale entry — the Phase 13 prompt's
|
||||
anti-Bundle-1-mistake rule held. Bundle 1's existing 85% floors
|
||||
for `internal/auth/` + `internal/service/auth/` stay 85
|
||||
(already-shipped-and-accepted) per the prompt's explicit
|
||||
inheritance rule.
|
||||
- **Multi-tenant query CI guard.** New `scripts/ci-guards/multi-tenant-query-coverage.sh`
|
||||
(ratchet-style, baseline 32 at v2.1.0 close): greps every
|
||||
SELECT/UPDATE/DELETE in `internal/repository/postgres/` against
|
||||
10 tenant-aware tables, fails on regression OR improvement (forces
|
||||
the operator to lift / lower the baseline visibly). Forward-compat
|
||||
protection so a future Bundle 3 / managed-service multi-tenant
|
||||
activation can flip the switch without finding silent
|
||||
tenant-data-leak bugs in shipped queries.
|
||||
- **Phase 10 Keycloak testcontainers integration test.** New build-tag-
|
||||
gated suite at `internal/auth/oidc/testfixtures/` + `integration_keycloak_test.go`
|
||||
drives the full OIDC flow against a live Keycloak container booted
|
||||
by testcontainers-go. 5-test matrix: discovery + JWKS load, full
|
||||
PKCE auth-code happy path with HTTP form scraping, logout-revokes-
|
||||
session, JWKS rotation, unmapped-groups-fails-closed. Reuses one
|
||||
container across the matrix to amortize the 60-90s boot. Optional
|
||||
Okta smoke test (build-tagged `integration && okta_smoke`) for live
|
||||
tenant validation. New Makefile targets: `make keycloak-integration-test`
|
||||
+ `make okta-smoke-test` + `make benchmark-auth-coldcache`.
|
||||
- **OpenAPI surface extended.** New `cookieAuth` security scheme
|
||||
(apiKey/cookie/`certctl_session`) alongside the existing
|
||||
`bearerAuth`. 13 new Bundle 2 endpoints across the OIDC + session
|
||||
+ group-mapping CRUD surface; 4 break-glass endpoints with
|
||||
surface-invisibility framing. The N-bundle-2-security-empty-preserved
|
||||
CI guard locks the `security: []` opt-out count at ≥ 14 so existing
|
||||
public endpoints stay public.
|
||||
- **Bundle-1-only compat regression CI guard.** New
|
||||
`scripts/ci-guards/bundle-1-compat-regression.sh` asserts the
|
||||
load-bearing invariants that protect the Bundle-1-only-deploy
|
||||
case (session middleware defers-to-next, CSRF passthrough on
|
||||
missing session row, ChainAuthSessionThenBearer wired, public
|
||||
OIDC routes in AuthExempt allowlist, AuthInfo guards on
|
||||
OIDCProvidersResolver != nil). Sibling
|
||||
`bundle-1-to-2-upgrade-regression.sh` asserts the upgrade-path
|
||||
invariants (migrations 000034..000038 are CREATE TABLE IF NOT EXISTS
|
||||
+ BEGIN/COMMIT-wrapped + no DROP TABLE / ALTER...DROP COLUMN
|
||||
against 19 protected Bundle-1 tables + ON CONFLICT DO NOTHING on
|
||||
permission seed).
|
||||
|
||||
Migration ordering, idempotency, and downgrade are documented in
|
||||
[`docs/migration/api-keys-to-rbac.md`](docs/migration/api-keys-to-rbac.md)
|
||||
(API-key → RBAC, Bundle 1) and [`docs/migration/oidc-enable.md`](docs/migration/oidc-enable.md)
|
||||
(API-key → OIDC, Bundle 2). The threat model lives at
|
||||
[`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md).
|
||||
Day-2 RBAC operations live at [`docs/operator/rbac.md`](docs/operator/rbac.md).
|
||||
RFC + CWE evidence at [`docs/reference/auth-standards-implemented.md`](docs/reference/auth-standards-implemented.md).
|
||||
|
||||
## v2.0.68 - Image registry path changed ⚠️
|
||||
|
||||
> **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:<tag>` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever - only the container-registry path changed.
|
||||
|
||||
This is the only operator-action-required change in v2.0.68. Other changes in this release are cosmetic URL refreshes after the GitHub-org transfer from `shankar0123/certctl` to `certctl-io/certctl` (HTTP redirects mean no other operator action is required) plus an internal contextcheck lint fix in the agent. Full commit list is on the [GitHub release page](https://github.com/certctl-io/certctl/releases/tag/v2.0.68).
|
||||
|
||||
---
|
||||
|
||||
certctl no longer maintains a hand-edited per-version changelog. Per-release
|
||||
notes are auto-generated from commit messages between consecutive tags.
|
||||
|
||||
**Where to find what changed in a given release:**
|
||||
|
||||
- **[GitHub Releases](https://github.com/certctl-io/certctl/releases)** - every
|
||||
tag has an auto-generated "What's Changed" section pulled from the commits
|
||||
between that tag and the previous one, plus per-release supply-chain
|
||||
verification instructions (Cosign / SLSA / SBOM).
|
||||
- **`git log <prev-tag>..<this-tag> --oneline`** - same content, locally.
|
||||
|
||||
**Why no hand-edited CHANGELOG.md:**
|
||||
|
||||
certctl is solo-developed and pushes directly to master. Maintaining a
|
||||
hand-edited CHANGELOG meant the file drifted (entries piled into
|
||||
`[unreleased]` and never got promoted to per-version sections when tags were
|
||||
cut). A stale CHANGELOG is worse than no CHANGELOG - it signals abandoned
|
||||
maintenance to security-conscious operators doing diligence.
|
||||
|
||||
The auto-generated release notes work here because commit messages follow a
|
||||
descriptive convention: `<area>: <summary>` with a longer body for non-trivial
|
||||
changes (see `git log v2.0.50..HEAD` for the established pattern). Anyone
|
||||
reading the GitHub Releases page can see exactly what landed in each version
|
||||
without depending on the author to manually update a separate file.
|
||||
|
||||
**For the historical record:** earlier versions (pre-v2.2.0 and the [2.2.0]
|
||||
tag itself) had a hand-edited CHANGELOG. That content is preserved in
|
||||
[git history](https://github.com/certctl-io/certctl/blob/v2.2.0/CHANGELOG.md)
|
||||
at the v2.2.0 tag.
|
||||
There is no backward-compat bridge. There is no dual-listener mode. The cutover is one step.
|
||||
|
||||
+5
-68
@@ -1,28 +1,7 @@
|
||||
# Multi-stage build for certctl server
|
||||
#
|
||||
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
|
||||
# immutable digest in addition to the human-readable tag. The tag is
|
||||
# advisory; the digest is what Docker actually pulls. A registry-side
|
||||
# tag swap (the documented prior-art for tag-only pulls being unsafe)
|
||||
# can no longer change the build.
|
||||
#
|
||||
# Bump procedure (operator):
|
||||
# 1. Quarterly cadence (or sooner if a CVE lands on a base image).
|
||||
# 2. For each FROM:
|
||||
# docker pull <image>:<tag>
|
||||
# docker manifest inspect <image>:<tag> | grep -m1 digest
|
||||
# OR via Docker Hub Registry API:
|
||||
# curl -sSL https://hub.docker.com/v2/repositories/library/<image>/tags/<tag> \
|
||||
# | jq -r .digest
|
||||
# 3. Replace the @sha256:... portion of the FROM line.
|
||||
# 4. Run `docker build` locally + verify CI.
|
||||
# 5. Commit with the bump procedure cited in the message body.
|
||||
#
|
||||
# The CI step "Forbidden bare FROM regression guard (H-001)" rejects
|
||||
# any future commit that lands a FROM without an @sha256 pin.
|
||||
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend
|
||||
FROM node:20-alpine AS frontend
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
|
||||
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
|
||||
@@ -43,27 +22,12 @@ ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
WORKDIR /app/web
|
||||
|
||||
COPY web/ .
|
||||
# Bundle A / Audit M-014: explicit retry loop for `npm ci`. Pre-bundle
|
||||
# this was `npm ci || npm ci && tsc && build` — the bash precedence is
|
||||
# `A || (B && C && D)` so the second `npm ci` only ran on the failure
|
||||
# path of the first, but the `tsc && build` chain only ran on the
|
||||
# success path of the second. Net effect: a transient registry blip
|
||||
# turned the build into a silent skip of the production step.
|
||||
#
|
||||
# New shape: a deterministic 3-attempt retry with 5-second backoff and
|
||||
# an explicit `[ -d node_modules ]` post-check so a silent failure is
|
||||
# impossible.
|
||||
RUN for i in 1 2 3; do \
|
||||
npm ci --include=dev && break; \
|
||||
echo "npm ci attempt $i failed; sleeping 5s before retry"; \
|
||||
sleep 5; \
|
||||
done && \
|
||||
[ -d node_modules ] || (echo "ERROR: npm ci failed after 3 attempts; node_modules missing" && exit 1) && \
|
||||
RUN npm ci --include=dev || npm ci --include=dev && \
|
||||
node_modules/.bin/tsc --version && \
|
||||
npm run build
|
||||
|
||||
# Stage 2: Build Go binary
|
||||
FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — see Stage 1 rationale.
|
||||
ARG HTTP_PROXY=
|
||||
@@ -93,7 +57,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
./cmd/server
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata curl
|
||||
|
||||
@@ -112,34 +76,7 @@ USER certctl
|
||||
|
||||
EXPOSE 8443
|
||||
|
||||
# Image-level HEALTHCHECK for bare `docker run` / Docker Swarm / Nomad / ECS.
|
||||
#
|
||||
# U-2 (P1, cat-u-healthcheck_protocol_mismatch): pre-U-2 this probe used
|
||||
# `curl -f http://localhost:8443/health`, which always failed against the
|
||||
# HTTPS-only listener (HTTPS-Everywhere milestone, v2.2 / tag v2.0.47 —
|
||||
# `cmd/server/main.go::ListenAndServeTLS`, no plaintext fallback, TLS 1.3
|
||||
# pinned). Operators outside docker-compose / Helm saw permanent
|
||||
# `unhealthy` status and a restart-loop the first time they pulled the
|
||||
# image. The compose stack overrides this HEALTHCHECK with `--cacert` to
|
||||
# the bootstrap CA bundle (deploy/docker-compose.yml:126); the Helm chart
|
||||
# uses explicit `httpGet` probes with `scheme: HTTPS` and ignores Docker's
|
||||
# HEALTHCHECK; every example compose file in `examples/*/docker-compose.yml`
|
||||
# overrides with `curl -sfk https://localhost:8443/health`. This image-
|
||||
# level probe is for the bare-`docker run` consumer ONLY.
|
||||
#
|
||||
# `-k` (insecure) is acceptable here because the probe is localhost-to-
|
||||
# localhost: the same process serving the cert is being probed; the probe
|
||||
# never traverses a network. Pinning a `--cacert` is not viable for the
|
||||
# published image because the bootstrap cert is per-deploy (generated into
|
||||
# the `certs` named volume on first up; operator-supplied via Helm's
|
||||
# `existingSecret` or cert-manager). Compose / Helm / examples already
|
||||
# perform full cert-chain validation and are unaffected.
|
||||
#
|
||||
# CI grep guardrail at .github/workflows/ci.yml ("Forbidden plaintext
|
||||
# HEALTHCHECK regression guard (U-2)") blocks reintroduction of the
|
||||
# `http://` shape. Image-level integration test in
|
||||
# deploy/test/healthcheck_test.go pins the contract end-to-end.
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=5 \
|
||||
CMD curl -fsk https://localhost:8443/health || exit 1
|
||||
CMD curl -f http://localhost:8443/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/server"]
|
||||
|
||||
+3
-30
@@ -1,11 +1,6 @@
|
||||
# Multi-stage build for certctl agent
|
||||
#
|
||||
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
|
||||
# immutable digest. See Dockerfile (server) for the bump-procedure
|
||||
# operator runbook; the pins here MUST be bumped in the same pass.
|
||||
|
||||
# Stage 1: Build
|
||||
FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
|
||||
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
|
||||
@@ -39,16 +34,9 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
./cmd/agent
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
||||
FROM alpine:3.19
|
||||
|
||||
# U-2: `procps` ships pgrep, which the HEALTHCHECK below uses to verify the
|
||||
# agent process is alive. Pre-U-2 the deploy/docker-compose.yml agent
|
||||
# HEALTHCHECK called `pgrep -f certctl-agent` against this image but
|
||||
# pgrep wasn't installed — the compose probe was a latent always-fail.
|
||||
# Adding procps here fixes both the new image-level HEALTHCHECK and the
|
||||
# pre-existing compose override. Adds ~250KB to the image; acceptable for
|
||||
# observability parity with the server image.
|
||||
RUN apk add --no-cache ca-certificates curl procps
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
|
||||
RUN addgroup -g 1000 certctl && \
|
||||
adduser -D -u 1000 -G certctl certctl
|
||||
@@ -63,19 +51,4 @@ RUN mkdir -p /var/lib/certctl/keys && \
|
||||
|
||||
USER certctl
|
||||
|
||||
# Image-level HEALTHCHECK for bare `docker run` / Docker Swarm / Nomad / ECS.
|
||||
#
|
||||
# U-2 (P1, cat-u-healthcheck_protocol_mismatch — adjacent fix): the agent
|
||||
# has no HTTP listener (it polls the server via outbound HTTPS), so a
|
||||
# process-presence check is the correct primitive. Pre-U-2 the agent image
|
||||
# shipped with no HEALTHCHECK at all, so bare-`docker run` operators got
|
||||
# zero health signal and orchestrators that key off Docker's HEALTHCHECK
|
||||
# (Swarm, Nomad, ECS) saw the container reported as `none`. The compose
|
||||
# override at deploy/docker-compose.yml:173 used the same `pgrep -f
|
||||
# certctl-agent` shape; we mirror it here so the published image has
|
||||
# parity with the compose stack and the override on docker-compose.yml
|
||||
# becomes redundant-but-correct rather than load-bearing.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD pgrep -f certctl-agent > /dev/null || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/agent"]
|
||||
|
||||
@@ -2,67 +2,26 @@ Business Source License 1.1
|
||||
|
||||
Parameters
|
||||
|
||||
Licensor: certctl LLC
|
||||
Licensor: Shankar Reddy
|
||||
Licensed Work: certctl
|
||||
The Licensed Work is © 2026 certctl LLC.
|
||||
|
||||
Additional Use Grant: You may make use of the Licensed Work, including in
|
||||
production for your internal business operations and
|
||||
for operations that provide products or services to
|
||||
your own customers, provided that you may not offer
|
||||
the Licensed Work as a Commercial Certificate Service.
|
||||
|
||||
A "Commercial Certificate Service" is any product
|
||||
or service that provides third parties with access
|
||||
to or control of any substantial set of the
|
||||
certificate management functionality of the Licensed
|
||||
Work — including but not limited to lifecycle
|
||||
The Licensed Work is (c) 2026 Shankar Reddy.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Commercial
|
||||
Certificate Service. A "Commercial Certificate Service"
|
||||
is any product, service, or offering in which a third
|
||||
party (other than your employees and contractors
|
||||
acting on your behalf) accesses, uses, or benefits
|
||||
from the Licensed Work's certificate management
|
||||
functionality — including but not limited to lifecycle
|
||||
management, discovery, monitoring, alerting, renewal
|
||||
automation, deployment, revocation, certificate
|
||||
authority operation, certificate issuance,
|
||||
certificate signing, or any combination thereof —
|
||||
where compensation, in any form, is received in
|
||||
connection with such access or control. This
|
||||
restriction applies irrespective of whether such
|
||||
functionality is the principal, ancillary,
|
||||
supporting, or one of several values provided by the
|
||||
product or service, and irrespective of whether the
|
||||
Licensed Work is presented under its original name,
|
||||
a modified name, or no name at all.
|
||||
automation, deployment, and revocation — as part of
|
||||
or in connection with an offering for which
|
||||
compensation is received. This restriction applies
|
||||
regardless of whether the Licensed Work is hosted,
|
||||
managed, embedded, bundled, or integrated with
|
||||
another product or service.
|
||||
|
||||
For the avoidance of doubt:
|
||||
|
||||
(a) you may run the Licensed Work in production to
|
||||
manage certificates for products or services
|
||||
that you offer to your customers, where the
|
||||
principal value of those products or services is
|
||||
something other than the Licensed Work's
|
||||
certificate management functionality (for
|
||||
example, you operate a banking application and
|
||||
use the Licensed Work internally to manage TLS
|
||||
certificates for that application);
|
||||
|
||||
(b) for the purposes of this Additional Use Grant,
|
||||
"third party" excludes (i) your employees, (ii)
|
||||
your contractors acting on your behalf, and
|
||||
(iii) your Affiliates. "Affiliate" means any
|
||||
entity that (1) directly or indirectly controls
|
||||
you, (2) is directly or indirectly controlled by
|
||||
you, or (3) is directly or indirectly under
|
||||
common control with you, where "control" means
|
||||
either (A) ownership of more than fifty percent
|
||||
(50%) of the voting interests of the entity, or
|
||||
(B) the power to direct the management and
|
||||
policies of the entity, whether through voting
|
||||
securities, contract, or otherwise;
|
||||
|
||||
(c) the restriction on offering a Commercial
|
||||
Certificate Service applies regardless of whether
|
||||
the Licensed Work is hosted, managed, embedded,
|
||||
bundled, or integrated with another product or
|
||||
service.
|
||||
|
||||
Change Date: March 14, 2076
|
||||
Change Date: March 14, 2033
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
@@ -80,34 +39,16 @@ works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited production
|
||||
use.
|
||||
|
||||
Effective on the Change Date, the Licensor hereby grants you rights under
|
||||
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||
available distribution of a specific version of the Licensed Work under this
|
||||
License, whichever comes first, the Licensor hereby grants you rights under
|
||||
the terms of the Change License, and the rights granted in the paragraph
|
||||
above terminate.
|
||||
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work. Rights granted
|
||||
under any commercial license from the Licensor are personal to the licensee
|
||||
and may not be sublicensed, transferred, assigned, or resold to any third
|
||||
party without the Licensor's prior written consent. Any attempted sublicense,
|
||||
transfer, assignment, or resale in violation of this provision is void.
|
||||
|
||||
Restricted Activities. Notwithstanding any other provision of this License,
|
||||
you may not:
|
||||
|
||||
(i) provide the Licensed Work or substantially similar functionality
|
||||
to third parties as a hosted, managed, embedded, bundled, or
|
||||
integrated service, except as expressly permitted in the
|
||||
Additional Use Grant;
|
||||
|
||||
(ii) move, change, disable, circumvent, or work around any license,
|
||||
security, attribution, audit-trail, or feature-gating
|
||||
functionality contained in the Licensed Work; or
|
||||
|
||||
(iii) alter or remove any license, copyright, attribution, trademark,
|
||||
or other notice from the Licensed Work, its derivatives, or any
|
||||
substantial portion thereof.
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
@@ -119,51 +60,13 @@ of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in this
|
||||
License apply to your use of that work.
|
||||
|
||||
Patent non-assertion. During the term of this License, Licensor covenants
|
||||
not to assert any patent claim that Licensor controls against any person
|
||||
whose use of the Licensed Work complies with this License, with respect to
|
||||
the Licensed Work as distributed by Licensor. This covenant terminates with
|
||||
respect to any person who initiates a patent infringement action against
|
||||
the Licensor or against any contributor to the Licensed Work.
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
Termination and reinstatement. Any use of the Licensed Work in violation of
|
||||
this License will automatically terminate your rights under this License
|
||||
for the current and all other versions of the Licensed Work. Your rights
|
||||
are reinstated automatically if you cease the violation and provide written
|
||||
notice to the Licensor at the contact address above within thirty (30) days
|
||||
of becoming aware of the violation. If you violate this License a second
|
||||
time after such reinstatement, your rights are not subject to further
|
||||
reinstatement.
|
||||
|
||||
Contributions. The Licensor does not accept third-party contributions to
|
||||
the Licensed Work. Any code, documentation, or other material submitted to
|
||||
the Licensor or to any repository hosting the Licensed Work is provided at
|
||||
the submitter's sole risk, confers no rights or obligations on the
|
||||
Licensor, and is not incorporated into the Licensed Work.
|
||||
|
||||
Trademark and naming. This License does not grant you any right in any
|
||||
trademark, service mark, trade name, or logo of the Licensor or its
|
||||
Affiliates. Forks, derivative works, and modifications of the Licensed Work
|
||||
must not use the name "certctl," any name confusingly similar to "certctl,"
|
||||
or any Licensor trademark in their distributed form, marketing materials,
|
||||
package metadata, or service offerings.
|
||||
|
||||
Governing law and venue. This License shall be governed by and construed in
|
||||
accordance with the laws of the State of Florida, USA, without giving
|
||||
effect to any choice or conflict of law provision or rule. Any dispute
|
||||
arising from or relating to this License shall be brought exclusively in
|
||||
the state or federal courts located in the State of Florida, and the
|
||||
parties consent to the personal jurisdiction of such courts.
|
||||
|
||||
Severability. If any provision of this License is held to be invalid,
|
||||
illegal, or unenforceable in any jurisdiction, that holding does not
|
||||
affect the validity, legality, or enforceability of any other provision of
|
||||
this License, which remains in full force and effect.
|
||||
|
||||
Survival. The disclaimers of warranty, the patent non-assertion provisions
|
||||
(with respect to acts occurring before termination), the governing-law and
|
||||
venue provisions, and this survival provision survive any termination of
|
||||
this License.
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||
Licensor as expressly required by this License).
|
||||
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help build run test lint verify verify-deploy loadtest loadtest-scale loadtest-scale-bulk loadtest-scale-acme loadtest-scale-agent acme-cert-manager-test acme-rfc-conformance-test keycloak-integration-test okta-smoke-test benchmark-auth benchmark-auth-coldcache clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build e2e-test qa-stats
|
||||
.PHONY: help build run test lint clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build
|
||||
|
||||
# Default target - show help
|
||||
help:
|
||||
@@ -15,9 +15,6 @@ help:
|
||||
@echo " make test-verbose Run tests with verbose output"
|
||||
@echo " make lint Run linter (golangci-lint)"
|
||||
@echo " make fmt Format code with gofmt"
|
||||
@echo " make verify Pre-commit gate: fmt + vet + lint + test (CI-parity)"
|
||||
@echo " make verify-deploy Pre-push gate: digest validity + OpenAPI parity + docker build smoke"
|
||||
@echo " make loadtest k6 throughput run against postgres + certctl (NOT in verify; manual + cron only)"
|
||||
@echo ""
|
||||
@echo "Database:"
|
||||
@echo " make migrate-up Run migrations (requires DB_URL)"
|
||||
@@ -100,179 +97,6 @@ vet:
|
||||
@echo "Running go vet..."
|
||||
go vet ./...
|
||||
|
||||
# verify: aggregate pre-commit gate. Mirrors what CI enforces, so
|
||||
# running `make verify` locally before committing prevents the
|
||||
# class of breakages that ship green-locally / red-on-CI (e.g.
|
||||
# Bundle-9's ST1018 invisible-Unicode-literal hits, which `go vet`
|
||||
# alone cannot catch — staticcheck under golangci-lint does).
|
||||
verify:
|
||||
@echo "==> fmt"
|
||||
@go fmt ./... | { ! grep -q '.'; } || (echo "gofmt produced changes — commit them" && exit 1)
|
||||
@echo "==> go vet ./..."
|
||||
@go vet ./...
|
||||
@echo "==> golangci-lint run ./... (incl. staticcheck ST*)"
|
||||
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
|
||||
@golangci-lint run ./... --timeout 5m
|
||||
@echo "==> go test -short ./..."
|
||||
@go test -short -count=1 ./...
|
||||
@echo ""
|
||||
@echo "verify: PASS — safe to commit"
|
||||
|
||||
# verify-deploy: optional pre-push gate. Runs the digest-validity check,
|
||||
# the OpenAPI ↔ handler parity check, and a Docker build smoke for the
|
||||
# production images (server + agent only — fast subset for local; CI
|
||||
# builds all 4 Dockerfiles per ci-pipeline-cleanup Phase 8 / frozen
|
||||
# decision 0.10).
|
||||
#
|
||||
# Per ci-pipeline-cleanup bundle Phase 11 / frozen decision 0.13.
|
||||
verify-deploy:
|
||||
@echo "==> Digest validity"
|
||||
@bash scripts/ci-guards/digest-validity.sh
|
||||
@echo "==> OpenAPI ↔ handler parity"
|
||||
@bash scripts/ci-guards/openapi-handler-parity.sh
|
||||
@echo "==> Docker build smoke (server + agent — fast subset)"
|
||||
@docker build -f Dockerfile -t certctl:verify .
|
||||
@docker build -f Dockerfile.agent -t certctl-agent:verify .
|
||||
@echo ""
|
||||
@echo "verify-deploy: PASS — safe to push"
|
||||
|
||||
# Load-test harness — closes the #8 acquisition-readiness blocker from
|
||||
# the 2026-05-01 issuer coverage audit. Boots a minimal certctl stack
|
||||
# (postgres + tls-init + certctl-server) and runs k6 against the API
|
||||
# tier for ~5 minutes. Exits non-zero on any threshold breach.
|
||||
#
|
||||
# NOT in `make verify` — load tests take minutes, not seconds, and
|
||||
# don't gate per-PR signal. CI gates this behind workflow_dispatch +
|
||||
# weekly cron in .github/workflows/loadtest.yml. See
|
||||
# deploy/test/loadtest/README.md for thresholds, baseline, and how to
|
||||
# interpret a regression.
|
||||
loadtest:
|
||||
@echo "==> spinning up postgres + certctl + k6 driver (this takes ~7m)"
|
||||
@cd deploy/test/loadtest && docker compose up --build --abort-on-container-exit --exit-code-from k6
|
||||
@echo ""
|
||||
@echo "==> results landed in deploy/test/loadtest/results/"
|
||||
@if [ -f deploy/test/loadtest/results/summary.txt ]; then cat deploy/test/loadtest/results/summary.txt; fi
|
||||
|
||||
# Phase 8 SCALE-H2 — scale-tier load tests. Profile-gated in the
|
||||
# loadtest compose so the default `make loadtest` stays fast and
|
||||
# focused on the per-PR regression scope (API tier + connector tier).
|
||||
#
|
||||
# loadtest-scale-bulk runs the 10K-cert bulk-renew scenario.
|
||||
# loadtest-scale-acme runs the 200-VU ACME directory/nonce/ARI burst.
|
||||
# loadtest-scale-agent runs the 5K-agent heartbeat storm.
|
||||
#
|
||||
# Each target uses --exit-code-from <scenario-driver> so a threshold
|
||||
# breach surfaces as a non-zero make exit. The scale-seed init runs
|
||||
# once per invocation (idempotent via ON CONFLICT) so re-running a
|
||||
# target against the same compose stack is fine.
|
||||
loadtest-scale-bulk:
|
||||
@echo "==> Phase 8 SCALE-H2: bulk-renewal scenario (10K cert fixture, ~6m)"
|
||||
@cd deploy/test/loadtest && docker compose --profile scale up --build \
|
||||
--abort-on-container-exit --exit-code-from k6-scale-bulk
|
||||
@echo ""
|
||||
@echo "==> results: deploy/test/loadtest/results/summary-bulk-renewal.{json,txt}"
|
||||
@if [ -f deploy/test/loadtest/results/summary-bulk-renewal.txt ]; then \
|
||||
cat deploy/test/loadtest/results/summary-bulk-renewal.txt; fi
|
||||
|
||||
loadtest-scale-acme:
|
||||
@echo "==> Phase 8 SCALE-H2: ACME enrollment burst (200 VU, ~6m)"
|
||||
@cd deploy/test/loadtest && docker compose --profile scale up --build \
|
||||
--abort-on-container-exit --exit-code-from k6-scale-acme
|
||||
@echo ""
|
||||
@echo "==> results: deploy/test/loadtest/results/summary-acme-burst.{json,txt}"
|
||||
@if [ -f deploy/test/loadtest/results/summary-acme-burst.txt ]; then \
|
||||
cat deploy/test/loadtest/results/summary-acme-burst.txt; fi
|
||||
|
||||
loadtest-scale-agent:
|
||||
@echo "==> Phase 8 SCALE-H2: agent heartbeat storm (5K agent fixture, ~6m)"
|
||||
@cd deploy/test/loadtest && docker compose --profile scale up --build \
|
||||
--abort-on-container-exit --exit-code-from k6-scale-agent
|
||||
@echo ""
|
||||
@echo "==> results: deploy/test/loadtest/results/summary-agent-storm.{json,txt}"
|
||||
@if [ -f deploy/test/loadtest/results/summary-agent-storm.txt ]; then \
|
||||
cat deploy/test/loadtest/results/summary-agent-storm.txt; fi
|
||||
|
||||
# All three Phase 8 scenarios serially. Use the matrix in
|
||||
# .github/workflows/loadtest.yml for parallel CI runs.
|
||||
loadtest-scale: loadtest-scale-bulk loadtest-scale-acme loadtest-scale-agent
|
||||
|
||||
# Auth Bundle 2 Phase 10 — Keycloak end-to-end OIDC integration test.
|
||||
# Boots a Keycloak container via testcontainers-go (quay.io/keycloak:25.0),
|
||||
# imports a canned realm with two groups + two users, and drives the
|
||||
# full OIDC flow against the certctl service: discovery + JWKS,
|
||||
# auth-code login, group-claim parsing, group-role mapping, session
|
||||
# mint, and JWKS rotation.
|
||||
#
|
||||
# Build-tag-gated under `integration` so `make verify` (which runs
|
||||
# go test -short) NEVER pulls in the 60-90s Keycloak boot. Requires a
|
||||
# local Docker daemon. Skips cleanly with t.Skip() when -short is set.
|
||||
keycloak-integration-test:
|
||||
@echo "==> running Keycloak OIDC integration test (requires Docker)"
|
||||
@go test -tags=integration -count=1 -timeout=10m \
|
||||
./internal/auth/oidc/...
|
||||
|
||||
# Auth Bundle 2 Phase 10 — optional Okta smoke test. Gated behind TWO
|
||||
# build tags (integration + okta_smoke) so it only runs when invoked
|
||||
# manually against the operator's own Okta dev tenant. Requires the
|
||||
# OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars; the test
|
||||
# t.Skip's with a clear message when any are missing. Documented in
|
||||
# internal/auth/oidc/integration_okta_smoke_test.go.
|
||||
okta-smoke-test:
|
||||
@echo "==> running Okta smoke test (requires OKTA_ISSUER / _CLIENT_ID / _CLIENT_SECRET env vars)"
|
||||
@go test -tags='integration okta_smoke' -count=1 -timeout=2m \
|
||||
./internal/auth/oidc/...
|
||||
|
||||
# Auth Bundle 2 Phase 14 — auth performance benchmarks. Three default-
|
||||
# tag benchmarks (session steady-state + session cold-process + oidc
|
||||
# steady-state) producing p50/p95/p99/max numbers per the auth-
|
||||
# benchmarks.md operator-doc table.
|
||||
benchmark-auth:
|
||||
@echo "==> running auth performance benchmarks (session + oidc steady-state)"
|
||||
@go test -bench='BenchmarkSession_|BenchmarkOIDC_SteadyState' -benchmem \
|
||||
-benchtime=2000x -run='^$$' \
|
||||
./internal/auth/session/ ./internal/auth/oidc/
|
||||
|
||||
# Auth Bundle 2 Phase 14 — OIDC cold-cache benchmark against a live
|
||||
# Keycloak container (requires Docker). Build-tag-gated so the
|
||||
# default-tag benchmarks above never pull in the 60-90s container
|
||||
# boot. Runs the integration test FIRST to populate the
|
||||
# sharedKeycloak fixture, then runs the benchmark.
|
||||
benchmark-auth-coldcache:
|
||||
@echo "==> running OIDC cold-cache benchmark against live Keycloak (requires Docker)"
|
||||
@go test -tags integration -count=1 -timeout=10m \
|
||||
-run TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS \
|
||||
-bench BenchmarkOIDC_ColdCache -benchmem -benchtime=10x \
|
||||
./internal/auth/oidc/
|
||||
|
||||
# Phase 5 — kind-driven cert-manager integration test. Requires
|
||||
# `kind`, `kubectl`, `helm`, and a local Docker daemon. Sets
|
||||
# KIND_AVAILABLE=1 so the test runs (it skips cleanly when unset, which
|
||||
# is the CI default — kind is too heavy for per-PR CI). The test
|
||||
# brings up a fresh cluster, installs cert-manager 1.15, helm-installs
|
||||
# certctl-test, applies a ClusterIssuer + Certificate, and asserts the
|
||||
# Secret lands.
|
||||
acme-cert-manager-test:
|
||||
@echo "==> running cert-manager integration test (requires kind/kubectl/helm)"
|
||||
@KIND_AVAILABLE=1 go test -tags=integration -count=1 -timeout=15m \
|
||||
./deploy/test/acme-integration/...
|
||||
|
||||
# Phase 5 — RFC 8555 conformance against `lego` driving the certctl
|
||||
# server. Hermetic: brings up a single certctl-server via docker
|
||||
# compose, points lego at it, runs the conformance scenarios. Skips
|
||||
# when the operator hasn't built the test image (`make docker-build`
|
||||
# first).
|
||||
acme-rfc-conformance-test:
|
||||
@echo "==> running RFC 8555 conformance via lego"
|
||||
@if ! command -v lego >/dev/null 2>&1; then \
|
||||
echo "lego not installed — go install github.com/go-acme/lego/v4/cmd/lego@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cd deploy/test/loadtest && docker compose up -d certctl postgres
|
||||
@sleep 8
|
||||
@CERTCTL_ACME_DIR=https://localhost:8443/acme/profile/prof-test/directory \
|
||||
bash deploy/test/acme-integration/conformance-lego.sh
|
||||
@cd deploy/test/loadtest && docker compose down
|
||||
|
||||
# Database targets (requires migrate tool)
|
||||
migrate-up:
|
||||
@echo "Running migrations..."
|
||||
@@ -338,41 +162,6 @@ frontend-build:
|
||||
cd web && npm ci && npx vite build
|
||||
@echo "Frontend build complete"
|
||||
|
||||
# Phase 3 TEST-M3 closure (2026-05-13): browser-driven E2E smoke
|
||||
# target. The full 15-flow suite from web/src/__tests__/e2e/README.md
|
||||
# ships in frontend-design-audit Phase 8; this target is the harness
|
||||
# wiring that lets `make e2e-test` work today.
|
||||
#
|
||||
# First-time setup: `cd web && npm install && npx playwright install --with-deps chromium`.
|
||||
# The webServer block in web/playwright.config.ts boots `npm run dev`
|
||||
# automatically; no separate `make docker-up` needed.
|
||||
e2e-test:
|
||||
@echo "Running Playwright E2E (smoke + any *.spec.ts under web/src/__tests__/e2e/)..."
|
||||
cd web && npx playwright test
|
||||
@echo "E2E run complete"
|
||||
|
||||
# qa-stats: snapshot of the test-suite size at the current commit.
|
||||
# Backend Go tests + subtests + fuzz targets + skipped sites, plus the
|
||||
# seed-data counts in migrations/seed_demo.sql. Useful before a release
|
||||
# to spot-check that no whole layer dropped off.
|
||||
qa-stats:
|
||||
@echo "=== certctl QA Suite Stats ==="
|
||||
@echo "Date: $$(date +%Y-%m-%d)"
|
||||
@echo "HEAD: $$(git rev-parse HEAD 2>/dev/null || echo 'not-a-git-repo')"
|
||||
@echo ""
|
||||
@echo "Backend test files: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "Backend Test functions: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c '^func Test' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
|
||||
@echo "Backend t.Run subtests: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c 't\.Run(' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
|
||||
@echo "Frontend test files: $$(find web/src -name '*.test.ts' -o -name '*.test.tsx' 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "Fuzz targets: $$(grep -rE 'func Fuzz[A-Z]' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "t.Skip sites: $$(grep -rE 't\.Skip(Now|f)?\(' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "qa_test.go Part_ subtests: $$(grep -cE 't\.Run\(\"Part[0-9]+_' deploy/test/qa_test.go 2>/dev/null || echo 0)"
|
||||
@echo "Seed unique mc-* IDs: $$(grep -oE "mc-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
@echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 13 incl. agent-demo-1 + 3 cloud sentinels + server-scanner)"
|
||||
@echo "Seed unique iss-* IDs: $$(grep -oE "iss-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (issuers table count is 13)"
|
||||
@echo "Seed unique tgt-* IDs: $$(grep -oE "tgt-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
@echo "Seed unique nst-* IDs: $$(grep -oE "nst-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
|
||||
# Cleanup
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
certctl
|
||||
Copyright 2026 certctl LLC.
|
||||
|
||||
This product is distributed under the Business Source License 1.1.
|
||||
See LICENSE at the repository root for the full license text and
|
||||
the Additional Use Grant carve-outs.
|
||||
|
||||
This product links third-party Go modules and JavaScript packages
|
||||
whose own license terms apply to those components. The full
|
||||
inventory of third-party dependencies and their respective licenses
|
||||
is enumerated in THIRD_PARTY_NOTICES.md at the repository root.
|
||||
|
||||
Effective March 14, 2076, the BSL 1.1 license converts to the
|
||||
Apache License 2.0 per the Change Date in LICENSE.
|
||||
|
||||
For inquiries about commercial licensing terms outside the
|
||||
Additional Use Grant — including the Commercial Certificate
|
||||
Service restriction — contact certctl@proton.me.
|
||||
@@ -2,43 +2,139 @@
|
||||
<img src="docs/screenshots/logo/certctl-logo.png" alt="certctl logo" width="450">
|
||||
</p>
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=89db181e-76e0-45cc-b9c0-790c3dfdfc73" />
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=b9379aff-9e5c-4d01-8f2d-9e4ffa09d126" />
|
||||
|
||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/certctl-io/certctl)
|
||||
[](https://github.com/certctl-io/certctl/releases)
|
||||
[](https://github.com/certctl-io/certctl/stargazers)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||
[](https://github.com/shankar0123/certctl/releases)
|
||||
[](https://github.com/shankar0123/certctl/stargazers)
|
||||
|
||||
certctl is a self-hosted platform that automates the entire TLS certificate lifecycle, from issuance through renewal to deployment, with zero human intervention. Twelve native CA connectors plus an OpenSSL / shell-script adapter for custom CAs; fifteen native deployment-target connectors plus a proxy-agent pattern for network appliances and agentless targets. Private keys stay on your infrastructure where they belong. Free, source-available under BSL 1.1, covers the same lifecycle that enterprise platforms charge $100K+/year for.
|
||||
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
|
||||
|
||||
The CA/Browser Forum's [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) caps public TLS certificates at **200 days by March 2026**, **100 days by 2027**, and **47 days by 2029**. At 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. Manual workflows stop being a choice.
|
||||
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong. It's free, self-hosted, and covers the same lifecycle that enterprise platforms charge $100K+/year for.
|
||||
|
||||
> **Status: Early-access — actively looking for design partners.**
|
||||
```mermaid
|
||||
gantt
|
||||
title TLS Certificate Maximum Lifespan — CA/Browser Forum Ballot SC-081v3
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat
|
||||
todayMarker off
|
||||
section 2015
|
||||
5 years (1825 days) :done, 2020-01-01, 1825d
|
||||
section 2018
|
||||
825 days :done, 2020-01-01, 825d
|
||||
section 2020
|
||||
398 days :active, 2020-01-01, 398d
|
||||
section 2026
|
||||
200 days :crit, 2020-01-01, 200d
|
||||
section 2027
|
||||
100 days :crit, 2020-01-01, 100d
|
||||
section 2029
|
||||
47 days :crit, 2020-01-01, 47d
|
||||
```
|
||||
|
||||
> The certificate lifecycle core is production-quality today: Local CA, ACME, agent deployment, audit, [role-based access control](docs/operator/rbac.md) with auditor split and four-eyes approval. v2.1.0 adds federated identity on top — [OIDC SSO](docs/operator/oidc-runbooks/index.md), server-side sessions, back-channel logout, and a break-glass admin path for SSO-outage recovery.
|
||||
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit.
|
||||
|
||||
> If your team runs PKI infrastructure that could use real automation, we'd love to have you on certctl. Lab and dev deployments are great. Production is welcome too — especially on the federated-identity surface, where real-world IdP shapes are exactly the exposure we can't manufacture in CI. Battle-testing certctl in your environment is genuinely valuable to us.
|
||||
|
||||
> [File issues](https://github.com/certctl-io/certctl/issues) liberally. Every IdP quirk, every connector edge, every doc gap you hit — that's how the platform earns the right to drop the "early-access" label. The faster the loop, the faster everyone benefits.
|
||||
|
||||
> **Actively maintained, shipping weekly.** [Open an issue](https://github.com/certctl-io/certctl/issues) if something breaks. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit.
|
||||
|
||||
**Ready to try it?** Jump to the [Quick Start](#quick-start). For the marketing site, see [certctl.io](https://certctl.io).
|
||||
**Ready to try it?** Jump to the [Quick Start](#quick-start) — you'll have a running dashboard in under 5 minutes.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full audience-organized index lives at [`docs/README.md`](docs/README.md). Top-level entry points:
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Why certctl?](docs/why-certctl.md) | How certctl compares to ACME clients, agent-based SaaS, and enterprise platforms |
|
||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||
| [Quick Start](docs/quickstart.md) | 5-minute setup — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||
| [Docker Compose Environments](deploy/ENVIRONMENTS.md) | Service-by-service walkthrough of all 4 compose files, env var reference |
|
||||
| [Deployment Examples](docs/examples.md) | 5 turnkey scenarios (ACME+NGINX, wildcard DNS-01, private CA, step-ca, multi-issuer) with migration guides |
|
||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||
| [Feature Inventory](docs/features.md) | Complete reference of all capabilities, API endpoints, and configuration |
|
||||
| [Connector Reference](docs/connectors.md) | Configuration for all issuer, target, and notifier connectors |
|
||||
| [MCP Server](docs/mcp.md) | AI integration via Model Context Protocol — setup, available tools, examples |
|
||||
| [OpenAPI 3.1 Spec](docs/openapi.md) | API reference guide with endpoint overview ([raw spec](api/openapi.yaml)) |
|
||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||
| [Migrate from certbot](docs/migrate-from-certbot.md) | Step-by-step migration from certbot cron jobs to certctl |
|
||||
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users, DNS hook compatibility |
|
||||
| [certctl for cert-manager users](docs/certctl-for-cert-manager-users.md) | How certctl complements cert-manager for mixed infrastructure |
|
||||
| [Test Environment](docs/test-env.md) | Docker Compose test environment with real CA backends |
|
||||
| [Testing Guide](docs/testing-guide.md) | Comprehensive test procedures, smoke tests, and release sign-off checklist |
|
||||
|
||||
| Audience | Start here |
|
||||
|---|---|
|
||||
| New to certctl | [Concepts](docs/getting-started/concepts.md) → [Quickstart](docs/getting-started/quickstart.md) → [Examples](docs/getting-started/examples.md) |
|
||||
| Production operator | [Architecture](docs/reference/architecture.md) → [Security posture](docs/operator/security.md) → [Disaster recovery runbook](docs/operator/runbooks/disaster-recovery.md) |
|
||||
| PKI engineer | [ACME server](docs/reference/protocols/acme-server.md) → [SCEP server](docs/reference/protocols/scep-server.md) → [EST server](docs/reference/protocols/est.md) → [CA hierarchy](docs/reference/intermediate-ca-hierarchy.md) |
|
||||
| Migrating from another tool | [from certbot](docs/migration/from-certbot.md) / [from acme.sh](docs/migration/from-acmesh.md) / [cert-manager coexistence](docs/migration/cert-manager-coexistence.md) |
|
||||
## Supported Integrations
|
||||
|
||||
For the connector reference (12 issuers, 15 targets, 6 notifiers) see [`docs/reference/connectors/index.md`](docs/reference/connectors/index.md).
|
||||
### Certificate Issuers
|
||||
|
||||
## Screenshots
|
||||
| Issuer | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| Local CA (self-signed + sub-CA) | `GenericCA` | Sub-CA mode chains to enterprise root (ADCS, etc.) |
|
||||
| ACME v2 (Let's Encrypt, ZeroSSL, etc.) | `ACME` | HTTP-01, DNS-01, DNS-PERSIST-01 challenges. EAB auto-fetch from ZeroSSL. Profile selection (`tlsserver`, `shortlived`). |
|
||||
| step-ca (Smallstep) | `StepCA` | JWK provisioner auth, issuance + renewal + revocation |
|
||||
| OpenSSL / Custom CA | `OpenSSL` | Shell script adapter — any CA with a CLI |
|
||||
| HashiCorp Vault PKI | `VaultPKI` | Token auth, synchronous issuance, CRL/OCSP delegated to Vault |
|
||||
| DigiCert CertCentral | `DigiCert` | Async order model, OV/EV support, PEM bundle parsing |
|
||||
| Sectigo SCM | `Sectigo` | 3-header auth, DV/OV/EV, collect-not-ready graceful handling |
|
||||
| Google Cloud CAS | `GoogleCAS` | OAuth2 service account, synchronous issuance, CA pool selection |
|
||||
| AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN |
|
||||
| Entrust Certificate Services | `Entrust` | mTLS client certificate auth, synchronous/approval-pending issuance |
|
||||
| GlobalSign Atlas HVCA | `GlobalSign` | mTLS + API key/secret dual auth, serial-based tracking |
|
||||
| EJBCA (Keyfactor) | `EJBCA` | Dual auth (mTLS or OAuth2), self-hosted open-source CA |
|
||||
|
||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated via the OpenSSL/Custom CA connector.
|
||||
|
||||
### Deployment Targets
|
||||
|
||||
| Target | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| NGINX | `NGINX` | File write, config validation, reload |
|
||||
| Apache httpd | `Apache` | Separate cert/chain/key files, configtest, graceful reload |
|
||||
| HAProxy | `HAProxy` | Combined PEM file, validate, reload |
|
||||
| Traefik | `Traefik` | File provider deployment, auto-reload via filesystem watch |
|
||||
| Caddy | `Caddy` | Dual-mode: admin API hot-reload or file-based |
|
||||
| Envoy | `Envoy` | File-based with optional SDS JSON config |
|
||||
| Postfix | `Postfix` | Mail server TLS, pairs with S/MIME support |
|
||||
| Dovecot | `Dovecot` | Mail server TLS, pairs with S/MIME support |
|
||||
| Microsoft IIS | `IIS` | Local PowerShell or remote WinRM, PEM→PFX, SNI support |
|
||||
| F5 BIG-IP | `F5` | iControl REST via proxy agent, transaction-based atomic updates |
|
||||
| SSH (Agentless) | `SSH` | SFTP cert/key deployment to any Linux/Unix server |
|
||||
| Windows Certificate Store | `WinCertStore` | PowerShell Import-PfxCertificate, configurable store/location |
|
||||
| Java Keystore | `JavaKeystore` | PEM→PKCS#12→keytool pipeline, JKS and PKCS12 formats |
|
||||
| Kubernetes Secrets | `KubernetesSecrets` | `kubernetes.io/tls` Secrets, in-cluster or kubeconfig auth |
|
||||
|
||||
### Enrollment Protocols
|
||||
|
||||
| Protocol | Standard | Use Case |
|
||||
|----------|----------|----------|
|
||||
| EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT |
|
||||
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices |
|
||||
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
||||
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
|
||||
|
||||
### Standards & Revocation
|
||||
|
||||
| Capability | Standard | Notes |
|
||||
|------------|----------|-------|
|
||||
| DER-encoded X.509 CRL | RFC 5280 | Per-issuer, signed by issuing CA, 24h validity |
|
||||
| Embedded OCSP responder | RFC 6960 | Good/revoked/unknown status per issuer |
|
||||
| S/MIME certificates | RFC 8551 | Email protection EKU, adaptive KeyUsage flags |
|
||||
| Certificate export | — | PEM (JSON/file) and PKCS#12 formats |
|
||||
| ACME DNS-PERSIST-01 | IETF draft | Standing validation record, no per-renewal DNS updates |
|
||||
|
||||
### Notifiers
|
||||
|
||||
| Notifier | Type |
|
||||
|----------|------|
|
||||
| Email (SMTP) | `Email` |
|
||||
| Webhooks | `Webhook` |
|
||||
| Slack | `Slack` |
|
||||
| Microsoft Teams | `Teams` |
|
||||
| PagerDuty | `PagerDuty` |
|
||||
| OpsGenie | `OpsGenie` |
|
||||
|
||||
All connectors are pluggable — build your own by implementing the [connector interface](docs/connectors.md).
|
||||
|
||||
### Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -46,7 +142,7 @@ For the connector reference (12 issuers, 15 targets, 6 notifiers) see [`docs/ref
|
||||
<td><a href="docs/screenshots/v2-certificates.png"><img src="docs/screenshots/v2-certificates.png" width="400" alt="Certificates"></a><br><b>Certificates</b><br><sub>Inventory with bulk ops, status filters, owner/team columns</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="400" alt="Issuers"></a><br><b>Issuers</b><br><sub>Catalog with 12 CA types, GUI config, test connection</sub></td>
|
||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="400" alt="Issuers"></a><br><b>Issuers</b><br><sub>Catalog with 10 CA types, GUI config, test connection</sub></td>
|
||||
<td><a href="docs/screenshots/v2-jobs.png"><img src="docs/screenshots/v2-jobs.png" width="400" alt="Jobs"></a><br><b>Jobs</b><br><sub>Issuance, renewal, deployment queue with approval workflow</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -55,99 +151,165 @@ For the connector reference (12 issuers, 15 targets, 6 notifiers) see [`docs/ref
|
||||
|
||||
## Why certctl
|
||||
|
||||
Certificate lifecycle tooling has historically split into two camps. Enterprise platforms charge six-figure annual licenses, take months to deploy, and bill professional-services hours at $250 to $400 per hour to write integration code that should ship with the product. Single-purpose tools handle one slice of the problem and leave the operator to glue the rest together. certctl fills the gap — full lifecycle automation, self-hosted, free, CA-agnostic, target-agnostic. If you're stitching together cron jobs across a fleet, manually renewing certs, or writing custom integration scripts to bridge a commercial CLM platform to your actual infrastructure, certctl replaces all of that.
|
||||
Certificate lifecycle tooling falls into two camps: enterprise platforms (Venafi, Keyfactor) that cost six figures and take months to deploy, or single-purpose tools (certbot, cert-manager) that handle one slice of the problem. certctl fills the gap — full lifecycle automation, self-hosted, free, CA-agnostic, and target-agnostic. If you're running certbot cron jobs, manually renewing certs, or stitching together scripts across mixed infrastructure, certctl replaces all of that.
|
||||
|
||||
Built for **platform engineering and DevOps teams** managing 10 to 500+ certificates, **security teams** who need audit trails and policy enforcement, and **small teams without enterprise budgets** who need enterprise-grade automation for a 50-server environment. For the detailed positioning argument and when not to use certctl, see [Why certctl?](docs/getting-started/why-certctl.md).
|
||||
Built for **platform engineering and DevOps teams** managing 10–500+ certificates, **security and compliance teams** who need audit trails and policy enforcement for SOC 2, PCI-DSS 4.0, or NIST SP 800-57 ([compliance mapping included](docs/compliance.md)), and **small teams without enterprise budgets** who need Venafi-grade automation for a 50-server environment. For a detailed comparison, see [Why certctl?](docs/why-certctl.md)
|
||||
|
||||
## What it does
|
||||
**Architecture.** Go 1.25 control plane with handler→service→repository layering, PostgreSQL 16 backend (21 tables), and a pull-only deployment model — the server never initiates outbound connections. Agents poll for work. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). Background scheduler runs 7 loops: renewal with ARI integration (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams.
|
||||
|
||||
certctl handles the full certificate lifecycle in one self-hosted control plane:
|
||||
**Security-first.** Agents generate ECDSA P-256 keys locally — private keys never touch the control plane. API key auth enforced by default with SHA-256 hashing and constant-time comparison. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Atomic idempotency guards on scheduler loops. Issuer and target credentials encrypted at rest with AES-256-GCM. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, 11 linters, and vulnerability scanning on every commit.
|
||||
|
||||
- **Issue and renew** from any CA. Let's Encrypt and any ACME provider, an embedded ACME server you can point cert-manager / certbot / lego at directly, a built-in local CA with sub-CA mode (chains under your enterprise root like ADCS), step-ca, Vault PKI, EJBCA, AWS ACM PCA, Google CAS, DigiCert, Sectigo, GlobalSign, Entrust, plus an OpenSSL / shell-script adapter for anything custom. Twelve native issuer connectors. See the [connector reference](docs/reference/connectors/index.md).
|
||||
- **Deploy automatically** to NGINX, Apache, HAProxy, Caddy, Traefik, Envoy, IIS, Windows Cert Store, Java keystore, Kubernetes Secrets, AWS ACM, Azure Key Vault, SSH known-hosts, Postfix + Dovecot, F5 BIG-IP. Fifteen native target connectors. File-based targets share an atomic-write + SHA-256 idempotency + on-failure rollback + per-target Prometheus counters primitive (the `deploy.Apply` path covers 12 of 13 file-based connectors). Cloud / API targets (AWS ACM, Azure Key Vault) use vendor-SDK semantics rather than the file primitive; F5 uses iControl REST transactions; Kubernetes Secrets is preview. For the per-target guarantee matrix, see [`docs/reference/deployment-model.md`](docs/reference/deployment-model.md). The reload / validate commands operators configure for shell-using targets (NGINX, Apache, HAProxy, Postfix, JavaKeystore, SSH) are validated server-side AND agent-side against shell-metacharacter injection before execution (see [`internal/connector/target/configcheck`](internal/connector/target/configcheck)).
|
||||
- **Run as an ACME server** so existing client tooling plugs in directly. RFC 8555 + RFC 9773 ARI, two per-profile auth modes (public-trust-style validation or trust_authenticated for internal PKI), doubly-signed key rollover, revoke-cert on both kid path and jwk path, per-account rate limiting. Cert-manager / certbot / lego all work pointed at it. See [`docs/reference/protocols/acme-server.md`](docs/reference/protocols/acme-server.md).
|
||||
- **Run as a SCEP server** for Microsoft Intune-managed phones, ChromeOS devices, network appliances. RFC 8894 native with full PKIMessage wire format, native Intune challenge dispatch with replay protection, per-profile dispatch with separate RA cert per profile. See [`docs/reference/protocols/scep-server.md`](docs/reference/protocols/scep-server.md).
|
||||
- **Run as an EST server** for HTTPS-based PKCS#10 enrollment. 802.1X / Wi-Fi authentication, IoT device enrollment, RFC 9266 channel binding. See [`docs/reference/protocols/est.md`](docs/reference/protocols/est.md).
|
||||
- **Manage multi-level CA hierarchies** with name constraints, path-length enforcement, and end-to-end RFC 5280 path validation. Root → intermediate → issuing chains, admin-gated CRUD, drain-first retirement. Patterns documented for 4-level boundary CAs, 3-level policy CAs with per-BU `PermittedDNSDomains`, and 2-level internal PKI. See [`docs/reference/intermediate-ca-hierarchy.md`](docs/reference/intermediate-ca-hierarchy.md).
|
||||
- **Gate high-stakes issuance** behind two-person-integrity approval. Flag a profile as `RequiresApproval`, the request lands in a queue, a non-requester approves, the scheduler dispatches. Profile-edit changes on approval-tier profiles route through the same gate so the flip-flop bypass is closed. See [`docs/operator/approval-workflow.md`](docs/operator/approval-workflow.md).
|
||||
- **Authorize with role-based access control.** Seven default roles (admin, operator, viewer, agent, mcp, cli, auditor) over a fine-grained permission catalogue with global / per-profile / per-issuer scope. Auditor role is read-only on the audit trail (`audit.read` + `audit.export`, nothing else) so a regulator's key cannot read certificates or mutate config. Day-0 admin via a one-shot `CERTCTL_BOOTSTRAP_TOKEN` endpoint that closes itself the moment any admin lands. Privilege-escalation guard requires `auth.role.assign` to grant or revoke a role. See [`docs/operator/rbac.md`](docs/operator/rbac.md), [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md), and the v2.0.x → v2.1.0 [migration guide](docs/migration/api-keys-to-rbac.md).
|
||||
- **Sign in with OIDC SSO** against any standards-compliant identity provider. Per-IdP setup runbooks for Keycloak, Authentik, Okta, Auth0, Microsoft Entra ID, and Google Workspace. Group-claim → role mapping for automatic provisioning; client_secret encrypted at rest (AES-256-GCM); JWKS auto-refresh on `kid` miss; PKCE-S256 required; RFC 9700 §4.7.1 pre-login UA/IP binding; RFC 9207 `iss` URL-param check on callback. Server mints HMAC-signed session cookies with the `__Host-` prefix (browser-enforced subdomain-takeover defense), CSRF rotation on every privileged write, and idle + absolute expiry. [RFC OIDC Back-Channel Logout 1.0](docs/reference/auth-standards-implemented.md) revokes sessions on IdP-driven logout. Argon2id break-glass admin path for SSO-outage recovery — disabled by default; 404-invisible to scanners when `CERTCTL_BREAKGLASS_ENABLED=false`. See [`docs/operator/oidc-runbooks/index.md`](docs/operator/oidc-runbooks/index.md) for the per-IdP onboarding guides and [`docs/migration/oidc-enable.md`](docs/migration/oidc-enable.md) for enabling SSO on an existing deploy.
|
||||
- **Discover** existing certs across your fleet via filesystem scanning on agents, network TLS probing across CIDR ranges, and cloud secret manager imports (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager). Triage workflow for claim / dismiss / investigate.
|
||||
- **Revoke** with full RFC 5280 reason codes, DER CRL generation per issuer (scheduler-pre-generated and ETag-cached), and an embedded RFC 6960 OCSP responder with dedicated per-issuer responder certs. Single + bulk revocation. See [`docs/reference/protocols/crl-ocsp.md`](docs/reference/protocols/crl-ocsp.md).
|
||||
- **Alert** via Slack, Microsoft Teams, PagerDuty, OpsGenie, email, webhooks. Per-policy multi-channel routing matrix with severity tiers and fault-isolating per-channel dispatch. See [`docs/operator/runbooks/expiry-alerts.md`](docs/operator/runbooks/expiry-alerts.md).
|
||||
- **Drive the platform from natural language** via the bundled MCP (Model Context Protocol) server. The full REST API is exposed as MCP tools — ask your AI client "show me all expiring certificates", "revoke the VPN cert, key compromised", or "what agents are offline?" and it translates to API calls. Stateless stdio-transport binary at `cmd/mcp-server/`; same auth as the REST API; no extra attack surface. See [`docs/reference/mcp.md`](docs/reference/mcp.md).
|
||||
**Key design decisions.** TEXT primary keys — human-readable prefixed IDs (`mc-api-prod`, `t-platform`, `o-alice`) so you can identify resources at a glance in logs and queries. Idempotent migrations (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING`) safe for repeated execution. Dynamic configuration via GUI with AES-256-GCM encrypted credential storage and env var backward compatibility. Handlers define their own service interfaces for clean dependency inversion.
|
||||
|
||||
## Architecture and security
|
||||
## What It Does
|
||||
|
||||
Go 1.25 control plane with handler → service → repository layering. PostgreSQL 16 backend with idempotent migrations. Pull-only deployment model — the server never initiates outbound connections. Agents poll for work and generate ECDSA P-256 keys locally so private keys never touch the control plane. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). See the [Architecture Guide](docs/reference/architecture.md) for full system diagrams.
|
||||
**Automated lifecycle.** Certificates renew and deploy themselves. The scheduler monitors expiration, issues through your CA, and deploys to targets — zero human intervention. ACME ARI (RFC 9773) lets the CA direct renewal timing. Ready for 47-day (SC-081v3) and 6-day (Let's Encrypt shortlived) certificate lifetimes.
|
||||
|
||||
Security: three authentication paths — API keys (SHA-256 hashed + constant-time compared), [OIDC SSO](docs/operator/oidc-runbooks/index.md) (Keycloak / Authentik / Okta / Auth0 / Entra ID / Google Workspace), and Argon2id [break-glass admin](docs/operator/security.md) for SSO-outage recovery. Successful OIDC login mints an HMAC-signed server-side session with `__Host-` cookies, CSRF rotation on every privileged write, and [RFC OIDC Back-Channel Logout](docs/reference/auth-standards-implemented.md) for IdP-driven session revoke. Role-based authorization on every gated handler with global / per-profile / per-issuer scope. Auditor split keeps regulator-class actors strictly read-only on the audit trail. Day-0 admin via a one-shot bootstrap token; granting or revoking roles requires the dedicated `auth.role.assign` permission. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Issuer + target + OIDC client_secret credentials encrypted at rest with AES-256-GCM. HTTPS-only control plane with TLS 1.3 pinned and a fail-closed startup gate that refuses to boot if the TLS bundle is unusable. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, static analysis, and vulnerability scanning on every commit. See [`docs/operator/security.md`](docs/operator/security.md) for the full posture and [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md) for what's defended vs deferred.
|
||||
**Operational dashboard.** 26-page GUI covers the entire lifecycle: certificate inventory with bulk ops, deployment timeline with rollback, discovery triage, network scan management, agent fleet health, short-lived credential countdown, approval workflows, and observability metrics. Configure issuers and targets from the dashboard — no env var editing, no server restarts.
|
||||
|
||||
**Private keys stay on your servers.** Agents generate ECDSA P-256 keys locally, submit only the CSR. The control plane never touches private keys. After deployment, agents probe the live TLS endpoint and compare SHA-256 fingerprints to confirm the right certificate is actually being served.
|
||||
|
||||
**Discovery.** Agents scan filesystems for existing PEM/DER certificates. The network scanner probes TLS endpoints across CIDR ranges without agents. Cloud discovery finds certificates in AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager. Continuous TLS health monitoring tracks endpoint status (healthy/degraded/down/cert_mismatch) with configurable thresholds and historical probe data. All discovery modes feed into a unified triage workflow — claim, dismiss, or import what you find.
|
||||
|
||||
**Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Approval workflows pause jobs for human review. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version.
|
||||
|
||||
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices. S/MIME issuance with email protection EKU.
|
||||
|
||||
**Revocation.** Single and bulk revocation (by profile, owner, agent, or issuer). DER-encoded X.509 CRL per issuer, signed by the issuing CA. Embedded OCSP responder. RFC 5280 reason codes. Short-lived certs (TTL < 1 hour) are exempt — expiry is sufficient revocation.
|
||||
|
||||
**Audit and observability.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Prometheus metrics endpoint. Scheduled certificate digest emails. Continuous endpoint health monitoring with state machine transitions and real-time alerts.
|
||||
|
||||
**Notifications.** Slack, Teams, PagerDuty, OpsGenie, SMTP, webhooks. Routed by certificate owner. Daily digest emails with stats and expiring certs.
|
||||
|
||||
**Multiple interfaces.** REST API (111 routes), CLI (12 commands), MCP server (80 tools for Claude, Cursor, Windsurf), Helm chart, web dashboard. Certificate export in PEM and PKCS#12.
|
||||
|
||||
**First-run onboarding.** Wizard guides you through connecting a CA, deploying an agent, and issuing your first certificate. Or start with the pre-populated demo — 32 certificates, 10 issuers, 180 days of history.
|
||||
|
||||
For the complete capability breakdown, see the [Feature Inventory](docs/features.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker Compose (recommended)
|
||||
|
||||
**Demo path — zero config, populated dashboard:**
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/certctl-io/certctl.git
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~30 seconds, then open **https://localhost:8443** in your browser. The demo overlay flips the base into demo-mode auth (every request served as the synthetic admin actor `actor-demo-anon` — the server emits a prominent ⚠ DEMO MODE banner at boot reminding you this posture is for evaluation only) and seeds 180 days of realistic history across 13 issuers, 8 agents, managed + discovered certs, jobs, deploys, audit, and notification events. The `certctl-tls-init` init container self-signs an ECDSA-P256 cert on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.
|
||||
|
||||
**Production path — `.env` required, fail-closed on placeholders:**
|
||||
|
||||
```bash
|
||||
cp .env.example deploy/.env # or root .env if running outside compose
|
||||
"${EDITOR:-nano}" deploy/.env # set POSTGRES_PASSWORD, CERTCTL_AUTH_SECRET,
|
||||
# CERTCTL_API_KEY, CERTCTL_CONFIG_ENCRYPTION_KEY,
|
||||
# CERTCTL_AGENT_ID — all via openssl rand
|
||||
# (replace nano with your preferred editor)
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
The base compose alone (no demo overlay) ships production-shaped: default `auth-type=api-key`, default `keygen-mode=agent`, no demo seed, no demo-mode synthetic admin. The fail-closed startup guards in `internal/config/config.go::Validate` refuse to boot when any of the change-me-... placeholder credentials reach config outside of demo mode (Bundle 2 closure, 2026-05-12). The four compose files (`docker-compose.yml` base, `docker-compose.demo.yml` overlay, `docker-compose.dev.yml` for PgAdmin + debug logging, `docker-compose.test.yml` for integration tests) are documented at [`deploy/ENVIRONMENTS.md`](deploy/ENVIRONMENTS.md).
|
||||
Wait ~30 seconds, then open **https://localhost:8443** in your browser. (The shipped `docker-compose.yml` self-signs a cert via the `certctl-tls-init` init container on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.) The onboarding wizard walks you through connecting a CA, deploying an agent, and issuing your first certificate.
|
||||
|
||||
**Want a pre-populated demo instead?** Add the demo override to see 32 certificates across 10 issuers, 8 agents, and 180 days of realistic history:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `deploy/` directory has four compose files: `docker-compose.yml` (base platform), `docker-compose.demo.yml` (demo data overlay), `docker-compose.dev.yml` (PgAdmin + debug logging), and `docker-compose.test.yml` (standalone integration tests with real CA backends). See the [Docker Compose Environments Guide](deploy/ENVIRONMENTS.md) for a service-by-service walkthrough, or the [Quick Start](docs/quickstart.md#docker-compose-environments) for a summary.
|
||||
|
||||
```bash
|
||||
curl --cacert $(docker compose -f deploy/docker-compose.yml exec -T certctl-server cat /etc/certctl/tls/ca.crt) https://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
```
|
||||
|
||||
The control plane is HTTPS-only with TLS 1.3 pinned. See [`docs/operator/tls.md`](docs/operator/tls.md) for cert provisioning patterns.
|
||||
The control plane is HTTPS-only (TLS 1.3, no plaintext listener). See [`docs/tls.md`](docs/tls.md) for cert provisioning patterns and [`docs/upgrade-to-tls.md`](docs/upgrade-to-tls.md) if you're upgrading from a pre-v2.2 release.
|
||||
|
||||
### Agent install (one-liner)
|
||||
### Agent Install (One-Liner)
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/certctl-io/certctl/master/install-agent.sh | bash
|
||||
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||
```
|
||||
|
||||
Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh).
|
||||
Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh) for details.
|
||||
|
||||
### Helm chart (Kubernetes)
|
||||
### Helm Chart (Kubernetes)
|
||||
|
||||
```bash
|
||||
# Required: TLS (pick one), server API key, and Postgres password.
|
||||
# The chart fail-fasts at template time if any required value is missing.
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=<your-kubernetes.io/tls-secret-name> \
|
||||
--set server.auth.apiKey=$(openssl rand -base64 32) \
|
||||
--set postgresql.auth.password=$(openssl rand -base64 32)
|
||||
--set server.apiKey=your-api-key \
|
||||
--set postgres.password=your-db-password
|
||||
```
|
||||
|
||||
Production-ready chart with Server Deployment, PostgreSQL StatefulSet (or external Postgres), Agent DaemonSet, health probes, container-scope security hardening (read-only rootfs, drop-all capabilities, non-root UID), optional PodDisruptionBudget, NetworkPolicy, Prometheus ServiceMonitor, and Ingress. See [values.yaml](deploy/helm/certctl/values.yaml) and the [external-Postgres example](deploy/helm/examples/values-external-db.yaml).
|
||||
Production-ready chart with Server Deployment, PostgreSQL StatefulSet, Agent DaemonSet, health probes, security contexts (non-root, read-only rootfs), and optional Ingress. See [values.yaml](deploy/helm/certctl/values.yaml) for all configuration options.
|
||||
|
||||
### Container images
|
||||
### Docker Pull
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/certctl-io/certctl-server:latest
|
||||
docker pull ghcr.io/certctl-io/certctl-agent:latest
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||
```
|
||||
|
||||
## Verifying this release
|
||||
|
||||
Every `v*` tag publishes signed, attested release artefacts. Binaries
|
||||
(`certctl-agent`, `certctl-server`, `certctl-cli`, `certctl-mcp-server` for
|
||||
`linux|darwin × amd64|arm64`) ship alongside a `checksums.txt`, per-binary
|
||||
SPDX-JSON SBOMs, Cosign signatures, and SLSA Level 3 provenance. Container
|
||||
images on `ghcr.io/shankar0123/certctl-{server,agent}` are built with
|
||||
`docker/build-push-action` `provenance: mode=max` + `sbom: true` and are
|
||||
additionally signed with Cosign at the image digest.
|
||||
|
||||
All signatures use Cosign keyless OIDC; the signing identity is the
|
||||
release workflow running on a signed tag.
|
||||
|
||||
**1. Verify SHA-256 checksums:**
|
||||
|
||||
```bash
|
||||
sha256sum -c checksums.txt
|
||||
```
|
||||
|
||||
**2. Verify the Cosign signature on `checksums.txt`:**
|
||||
|
||||
```bash
|
||||
cosign verify-blob \
|
||||
--bundle checksums.txt.sigstore.json \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
checksums.txt
|
||||
```
|
||||
|
||||
Every individual binary ships with its own `.sigstore.json` bundle
|
||||
(unified Sigstore bundle containing signature, certificate chain, and
|
||||
Rekor inclusion proof). Swap `checksums.txt` for any binary name and
|
||||
point `--bundle` at the matching `<binary>.sigstore.json` to verify it
|
||||
directly.
|
||||
|
||||
**3. Verify SLSA Level 3 provenance on a binary:**
|
||||
|
||||
```bash
|
||||
slsa-verifier verify-artifact \
|
||||
--provenance-path multiple.intoto.jsonl \
|
||||
--source-uri github.com/shankar0123/certctl \
|
||||
--source-tag v2.1.0 \
|
||||
certctl-agent-linux-amd64
|
||||
```
|
||||
|
||||
**4. Verify a container image signature and its SBOM / provenance attestations:**
|
||||
|
||||
```bash
|
||||
IMAGE=ghcr.io/shankar0123/certctl-server:v2.1.0
|
||||
|
||||
cosign verify \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
|
||||
# SBOM attestation (SPDX-JSON, emitted by docker/build-push-action)
|
||||
cosign verify-attestation --type spdxjson \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
|
||||
# SLSA provenance attestation (docker/build-push-action `provenance: mode=max`)
|
||||
cosign verify-attestation --type slsaprovenance \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Pick the scenario closest to your setup and have it running in 2 minutes:
|
||||
Pick the scenario closest to your setup and have it running in 2 minutes.
|
||||
|
||||
| Example | Scenario |
|
||||
|---------|----------|
|
||||
@@ -159,38 +321,91 @@ Pick the scenario closest to your setup and have it running in 2 minutes:
|
||||
|
||||
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
|
||||
|
||||
## Verifying a release
|
||||
## CLI
|
||||
|
||||
Every `v*` tag publishes signed, attested artefacts (Cosign keyless OIDC + SLSA Level 3 provenance + SPDX-JSON SBOMs). For the verification procedure, see [`docs/reference/release-verification.md`](docs/reference/release-verification.md).
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/shankar0123/certctl/cmd/cli@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=https://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt # or --ca-bundle on the CLI; --insecure for dev self-signed
|
||||
|
||||
# Usage
|
||||
certctl-cli certs list # List all certificates
|
||||
certctl-cli certs renew mc-api-prod # Trigger renewal
|
||||
certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
||||
certctl-cli agents list # List registered agents
|
||||
certctl-cli jobs list # List jobs
|
||||
certctl-cli status # Server health + summary stats
|
||||
certctl-cli import certs.pem # Bulk import from PEM file
|
||||
certctl-cli certs list --format json # JSON output (default: table)
|
||||
```
|
||||
|
||||
## MCP Server (AI Integration)
|
||||
|
||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 80 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||
|
||||
```bash
|
||||
# Install and run
|
||||
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
||||
export CERTCTL_SERVER_URL=https://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt # required for self-signed bootstrap
|
||||
mcp-server
|
||||
```
|
||||
|
||||
The MCP server is env-vars-only — there are no CLI flags for TLS. If you must bypass verification for local development against a self-signed cert, set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true`. Never set that in production.
|
||||
|
||||
**Claude Desktop** (`claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"certctl": {
|
||||
"command": "mcp-server",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "https://localhost:8443",
|
||||
"CERTCTL_API_KEY": "your-api-key",
|
||||
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/ca.crt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make build # Build server + agent binaries
|
||||
make test # Run tests
|
||||
make lint # golangci-lint (govet + staticcheck + contextcheck + unused)
|
||||
make lint # golangci-lint (11 linters)
|
||||
govulncheck ./... # Vulnerability scan
|
||||
make docker-up # Start Docker Compose stack
|
||||
```
|
||||
|
||||
CI runs `go vet`, `go test -race`, `golangci-lint`, `govulncheck`, and per-package coverage thresholds (service 70%, handler 75%, crypto 88%, auth packages 85-95%) on every push. The thresholds-as-data file is `.github/coverage-thresholds.yml`; lowering a floor requires corresponding test work, not a config flip. Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build.
|
||||
CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck`, and per-layer coverage thresholds (service 55%, handler 60%, domain 40%, middleware 30%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build. 1,668 Go test functions with 625+ subtests, plus frontend test suite.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0) — Shipped
|
||||
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||
|
||||
### V2: Operational Maturity — Shipped
|
||||
30+ milestones shipping enterprise-grade features for free. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01/EAB/ARI (RFC 9773)/profile selection, step-ca, Vault PKI, DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM PCA, Entrust, GlobalSign, EJBCA, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (WinRM), F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets targets. EST server (RFC 7030) and SCEP server (RFC 8894) enrollment protocols. RFC 5280 revocation with DER CRL + embedded OCSP responder. Certificate profiles, ownership tracking, team assignment, agent groups, interactive approval workflows. Filesystem, network, and cloud secret manager (AWS SM, Azure KV, GCP SM) certificate discovery with triage GUI. Dynamic issuer/target configuration via GUI with AES-256-GCM encrypted storage. First-run onboarding wizard. Post-deployment TLS verification. Certificate export (PEM/PKCS#12). S/MIME support. Prometheus metrics. Scheduled certificate digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. MCP server (80 tools), CLI (12 commands), Helm chart. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). 5 turnkey deployment examples. Agent install script. Migration guides from certbot, acme.sh, and cert-manager. See the [Feature Inventory](docs/features.md) for details.
|
||||
|
||||
### V3: certctl Pro
|
||||
Enterprise capabilities for larger deployments are available in the commercial tier.
|
||||
|
||||
### V4+: Cloud & Scale
|
||||
Kubernetes cert-manager external issuer, cloud infrastructure targets, extended CA support, and platform-scale features.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not use certctl's certificate management functionality as part of a commercial certificate-management offering to third parties. See the LICENSE file for the full Additional Use Grant.
|
||||
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not use certctl's certificate management functionality as part of a commercial offering to third parties, whether hosted, managed, embedded, bundled, or integrated. The BSL 1.1 license converts automatically to Apache 2.0 on March 14, 2033.
|
||||
|
||||
For licensing inquiries: certctl@proton.me
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
go list -m all | wc -l # total module count (direct + transitive)
|
||||
go mod why <path> # explain why a module is pulled in
|
||||
govulncheck ./... # vulnerability scan (CI runs this on every commit)
|
||||
```
|
||||
|
||||
The release-time SBOM is published as an SPDX-JSON file alongside each release artifact.
|
||||
|
||||
---
|
||||
|
||||
If certctl solves a problem you have, [star the repo](https://github.com/certctl-io/certctl) to help others find it. Questions, bugs, or feature requests: [open an issue](https://github.com/certctl-io/certctl/issues).
|
||||
If certctl solves a problem you have, [star the repo](https://github.com/shankar0123/certctl) to help others find it. Questions, bugs, or feature requests — [open an issue](https://github.com/shankar0123/certctl/issues).
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
# Third-Party Notices
|
||||
|
||||
certctl is distributed under the Business Source License 1.1
|
||||
(see [LICENSE](LICENSE)). The binaries built from this source link
|
||||
third-party Go and JavaScript libraries listed below; certctl LLC
|
||||
acknowledges each library's authors and reproduces their copyright
|
||||
and license terms here in compliance with each library's license.
|
||||
|
||||
Full license text for each library lives in that library's upstream
|
||||
repository. The license type is provided per-row; for the canonical
|
||||
notice, refer to the upstream source.
|
||||
|
||||
- **Last reviewed:** 2026-05-13
|
||||
- **Holder:** certctl LLC
|
||||
- **License:** BSL 1.1 (Apache 2.0 effective March 14, 2076)
|
||||
|
||||
## Go Modules (binary-link dependencies)
|
||||
|
||||
Generated by walking `go list -deps ./...` against the certctl
|
||||
server, agent, CLI, and MCP-server build paths. Excludes the Go
|
||||
standard library and the certctl-io/certctl module itself.
|
||||
|
||||
**Count:** see commit; generate via `go list -deps -f '{{if .Module}}{{.Module.Path}} {{.Module.Version}}{{end}}' ./...`
|
||||
|
||||
| Module | Version | License |
|
||||
|---|---|---|
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/azcore` | v1.20.0 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/azidentity` | v1.13.1 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/internal` | v1.11.2 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates` | v1.4.0 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal` | v1.2.0 | MIT |
|
||||
| `github.com/Azure/go-ntlmssp` | v0.1.1 | MIT |
|
||||
| `github.com/AzureAD/microsoft-authentication-library-for-go` | v1.6.0 | MIT |
|
||||
| `github.com/ChrisTrenkamp/goxpath` | v0.0.0-20210404020558-97928f7e12b6 | MIT |
|
||||
| `github.com/aws/aws-sdk-go-v2` | v1.41.7 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/config` | v1.32.17 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/credentials` | v1.19.16 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` | v1.18.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/configsources` | v1.4.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/endpoints/v2` | v2.7.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/v4a` | v1.4.24 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/acm` | v1.38.3 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/acmpca` | v1.46.14 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding` | v1.13.9 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/presigned-url` | v1.13.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/signin` | v1.0.11 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/sso` | v1.30.17 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/ssooidc` | v1.35.21 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/sts` | v1.42.1 | Apache-2.0 |
|
||||
| `github.com/aws/smithy-go` | v1.25.1 | Apache-2.0 |
|
||||
| `github.com/bodgit/ntlmssp` | v0.0.0-20240506230425-31973bb52d9b | BSD-2/3-Clause |
|
||||
| `github.com/bodgit/windows` | v1.0.1 | BSD-2/3-Clause |
|
||||
| `github.com/coreos/go-oidc/v3` | v3.18.0 | Apache-2.0 |
|
||||
| `github.com/go-jose/go-jose/v4` | v4.1.4 | Apache-2.0 |
|
||||
| `github.com/go-logr/logr` | v1.4.3 | Apache-2.0 |
|
||||
| `github.com/gofrs/uuid` | v4.4.0+incompatible | MIT |
|
||||
| `github.com/golang-jwt/jwt/v5` | v5.3.0 | MIT |
|
||||
| `github.com/google/jsonschema-go` | v0.4.2 | MIT |
|
||||
| `github.com/google/uuid` | v1.6.0 | BSD-2/3-Clause |
|
||||
| `github.com/hashicorp/go-cleanhttp` | v0.5.2 | MPL-2.0 |
|
||||
| `github.com/hashicorp/go-uuid` | v1.0.3 | MPL-2.0 |
|
||||
| `github.com/jcmturner/aescts/v2` | v2.0.0 | Apache-2.0 |
|
||||
| `github.com/jcmturner/dnsutils/v2` | v2.0.0 | Apache-2.0 |
|
||||
| `github.com/jcmturner/gofork` | v1.7.6 | BSD-2/3-Clause |
|
||||
| `github.com/jcmturner/goidentity/v6` | v6.0.1 | Apache-2.0 |
|
||||
| `github.com/jcmturner/gokrb5/v8` | v8.4.4 | Apache-2.0 |
|
||||
| `github.com/jcmturner/rpc/v2` | v2.0.3 | Apache-2.0 |
|
||||
| `github.com/kr/fs` | v0.1.0 | BSD-2/3-Clause |
|
||||
| `github.com/kylelemons/godebug` | v1.1.0 | Apache-2.0 |
|
||||
| `github.com/lib/pq` | v1.10.9 | MIT |
|
||||
| `github.com/masterzen/simplexml` | v0.0.0-20190410153822-31eea3082786 | Apache-2.0 |
|
||||
| `github.com/masterzen/winrm` | v0.0.0-20250927112105-5f8e6c707321 | Apache-2.0 |
|
||||
| `github.com/modelcontextprotocol/go-sdk` | v1.4.1 | Apache-2.0 |
|
||||
| `github.com/pkg/browser` | v0.0.0-20240102092130-5ac0b6a4141c | BSD-2/3-Clause |
|
||||
| `github.com/pkg/sftp` | v1.13.10 | BSD-2/3-Clause |
|
||||
| `github.com/segmentio/asm` | v1.1.3 | MIT |
|
||||
| `github.com/segmentio/encoding` | v0.5.4 | MIT |
|
||||
| `github.com/tidwall/transform` | v0.0.0-20201103190739-32f242e2dbde | ISC |
|
||||
| `github.com/yosida95/uritemplate/v3` | v3.0.2 | BSD-2/3-Clause |
|
||||
| `golang.org/x/crypto` | v0.50.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/net` | v0.53.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/oauth2` | v0.36.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/sync` | v0.20.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/sys` | v0.43.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/text` | v0.36.0 | BSD-2/3-Clause |
|
||||
| `software.sslmate.com/src/go-pkcs12` | v0.7.0 | BSD-2/3-Clause |
|
||||
|
||||
## JavaScript Packages (production transitive closure)
|
||||
|
||||
Generated by walking the `dependencies` graph from `web/package.json`
|
||||
through `node_modules/`. Excludes devDependencies (Vitest, Playwright,
|
||||
Vite, etc.) since they don't ship in the distributed frontend bundle.
|
||||
|
||||
| Package | Version | License |
|
||||
|---|---|---|
|
||||
| `@reduxjs/toolkit` | 2.11.2 | MIT |
|
||||
| `@remix-run/router` | 1.23.2 | MIT |
|
||||
| `@standard-schema/spec` | 1.1.0 | MIT |
|
||||
| `@standard-schema/utils` | 0.3.0 | MIT |
|
||||
| `@tanstack/query-core` | 5.90.20 | MIT |
|
||||
| `@tanstack/react-query` | 5.90.21 | MIT |
|
||||
| `@types/d3-array` | 3.2.2 | MIT |
|
||||
| `@types/d3-color` | 3.1.3 | MIT |
|
||||
| `@types/d3-ease` | 3.0.2 | MIT |
|
||||
| `@types/d3-interpolate` | 3.0.4 | MIT |
|
||||
| `@types/d3-path` | 3.1.1 | MIT |
|
||||
| `@types/d3-scale` | 4.0.9 | MIT |
|
||||
| `@types/d3-shape` | 3.1.8 | MIT |
|
||||
| `@types/d3-time` | 3.0.4 | MIT |
|
||||
| `@types/d3-timer` | 3.0.2 | MIT |
|
||||
| `@types/use-sync-external-store` | 0.0.6 | MIT |
|
||||
| `clsx` | 2.1.1 | MIT |
|
||||
| `d3-array` | 3.2.4 | ISC |
|
||||
| `d3-color` | 3.1.0 | ISC |
|
||||
| `d3-ease` | 3.0.1 | BSD-3-Clause |
|
||||
| `d3-format` | 3.1.2 | ISC |
|
||||
| `d3-interpolate` | 3.0.1 | ISC |
|
||||
| `d3-path` | 3.1.0 | ISC |
|
||||
| `d3-scale` | 4.0.2 | ISC |
|
||||
| `d3-shape` | 3.2.0 | ISC |
|
||||
| `d3-time` | 3.1.0 | ISC |
|
||||
| `d3-time-format` | 4.1.0 | ISC |
|
||||
| `d3-timer` | 3.0.1 | ISC |
|
||||
| `decimal.js-light` | 2.5.1 | MIT |
|
||||
| `es-toolkit` | 1.45.1 | MIT |
|
||||
| `eventemitter3` | 5.0.4 | MIT |
|
||||
| `immer` | 10.2.0 | MIT |
|
||||
| `internmap` | 2.0.3 | ISC |
|
||||
| `js-tokens` | 4.0.0 | MIT |
|
||||
| `loose-envify` | 1.4.0 | MIT |
|
||||
| `react` | 18.3.1 | MIT |
|
||||
| `react-dom` | 18.3.1 | MIT |
|
||||
| `react-redux` | 9.2.0 | MIT |
|
||||
| `react-router` | 6.30.3 | MIT |
|
||||
| `react-router-dom` | 6.30.3 | MIT |
|
||||
| `recharts` | 3.8.0 | MIT |
|
||||
| `redux` | 5.0.1 | MIT |
|
||||
| `redux-thunk` | 3.1.0 | MIT |
|
||||
| `reselect` | 5.1.1 | MIT |
|
||||
| `scheduler` | 0.23.2 | MIT |
|
||||
| `tiny-invariant` | 1.3.3 | MIT |
|
||||
| `use-sync-external-store` | 1.6.0 | MIT |
|
||||
| `victory-vendor` | 37.3.6 | MIT AND ISC |
|
||||
|
||||
## Test-fixture-only dependencies
|
||||
|
||||
**Cisco libest.** The certctl integration test suite exercises the EST
|
||||
(RFC 7030) endpoints against Cisco's libest reference client. libest
|
||||
runs as a sidecar container (`certctl-test-libest`) only when the
|
||||
`est-e2e` Docker Compose profile is active — it is **not** vendored
|
||||
into the certctl source tree and **not** linked into any distributed
|
||||
release artifact (server, agent, CLI, MCP-server, container images,
|
||||
or release tarballs). For libest's own license terms, see
|
||||
<https://github.com/cisco/libest>.
|
||||
|
||||
**f5-mock-icontrol.** The F5 deployment-target integration test
|
||||
ships a small Go program at `deploy/test/f5-mock-icontrol/main.go`
|
||||
under the same BSL 1.1 license as the rest of certctl. The compiled
|
||||
ELF was removed from the tracked tree in Phase 1 closure (commit
|
||||
eda3b48, 2026-05-13); it now rebuilds via the Dockerfile's
|
||||
multi-stage build on demand.
|
||||
@@ -1,177 +0,0 @@
|
||||
# Routes registered in internal/api/router/router.go that are intentionally
|
||||
# NOT in api/openapi.yaml. Each entry needs a one-line `why:` justification.
|
||||
# Adding a new entry requires PR-time review.
|
||||
#
|
||||
# OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here.
|
||||
# This list is for protocol-shaped (SCEP wire endpoints) and operational
|
||||
# (health, metrics, pprof) routes only.
|
||||
#
|
||||
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
|
||||
#
|
||||
# Phase 5 reconciliation (2026-05-13, architecture diligence audit
|
||||
# ARCH-H1): of the 64 entries below, 35 are legitimate wire-protocol
|
||||
# carve-outs (SCEP RFC 8894 = 8 entries, ACME RFC 8555 default + per-
|
||||
# profile = 27 entries) that MUST stay. The remaining 29 are REST-
|
||||
# shaped routes whose OpenAPI ops were deferred during their original
|
||||
# Bundle 2 / audit-2026-05-10 / 2026-05-11 work. Burn-down plan:
|
||||
#
|
||||
# Sprint A (per-cluster, ~7-8 ops each):
|
||||
# Cluster 1: auth/sessions + auth/oidc (12 ops)
|
||||
# Cluster 2: auth/breakglass + auth/users + auth/runtime-config (8 ops)
|
||||
# Cluster 3: audit/export + demo-residual/cleanup + auth/logout +
|
||||
# auth/breakglass/login + auth/oidc/{login,callback,bcl} (9 ops)
|
||||
#
|
||||
# Each authored OpenAPI op needs request/response schemas (not
|
||||
# placeholders) so the generated client at web/orval.config.ts emits
|
||||
# typed signatures. When an op lands, delete the corresponding entry
|
||||
# below + bump the openapi-handler-parity.sh expected counts.
|
||||
|
||||
documented_exceptions:
|
||||
- route: "GET /scep"
|
||||
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; serves CA certs via GetCACert/GetCACaps query params, NOT a REST resource."
|
||||
- route: "POST /scep"
|
||||
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource."
|
||||
- route: "GET /scep/"
|
||||
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
||||
- route: "POST /scep/"
|
||||
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
||||
- route: "GET /scep-mtls"
|
||||
why: "SCEP-mTLS sibling endpoint per ci-pipeline-cleanup-prerequisite EST RFC 7030 hardening Phase 6.5; same wire-protocol semantics, mutually-authenticated TLS variant."
|
||||
- route: "POST /scep-mtls"
|
||||
why: "SCEP-mTLS sibling endpoint, POST variant."
|
||||
- route: "GET /scep-mtls/"
|
||||
why: "SCEP-mTLS sibling endpoint, trailing-slash variant."
|
||||
- route: "POST /scep-mtls/"
|
||||
why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant."
|
||||
|
||||
# ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface.
|
||||
# Like SCEP/EST, ACME is a JWS-signed-JSON wire protocol whose
|
||||
# semantics are dictated by the RFC, not by an OpenAPI schema.
|
||||
# Documenting every endpoint in openapi.yaml would duplicate
|
||||
# RFC 8555 §7.1 + §7.2 + §7.3 with no information gain. The
|
||||
# canonical operator-facing reference is docs/acme-server.md.
|
||||
# Phases 2-4 will extend this list as new-order, finalize, authz,
|
||||
# challenge, cert, key-change, revoke-cert, renewal-info routes land.
|
||||
- route: "GET /acme/profile/{id}/directory"
|
||||
why: "ACME server RFC 8555 §7.1.1 directory; documented in docs/acme-server.md."
|
||||
- route: "HEAD /acme/profile/{id}/new-nonce"
|
||||
why: "ACME server RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md."
|
||||
- route: "GET /acme/profile/{id}/new-nonce"
|
||||
why: "ACME server RFC 8555 §7.2 new-nonce GET form; documented in docs/acme-server.md."
|
||||
- route: "POST /acme/profile/{id}/new-account"
|
||||
why: "ACME server RFC 8555 §7.3 new-account (JWS jwk); documented in docs/acme-server.md."
|
||||
- route: "POST /acme/profile/{id}/account/{acc_id}"
|
||||
why: "ACME server RFC 8555 §7.3.2 + §7.3.6 (JWS kid) account update + deactivation; documented in docs/acme-server.md."
|
||||
- route: "GET /acme/directory"
|
||||
why: "ACME server default-profile shorthand; mirrors per-profile when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set."
|
||||
- route: "HEAD /acme/new-nonce"
|
||||
why: "ACME server default-profile shorthand for new-nonce HEAD."
|
||||
- route: "GET /acme/new-nonce"
|
||||
why: "ACME server default-profile shorthand for new-nonce GET."
|
||||
- route: "POST /acme/new-account"
|
||||
why: "ACME server default-profile shorthand for new-account."
|
||||
- route: "POST /acme/account/{acc_id}"
|
||||
why: "ACME server default-profile shorthand for account update + deactivation."
|
||||
|
||||
# Phase 2 — orders + finalize + authz + cert.
|
||||
- route: "POST /acme/profile/{id}/new-order"
|
||||
why: "ACME server RFC 8555 §7.4 new-order; documented in docs/acme-server.md."
|
||||
- route: "POST /acme/profile/{id}/order/{ord_id}"
|
||||
why: "ACME server RFC 8555 §7.4 order POST-as-GET; documented in docs/acme-server.md."
|
||||
- route: "POST /acme/profile/{id}/order/{ord_id}/finalize"
|
||||
why: "ACME server RFC 8555 §7.4 finalize; documented in docs/acme-server.md."
|
||||
- route: "POST /acme/profile/{id}/authz/{authz_id}"
|
||||
why: "ACME server RFC 8555 §7.5 authz POST-as-GET; documented in docs/acme-server.md."
|
||||
- route: "POST /acme/profile/{id}/challenge/{chall_id}"
|
||||
why: "ACME server RFC 8555 §7.5.1 challenge response; dispatches to Phase 3 validator pool."
|
||||
- route: "POST /acme/profile/{id}/cert/{cert_id}"
|
||||
why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md."
|
||||
- route: "POST /acme/new-order"
|
||||
why: "Phase 2 default-profile shorthand for new-order."
|
||||
- route: "POST /acme/order/{ord_id}"
|
||||
why: "Phase 2 default-profile shorthand for order POST-as-GET."
|
||||
- route: "POST /acme/order/{ord_id}/finalize"
|
||||
why: "Phase 2 default-profile shorthand for finalize."
|
||||
- route: "POST /acme/authz/{authz_id}"
|
||||
why: "Phase 2 default-profile shorthand for authz POST-as-GET."
|
||||
- route: "POST /acme/challenge/{chall_id}"
|
||||
why: "Phase 3 default-profile shorthand for challenge response."
|
||||
- route: "POST /acme/cert/{cert_id}"
|
||||
why: "Phase 2 default-profile shorthand for cert download."
|
||||
- route: "POST /acme/profile/{id}/key-change"
|
||||
why: "ACME server RFC 8555 §7.3.5 doubly-signed key rollover; documented in docs/acme-server.md."
|
||||
- route: "POST /acme/profile/{id}/revoke-cert"
|
||||
why: "ACME server RFC 8555 §7.6 revoke-cert (kid OR cert-key auth); documented in docs/acme-server.md."
|
||||
- route: "GET /acme/profile/{id}/renewal-info/{cert_id}"
|
||||
why: "ACME server RFC 9773 ACME Renewal Information (unauthenticated GET); documented in docs/acme-server.md."
|
||||
- route: "POST /acme/key-change"
|
||||
why: "Phase 4 default-profile shorthand for key rollover."
|
||||
- route: "POST /acme/revoke-cert"
|
||||
why: "Phase 4 default-profile shorthand for revoke-cert."
|
||||
- route: "GET /acme/renewal-info/{cert_id}"
|
||||
why: "Phase 4 default-profile shorthand for ARI."
|
||||
|
||||
# =============================================================================
|
||||
# Auth Bundle 2 + audit-2026-05-10/11 fix bundle — REST endpoints not yet
|
||||
# represented in api/openapi.yaml. These are operator-facing REST endpoints
|
||||
# (not protocol-shaped); the OpenAPI surface is scheduled to land pre-v2.2.0
|
||||
# alongside the GUI E2E coverage push. Documented here so the parity guard
|
||||
# stays green for the v2.1.0 release tag. Threat model + handler contracts
|
||||
# live in docs/operator/{rbac.md,auth-threat-model.md,oidc-runbooks/*}.
|
||||
# =============================================================================
|
||||
- route: "GET /auth/oidc/login"
|
||||
why: "Bundle 2 Phase 5 OIDC login redirect; user-facing 302 with state cookie. OpenAPI rep deferred to pre-2.2.0."
|
||||
- route: "GET /auth/oidc/callback"
|
||||
why: "Bundle 2 Phase 5 OIDC callback handler; RFC 9700 §4.7.1 + RFC 9207. OpenAPI rep deferred to pre-2.2.0."
|
||||
- route: "POST /auth/logout"
|
||||
why: "Bundle 2 Phase 5 cookie + CSRF revoker. OpenAPI rep deferred to pre-2.2.0."
|
||||
- route: "POST /auth/breakglass/login"
|
||||
why: "Bundle 2 Phase 7.5 public break-glass login (auth-bypass, 404 when disabled). OpenAPI rep deferred to pre-2.2.0."
|
||||
- route: "POST /auth/oidc/back-channel-logout"
|
||||
why: "Bundle 2 Phase 5 RFC OIDC Back-Channel Logout 1.0 endpoint. OpenAPI rep deferred to pre-2.2.0."
|
||||
- route: "GET /api/v1/auth/sessions"
|
||||
why: "Bundle 2 Phase 5 self/admin session list. OpenAPI rep deferred to pre-2.2.0."
|
||||
- route: "DELETE /api/v1/auth/sessions/{id}"
|
||||
why: "Bundle 2 Phase 5 session revoke. OpenAPI rep deferred to pre-2.2.0."
|
||||
- route: "DELETE /api/v1/auth/sessions"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-2/3 revoke-all-except-current."
|
||||
- route: "GET /api/v1/auth/oidc/providers"
|
||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (list)."
|
||||
- route: "POST /api/v1/auth/oidc/providers"
|
||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (create)."
|
||||
- route: "PUT /api/v1/auth/oidc/providers/{id}"
|
||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (update)."
|
||||
- route: "DELETE /api/v1/auth/oidc/providers/{id}"
|
||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (delete)."
|
||||
- route: "POST /api/v1/auth/oidc/providers/{id}/refresh"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-7 JWKS hot-refresh."
|
||||
- route: "GET /api/v1/auth/oidc/providers/{id}/jwks-status"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-7 JWKS health snapshot."
|
||||
- route: "POST /api/v1/auth/oidc/test"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-5 dry-run discovery + JWKS + alg-downgrade check."
|
||||
- route: "GET /api/v1/auth/oidc/group-mappings"
|
||||
why: "Bundle 2 Phase 5 group-mapping CRUD (list)."
|
||||
- route: "POST /api/v1/auth/oidc/group-mappings"
|
||||
why: "Bundle 2 Phase 5 group-mapping CRUD (create)."
|
||||
- route: "DELETE /api/v1/auth/oidc/group-mappings/{id}"
|
||||
why: "Bundle 2 Phase 5 group-mapping CRUD (delete)."
|
||||
- route: "GET /api/v1/auth/breakglass/credentials"
|
||||
why: "Bundle 2 Phase 7.5 admin break-glass list (404 when disabled; password hash never on wire)."
|
||||
- route: "POST /api/v1/auth/breakglass/credentials"
|
||||
why: "Bundle 2 Phase 7.5 admin break-glass set/rotate password."
|
||||
- route: "POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock"
|
||||
why: "Bundle 2 Phase 7.5 admin break-glass unlock after lockout."
|
||||
- route: "DELETE /api/v1/auth/breakglass/credentials/{actor_id}"
|
||||
why: "Bundle 2 Phase 7.5 admin break-glass credential delete."
|
||||
- route: "GET /api/v1/auth/users"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-11 users page."
|
||||
- route: "DELETE /api/v1/auth/users/{id}"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-11 user deactivate."
|
||||
- route: "POST /api/v1/auth/users/{id}/reactivate"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-11 user reactivate."
|
||||
- route: "GET /api/v1/auth/runtime-config"
|
||||
why: "Bundle 2 audit-2026-05-10 MED-12 effective auth-runtime-config (read-only)."
|
||||
- route: "POST /api/v1/auth/demo-residual/cleanup"
|
||||
why: "Audit 2026-05-11 A-8 demo-mode residual-grants cleanup endpoint."
|
||||
- route: "GET /api/v1/audit/export"
|
||||
why: "Bundle 1 Phase 8 streaming NDJSON audit export."
|
||||
+5
-1703
File diff suppressed because it is too large
Load Diff
+14
-39
@@ -478,7 +478,7 @@ func TestCreateTargetConnector_NGINX(t *testing.T) {
|
||||
agent, _ := NewAgent(cfg, logger)
|
||||
|
||||
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
|
||||
connector, err := agent.createTargetConnector(context.Background(), "NGINX", configJSON)
|
||||
connector, err := agent.createTargetConnector("NGINX", configJSON)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
@@ -499,7 +499,7 @@ func TestCreateTargetConnector_Unsupported(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
agent, _ := NewAgent(cfg, logger)
|
||||
|
||||
_, err := agent.createTargetConnector(context.Background(), "UnsupportedType", nil)
|
||||
_, err := agent.createTargetConnector("UnsupportedType", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for unsupported target type")
|
||||
@@ -692,10 +692,10 @@ func TestMakeRequest_InvalidURL(t *testing.T) {
|
||||
// TestCertKeyInfo tests extraction of key algorithm and size from certificates.
|
||||
func TestCertKeyInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
genKey func() interface{}
|
||||
expectedAlg string
|
||||
minBitSize int
|
||||
name string
|
||||
genKey func() interface{}
|
||||
expectedAlg string
|
||||
minBitSize int
|
||||
}{
|
||||
{
|
||||
name: "ECDSA P-256",
|
||||
@@ -831,7 +831,7 @@ func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 16 supported target types.
|
||||
// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 14 supported target types.
|
||||
func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -946,29 +946,6 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
|
||||
"secret_name": "tls-secret",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
|
||||
// Region must be a valid AWS region; the connector lazy-loads
|
||||
// the SDK client during ValidateConfig but New() with a populated
|
||||
// region should succeed against the SDK credential chain
|
||||
// (LoadDefaultConfig doesn't require live creds).
|
||||
name: "AWSACM",
|
||||
typeName: "AWSACM",
|
||||
config: map[string]string{
|
||||
"region": "us-east-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rank 5 (Azure half). Vault URL + cert name; the SDK client
|
||||
// lazy-loads via DefaultAzureCredential which doesn't require
|
||||
// live creds at construction time.
|
||||
name: "AzureKeyVault",
|
||||
typeName: "AzureKeyVault",
|
||||
config: map[string]string{
|
||||
"vault_url": "https://test-vault.vault.azure.net",
|
||||
"certificate_name": "demo-cert",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := &AgentConfig{
|
||||
@@ -987,7 +964,7 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
|
||||
connector, err := agent.createTargetConnector(context.Background(), tt.typeName, configJSON)
|
||||
connector, err := agent.createTargetConnector(tt.typeName, configJSON)
|
||||
|
||||
// Some connectors (like WinCertStore, IIS) may error on non-Windows platforms
|
||||
// or with insufficient validation. We accept either a valid connector or an error
|
||||
@@ -1022,8 +999,6 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
|
||||
"WinCertStore",
|
||||
"JavaKeystore",
|
||||
"KubernetesSecrets",
|
||||
"AWSACM",
|
||||
"AzureKeyVault",
|
||||
}
|
||||
|
||||
cfg := &AgentConfig{
|
||||
@@ -1039,7 +1014,7 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
|
||||
|
||||
for _, typeName := range tests {
|
||||
t.Run(typeName, func(t *testing.T) {
|
||||
_, err := agent.createTargetConnector(context.Background(), typeName, invalidJSON)
|
||||
_, err := agent.createTargetConnector(typeName, invalidJSON)
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid JSON with type %s", typeName)
|
||||
@@ -1059,7 +1034,7 @@ func TestCreateTargetConnector_UnknownType(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
agent, _ := NewAgent(cfg, logger)
|
||||
|
||||
_, err := agent.createTargetConnector(context.Background(), "MagicBox", nil)
|
||||
_, err := agent.createTargetConnector("MagicBox", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for unsupported target type")
|
||||
@@ -1092,7 +1067,7 @@ func TestCreateTargetConnector_EmptyConfig(t *testing.T) {
|
||||
for _, typeName := range tests {
|
||||
t.Run(typeName, func(t *testing.T) {
|
||||
// Empty config should be handled gracefully (defaults applied)
|
||||
connector, err := agent.createTargetConnector(context.Background(), typeName, nil)
|
||||
connector, err := agent.createTargetConnector(typeName, nil)
|
||||
|
||||
// Should not error on nil/empty config (defaults are applied)
|
||||
if err != nil {
|
||||
@@ -1528,9 +1503,9 @@ func TestValidateHTTPSScheme(t *testing.T) {
|
||||
wantErrSub: "plaintext http://",
|
||||
},
|
||||
{
|
||||
name: "bare host missing scheme falls through to unsupported",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
name: "bare host missing scheme falls through to unsupported",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
// url.Parse treats "localhost:8443" as scheme=localhost,
|
||||
// opaque=8443 — exercises the default arm (unsupported scheme)
|
||||
// rather than the empty-scheme arm. Both are fail-closed, which
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/connector/target"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/apache"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/awsacm"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/azurekv"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/caddy"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/envoy"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/f5"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/haproxy"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/iis"
|
||||
jks "github.com/certctl-io/certctl/internal/connector/target/javakeystore"
|
||||
k8s "github.com/certctl-io/certctl/internal/connector/target/k8ssecret"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/nginx"
|
||||
pf "github.com/certctl-io/certctl/internal/connector/target/postfix"
|
||||
sshconn "github.com/certctl-io/certctl/internal/connector/target/ssh"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/traefik"
|
||||
wcs "github.com/certctl-io/certctl/internal/connector/target/wincertstore"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 12 (2026-05-14): extracted from
|
||||
// cmd/agent/main.go via the Option B sibling-file pattern.
|
||||
//
|
||||
// This file holds the DEPLOYMENT executor + the target connector
|
||||
// factory + the deploy-only helpers:
|
||||
//
|
||||
// - executeDeploymentJob: handles Pending deployment jobs by
|
||||
// fetching the cert PEM from the control plane, loading the
|
||||
// locally-held private key (in agent keygen mode), instantiating
|
||||
// the appropriate target connector via createTargetConnector,
|
||||
// calling DeployCertificate on it, and reporting Completed or
|
||||
// Failed back to the control plane.
|
||||
// - createTargetConnector: the big switch over target_type that
|
||||
// instantiates one of 14 target connectors (apache / awsacm /
|
||||
// azurekv / caddy / envoy / f5 / haproxy / iis / javakeystore /
|
||||
// k8ssecret / nginx / postfix / ssh / traefik / wincertstore).
|
||||
// Context is threaded into SDK-driven connectors (AWSACM,
|
||||
// AzureKeyVault) so credential resolution honors caller
|
||||
// cancellation per the contextcheck linter — see CI commit
|
||||
// 502823d.
|
||||
// - splitPEMChain: split a PEM chain into (first cert, rest).
|
||||
// - fetchCertificate: pull the PEM chain from
|
||||
// GET /api/v1/certificates/{certID}/version.
|
||||
//
|
||||
// All 14 target-connector imports were used ONLY by
|
||||
// createTargetConnector; moving the factory here also moved the
|
||||
// 14 connector imports out of main.go, leaving the surviving
|
||||
// cmd/agent/main.go with the minimal stdlib surface its lifecycle
|
||||
// + HTTP infrastructure needs.
|
||||
|
||||
// executeDeploymentJob executes a deployment job by fetching the certificate and deploying it
|
||||
// to the target system using the appropriate connector (NGINX, F5 BIG-IP, or IIS).
|
||||
//
|
||||
// For agent keygen mode, the private key is read from the local key store (keyDir/certID.key)
|
||||
// rather than fetched from the server. The deployment includes the locally-held key.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Report job as Running
|
||||
// 2. Fetch the certificate PEM from the control plane
|
||||
// 3. Load local private key if it exists (agent keygen mode)
|
||||
// 4. Instantiate the target connector based on target_type from the work response
|
||||
// 5. Call DeployCertificate on the connector
|
||||
// 6. Report job as Completed (or Failed)
|
||||
func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
|
||||
a.logger.Info("executing deployment job",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"target_type", job.TargetType)
|
||||
|
||||
// Report job as running
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Running", ""); err != nil {
|
||||
a.logger.Error("failed to report job running", "error", err)
|
||||
}
|
||||
|
||||
// Fetch the certificate from the control plane
|
||||
certPEM, err := a.fetchCertificate(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to fetch certificate",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("cert fetch failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("certificate fetched for deployment",
|
||||
"job_id", job.ID,
|
||||
"cert_length", len(certPEM))
|
||||
|
||||
// Split PEM into cert and chain (separated by double newline between PEM blocks)
|
||||
certOnly, chainPEM := splitPEMChain(certPEM)
|
||||
|
||||
// Check for locally-stored private key (agent keygen mode)
|
||||
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
|
||||
var keyPEM string
|
||||
keyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to read local private key for deployment",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key read failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
keyPEM = string(keyData)
|
||||
a.logger.Info("loaded local private key for deployment",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath)
|
||||
|
||||
// Deploy to the target using the appropriate connector
|
||||
if job.TargetType != "" {
|
||||
connector, err := a.createTargetConnector(ctx, job.TargetType, job.TargetConfig)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create target connector",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("connector init failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Bundle 1 / RT-C1 closure (2026-05-12): defense in depth. The server
|
||||
// runs internal/connector/target/configcheck.Validate on the way IN
|
||||
// (Create/Update), and rejects shell metacharacters in command-bearing
|
||||
// fields. Re-run the connector's full ValidateConfig here on the way
|
||||
// OUT, before any DeployCertificate call. This catches (a) configs
|
||||
// that pre-date the server-side guard, (b) corruption/tampering of
|
||||
// the encrypted config blob, and (c) per-connector filesystem
|
||||
// invariants (cert dir exists, paths writable) that the server can't
|
||||
// check because the filesystem is on the agent host.
|
||||
if err := connector.ValidateConfig(ctx, job.TargetConfig); err != nil {
|
||||
a.logger.Error("connector config validation failed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("%s config validation failed: %v", job.TargetType, err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
deployReq := target.DeploymentRequest{
|
||||
CertPEM: certOnly,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
TargetConfig: job.TargetConfig,
|
||||
Metadata: map[string]string{
|
||||
"certificate_id": job.CertificateID,
|
||||
"job_id": job.ID,
|
||||
},
|
||||
}
|
||||
|
||||
// Phase 2 of the deploy-hardening I master bundle:
|
||||
// per-target deploy mutex. Acquire BEFORE
|
||||
// DeployCertificate so two concurrent renewals against
|
||||
// the same target ID serialize. The lock is held for the
|
||||
// full Deploy duration including PreCommit (validate),
|
||||
// PostCommit (reload), and post-deploy verify (Phases
|
||||
// 4-9). Released on every return path via defer.
|
||||
var targetID string
|
||||
if job.TargetID != nil {
|
||||
targetID = *job.TargetID
|
||||
}
|
||||
if mu := a.targetDeployMutex(targetID); mu != nil {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, deployReq)
|
||||
if err != nil {
|
||||
a.logger.Error("deployment failed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("deployment failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("target connector deployment completed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"success", result.Success,
|
||||
"message", result.Message)
|
||||
|
||||
// If verification is enabled, verify the deployment by probing the live TLS endpoint
|
||||
targetHost, targetPort, err := extractTargetHostAndPort(job.TargetConfig)
|
||||
if err != nil {
|
||||
a.logger.Warn("could not extract target host/port for verification",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
} else {
|
||||
a.verifyAndReportDeployment(ctx, job, targetHost, targetPort, certOnly)
|
||||
}
|
||||
} else {
|
||||
a.logger.Info("no target type specified, skipping connector invocation",
|
||||
"job_id", job.ID)
|
||||
}
|
||||
|
||||
// Report job as completed
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Completed", ""); err != nil {
|
||||
a.logger.Error("failed to report job completed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("deployment job completed", "job_id", job.ID)
|
||||
}
|
||||
|
||||
// createTargetConnector instantiates the appropriate target connector based on type.
|
||||
// ctx is threaded into SDK-driven connectors (AWSACM, AzureKeyVault) so credential
|
||||
// resolution honors caller cancellation / deadlines instead of using a fresh
|
||||
// context.Background() (the contextcheck linter enforces this — the original Rank 5
|
||||
// implementation used Background() and tripped CI on commit 502823d).
|
||||
func (a *Agent) createTargetConnector(ctx context.Context, targetType string, configJSON json.RawMessage) (target.Connector, error) {
|
||||
switch targetType {
|
||||
case "NGINX":
|
||||
var cfg nginx.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid NGINX config: %w", err)
|
||||
}
|
||||
}
|
||||
return nginx.New(&cfg, a.logger), nil
|
||||
|
||||
case "Apache":
|
||||
var cfg apache.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Apache config: %w", err)
|
||||
}
|
||||
}
|
||||
return apache.New(&cfg, a.logger), nil
|
||||
|
||||
case "HAProxy":
|
||||
var cfg haproxy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid HAProxy config: %w", err)
|
||||
}
|
||||
}
|
||||
return haproxy.New(&cfg, a.logger), nil
|
||||
|
||||
case "F5":
|
||||
var cfg f5.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid F5 config: %w", err)
|
||||
}
|
||||
}
|
||||
conn, err := f5.New(&cfg, a.logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create F5 connector: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
case "IIS":
|
||||
var cfg iis.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid IIS config: %w", err)
|
||||
}
|
||||
}
|
||||
return iis.New(&cfg, a.logger)
|
||||
|
||||
case "Traefik":
|
||||
var cfg traefik.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Traefik config: %w", err)
|
||||
}
|
||||
}
|
||||
return traefik.New(&cfg, a.logger), nil
|
||||
|
||||
case "Caddy":
|
||||
var cfg caddy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Caddy config: %w", err)
|
||||
}
|
||||
}
|
||||
return caddy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Envoy":
|
||||
var cfg envoy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Envoy config: %w", err)
|
||||
}
|
||||
}
|
||||
return envoy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Postfix":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "postfix"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Postfix config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
case "Dovecot":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "dovecot"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Dovecot config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
case "SSH":
|
||||
var cfg sshconn.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid SSH config: %w", err)
|
||||
}
|
||||
}
|
||||
return sshconn.New(&cfg, a.logger)
|
||||
|
||||
case "WinCertStore":
|
||||
var cfg wcs.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid WinCertStore config: %w", err)
|
||||
}
|
||||
}
|
||||
return wcs.New(&cfg, a.logger)
|
||||
|
||||
case "JavaKeystore":
|
||||
var cfg jks.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid JavaKeystore config: %w", err)
|
||||
}
|
||||
}
|
||||
return jks.New(&cfg, a.logger), nil
|
||||
|
||||
case "KubernetesSecrets":
|
||||
var cfg k8s.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid KubernetesSecrets config: %w", err)
|
||||
}
|
||||
}
|
||||
return k8s.New(&cfg, a.logger)
|
||||
|
||||
case "AWSACM":
|
||||
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
|
||||
// AWS Certificate Manager target — SDK-driven (no file I/O).
|
||||
// LoadDefaultConfig handles the standard AWS credential chain
|
||||
// (IRSA / EC2 instance profile / SSO / env vars) without any
|
||||
// long-lived creds in connector Config.
|
||||
var cfg awsacm.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid AWSACM config: %w", err)
|
||||
}
|
||||
}
|
||||
return awsacm.New(ctx, &cfg, a.logger)
|
||||
|
||||
case "AzureKeyVault":
|
||||
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
|
||||
// Azure Key Vault target — SDK-driven (no file I/O).
|
||||
// DefaultAzureCredential handles the standard Azure credential
|
||||
// chain (managed identity / workload identity / env vars / az
|
||||
// CLI fallback). Long-lived service-principal secrets are
|
||||
// supported but discouraged via the credential_mode config.
|
||||
var cfg azurekv.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid AzureKeyVault config: %w", err)
|
||||
}
|
||||
}
|
||||
return azurekv.New(ctx, &cfg, a.logger)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||
}
|
||||
}
|
||||
|
||||
// splitPEMChain splits a PEM chain into the first certificate (cert) and the rest (chain).
|
||||
// The control plane returns the full chain as a single string with PEM blocks concatenated.
|
||||
func splitPEMChain(pemChain string) (string, string) {
|
||||
data := []byte(pemChain)
|
||||
block, rest := pem.Decode(data)
|
||||
if block == nil {
|
||||
return pemChain, ""
|
||||
}
|
||||
cert := string(pem.EncodeToMemory(block))
|
||||
|
||||
// Skip whitespace between cert and chain
|
||||
chain := strings.TrimSpace(string(rest))
|
||||
if chain == "" {
|
||||
return cert, ""
|
||||
}
|
||||
return cert, chain
|
||||
}
|
||||
|
||||
// fetchCertificate retrieves the certificate PEM chain from the control plane.
|
||||
// GET /api/v1/agents/{agentID}/certificates/{certID}
|
||||
func (a *Agent) fetchCertificate(ctx context.Context, certID string) (string, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/certificates/%s", a.config.AgentID, certID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var certResp struct {
|
||||
CertificatePEM string `json:"certificate_pem"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&certResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return certResp.CertificatePEM, nil
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Phase 2 of the deploy-hardening I master bundle: per-target
|
||||
// deploy mutex serializes concurrent deploys to the same target
|
||||
// at the agent dispatch layer.
|
||||
|
||||
// TestAgent_ConcurrentDeploysToSameTarget_Serialize spawns N
|
||||
// goroutines acquiring the same target's mutex and asserts that
|
||||
// only one is in the critical section at a time. The "critical
|
||||
// section" is simulated as an atomic-counter increment + sleep +
|
||||
// decrement; if the lock works, max-in-flight is 1.
|
||||
func TestAgent_ConcurrentDeploysToSameTarget_Serialize(t *testing.T) {
|
||||
a := &Agent{}
|
||||
|
||||
const N = 10
|
||||
var inFlight, maxInFlight int32
|
||||
var done int32
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mu := a.targetDeployMutex("target-A")
|
||||
if mu == nil {
|
||||
t.Errorf("expected non-nil mutex for non-empty target id")
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
n := atomic.AddInt32(&inFlight, 1)
|
||||
for {
|
||||
m := atomic.LoadInt32(&maxInFlight)
|
||||
if n <= m || atomic.CompareAndSwapInt32(&maxInFlight, m, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Brief work simulating the connector's Deploy.
|
||||
for j := 0; j < 1000; j++ {
|
||||
_ = j * j
|
||||
}
|
||||
atomic.AddInt32(&inFlight, -1)
|
||||
atomic.AddInt32(&done, 1)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if done != N {
|
||||
t.Errorf("done = %d, want %d (some goroutines didn't run)", done, N)
|
||||
}
|
||||
if maxInFlight > 1 {
|
||||
t.Errorf("max concurrent critical sections = %d, want 1 (mutex broken)", maxInFlight)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_DifferentTargetIDs_ParallelizeIndependently verifies
|
||||
// the per-target granularity: deploys to target-A and target-B
|
||||
// proceed in parallel (no global serialization point).
|
||||
func TestAgent_DifferentTargetIDs_ParallelizeIndependently(t *testing.T) {
|
||||
a := &Agent{}
|
||||
|
||||
muA := a.targetDeployMutex("target-A")
|
||||
muB := a.targetDeployMutex("target-B")
|
||||
|
||||
if muA == nil || muB == nil {
|
||||
t.Fatal("nil mutexes")
|
||||
}
|
||||
if muA == muB {
|
||||
t.Error("target-A and target-B share the same mutex (broken granularity)")
|
||||
}
|
||||
|
||||
// Acquire A; B should still be acquirable concurrently.
|
||||
muA.Lock()
|
||||
defer muA.Unlock()
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
muB.Lock()
|
||||
close(acquired)
|
||||
muB.Unlock()
|
||||
}()
|
||||
<-acquired // would deadlock if B were blocked by A
|
||||
}
|
||||
|
||||
// TestAgent_EmptyTargetID_ReturnsNilMutex pins the
|
||||
// "no-targetID = no-lock" contract. Defends against the
|
||||
// pathological case where every targetless deploy serializes on a
|
||||
// shared empty-string mutex.
|
||||
func TestAgent_EmptyTargetID_ReturnsNilMutex(t *testing.T) {
|
||||
a := &Agent{}
|
||||
if mu := a.targetDeployMutex(""); mu != nil {
|
||||
t.Errorf("empty targetID returned non-nil mutex: %p", mu)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_TargetMutex_IsStable verifies sync.Map LoadOrStore
|
||||
// semantics: same target ID returns the same *sync.Mutex pointer
|
||||
// across calls (so the lock actually works across goroutines that
|
||||
// look up the mutex independently).
|
||||
func TestAgent_TargetMutex_IsStable(t *testing.T) {
|
||||
a := &Agent{}
|
||||
mu1 := a.targetDeployMutex("target-X")
|
||||
mu2 := a.targetDeployMutex("target-X")
|
||||
if mu1 != mu2 {
|
||||
t.Errorf("targetMutex returned %p then %p for same id (stability broken)", mu1, mu2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_TargetMutex_RaceLookup pins the race-detector
|
||||
// invariant: many goroutines calling targetDeployMutex
|
||||
// concurrently for the same key all get the same pointer (no
|
||||
// torn read).
|
||||
func TestAgent_TargetMutex_RaceLookup(t *testing.T) {
|
||||
a := &Agent{}
|
||||
const N = 50
|
||||
results := make(chan *sync.Mutex, N)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- a.targetDeployMutex("target-shared")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
var first *sync.Mutex
|
||||
for got := range results {
|
||||
if first == nil {
|
||||
first = got
|
||||
continue
|
||||
}
|
||||
if got != first {
|
||||
t.Errorf("goroutine got different mutex (%p vs %p)", got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 12 (2026-05-14): extracted from
|
||||
// cmd/agent/main.go via the Option B sibling-file pattern.
|
||||
//
|
||||
// This file holds the filesystem DISCOVERY scan — the agent's
|
||||
// outbound surface for reporting pre-existing certificates it
|
||||
// finds on disk back to the control plane (POST /api/v1/agents/
|
||||
// {id}/discoveries, a machine-to-machine flow NOT exposed via the
|
||||
// MCP surface per the comment in
|
||||
// internal/mcp/tools.go::RegisterTools):
|
||||
//
|
||||
// - runDiscoveryScan: walks each configured discovery directory,
|
||||
// dispatches each candidate file to parsePEMFile or parseDERFile
|
||||
// depending on extension, batches the parsed entries, and POSTs
|
||||
// them in one report.
|
||||
// - parsePEMFile / parseDERFile: extract every X.509 certificate
|
||||
// from a candidate file in either encoding.
|
||||
// - certToEntry: project a parsed *x509.Certificate into the
|
||||
// discoveredCertEntry shape the control plane expects.
|
||||
// - discoveredCertEntry struct + sha256Sum + certKeyInfo helpers
|
||||
// consumed only by the discovery path; co-locating them keeps
|
||||
// this file self-contained.
|
||||
|
||||
// runDiscoveryScan walks configured directories, parses certificate files, and reports
|
||||
// discovered certificates to the control plane.
|
||||
// Supports PEM and DER encoded X.509 certificates.
|
||||
func (a *Agent) runDiscoveryScan(ctx context.Context) {
|
||||
a.logger.Info("starting filesystem certificate discovery scan",
|
||||
"directories", a.config.DiscoveryDirs)
|
||||
|
||||
startTime := time.Now()
|
||||
var certs []discoveredCertEntry
|
||||
var scanErrors []string
|
||||
|
||||
for _, dir := range a.config.DiscoveryDirs {
|
||||
a.logger.Debug("scanning directory", "path", dir)
|
||||
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("walk error at %s: %v", path, err))
|
||||
return nil // continue walking
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files larger than 1MB (unlikely to be a certificate)
|
||||
if info.Size() > 1*1024*1024 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".pem", ".crt", ".cer", ".cert":
|
||||
found := a.parsePEMFile(path)
|
||||
certs = append(certs, found...)
|
||||
case ".der":
|
||||
if entry, err := a.parseDERFile(path); err == nil {
|
||||
certs = append(certs, entry)
|
||||
} else {
|
||||
a.logger.Debug("skipping non-cert DER file", "path", path, "error", err)
|
||||
}
|
||||
default:
|
||||
// Try PEM parsing for extensionless files or unknown extensions
|
||||
if ext == "" || ext == ".key" {
|
||||
return nil // skip key files and extensionless
|
||||
}
|
||||
found := a.parsePEMFile(path)
|
||||
if len(found) > 0 {
|
||||
certs = append(certs, found...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("failed to walk %s: %v", dir, err))
|
||||
}
|
||||
}
|
||||
|
||||
scanDuration := time.Since(startTime)
|
||||
a.logger.Info("discovery scan completed",
|
||||
"certificates_found", len(certs),
|
||||
"errors", len(scanErrors),
|
||||
"duration_ms", scanDuration.Milliseconds())
|
||||
|
||||
if len(certs) == 0 && len(scanErrors) == 0 {
|
||||
a.logger.Debug("no certificates found and no errors, skipping report")
|
||||
return
|
||||
}
|
||||
|
||||
// Build report payload
|
||||
entries := make([]map[string]interface{}, len(certs))
|
||||
for i, c := range certs {
|
||||
entries[i] = map[string]interface{}{
|
||||
"fingerprint_sha256": c.FingerprintSHA256,
|
||||
"common_name": c.CommonName,
|
||||
"sans": c.SANs,
|
||||
"serial_number": c.SerialNumber,
|
||||
"issuer_dn": c.IssuerDN,
|
||||
"subject_dn": c.SubjectDN,
|
||||
"not_before": c.NotBefore,
|
||||
"not_after": c.NotAfter,
|
||||
"key_algorithm": c.KeyAlgorithm,
|
||||
"key_size": c.KeySize,
|
||||
"is_ca": c.IsCA,
|
||||
"pem_data": c.PEMData,
|
||||
"source_path": c.SourcePath,
|
||||
"source_format": c.SourceFormat,
|
||||
}
|
||||
}
|
||||
|
||||
report := map[string]interface{}{
|
||||
"agent_id": a.config.AgentID,
|
||||
"directories": a.config.DiscoveryDirs,
|
||||
"certificates": entries,
|
||||
"errors": scanErrors,
|
||||
"scan_duration_ms": int(scanDuration.Milliseconds()),
|
||||
}
|
||||
|
||||
// Submit to control plane
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/discoveries", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, report)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to submit discovery report", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("discovery report rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("discovery report submitted successfully",
|
||||
"certificates", len(certs),
|
||||
"errors", len(scanErrors))
|
||||
}
|
||||
|
||||
// discoveredCertEntry holds parsed certificate metadata for reporting.
|
||||
type discoveredCertEntry struct {
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
SubjectDN string `json:"subject_dn"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
PEMData string `json:"pem_data"`
|
||||
SourcePath string `json:"source_path"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
}
|
||||
|
||||
// parsePEMFile reads a file and extracts all X.509 certificates from PEM blocks.
|
||||
func (a *Agent) parsePEMFile(path string) []discoveredCertEntry {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to read file", "path", path, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []discoveredCertEntry
|
||||
rest := data
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to parse certificate in PEM", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
pemStr := string(pem.EncodeToMemory(block))
|
||||
entries = append(entries, certToEntry(cert, path, "PEM", pemStr))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// parseDERFile reads a DER-encoded certificate file.
|
||||
func (a *Agent) parseDERFile(path string) (discoveredCertEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("read failed: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(data)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("parse failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert to PEM for storage
|
||||
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: data}))
|
||||
return certToEntry(cert, path, "DER", pemStr), nil
|
||||
}
|
||||
|
||||
// certToEntry converts a parsed x509.Certificate into a discoveredCertEntry.
|
||||
func certToEntry(cert *x509.Certificate, path, format, pemData string) discoveredCertEntry {
|
||||
// Compute SHA-256 fingerprint
|
||||
fingerprint := fmt.Sprintf("%x", sha256Sum(cert.Raw))
|
||||
|
||||
// Determine key algorithm and size
|
||||
keyAlg, keySize := certKeyInfo(cert)
|
||||
|
||||
return discoveredCertEntry{
|
||||
FingerprintSHA256: fingerprint,
|
||||
CommonName: cert.Subject.CommonName,
|
||||
SANs: cert.DNSNames,
|
||||
SerialNumber: cert.SerialNumber.Text(16),
|
||||
IssuerDN: cert.Issuer.String(),
|
||||
SubjectDN: cert.Subject.String(),
|
||||
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339),
|
||||
NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
|
||||
KeyAlgorithm: keyAlg,
|
||||
KeySize: keySize,
|
||||
IsCA: cert.IsCA,
|
||||
PEMData: pemData,
|
||||
SourcePath: path,
|
||||
SourceFormat: format,
|
||||
}
|
||||
}
|
||||
|
||||
// sha256Sum returns the SHA-256 hash of data.
|
||||
func sha256Sum(data []byte) [32]byte {
|
||||
return sha256.Sum256(data)
|
||||
}
|
||||
|
||||
// certKeyInfo extracts key algorithm name and size from a certificate.
|
||||
func certKeyInfo(cert *x509.Certificate) (string, int) {
|
||||
switch pub := cert.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return "ECDSA", pub.Curve.Params().BitSize
|
||||
case *rsa.PublicKey:
|
||||
return "RSA", pub.N.BitLen()
|
||||
default:
|
||||
switch cert.PublicKeyAlgorithm {
|
||||
case x509.Ed25519:
|
||||
return "Ed25519", 256
|
||||
default:
|
||||
return cert.PublicKeyAlgorithm.String(), 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,638 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bundle 0.7-extended: cmd/agent dispatch coverage for executeCSRJob,
|
||||
// executeDeploymentJob, verifyAndReportDeployment, markRetired, getEnvDefault,
|
||||
// getEnvBoolDefault — the previously-uncovered code paths flagged by the
|
||||
// audit's per-function coverage report.
|
||||
//
|
||||
// Strategy: same httptest-backed pattern as the existing agent_test.go
|
||||
// (Heartbeat / PollWork tests). Each test:
|
||||
// - constructs a mock control-plane HTTP server (httptest.NewServer)
|
||||
// - configures an Agent pointing at that server via NewAgent
|
||||
// - invokes the function under test
|
||||
// - asserts on the requests the mock server received
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// executeCSRJob
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_ExecuteCSRJob_HappyPath(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var csrSubmitted atomic.Bool
|
||||
var statusUpdates atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/csr") && r.Method == http.MethodPost:
|
||||
csrSubmitted.Store(true)
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["csr_pem"] == "" || !strings.Contains(body["csr_pem"], "CERTIFICATE REQUEST") {
|
||||
t.Errorf("CSR submission missing PEM body: %v", body)
|
||||
}
|
||||
if body["certificate_id"] != "mc-test-cert" {
|
||||
t.Errorf("CSR submission missing certificate_id: %v", body)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
statusUpdates.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, err := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewAgent: %v", err)
|
||||
}
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-csr-1",
|
||||
CertificateID: "mc-test-cert",
|
||||
Type: "csr",
|
||||
CommonName: "test.example.com",
|
||||
SANs: []string{"test.example.com", "alt.example.com", "alice@example.com"},
|
||||
}
|
||||
|
||||
agent.executeCSRJob(context.Background(), job)
|
||||
|
||||
if !csrSubmitted.Load() {
|
||||
t.Errorf("expected CSR to be submitted to control plane")
|
||||
}
|
||||
|
||||
// Key file should exist with mode 0600
|
||||
keyPath := filepath.Join(keyDir, "mc-test-cert.key")
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected key file at %s: %v", keyPath, err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected key file mode 0600, got %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Read back and verify it parses as an ECDSA key
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read key file: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil || block.Type != "EC PRIVATE KEY" {
|
||||
t.Errorf("expected EC PRIVATE KEY PEM, got %v", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteCSRJob_EmptyCommonName_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-csr-empty-cn",
|
||||
CertificateID: "mc-empty-cn",
|
||||
Type: "csr",
|
||||
CommonName: "", // empty CN — should be rejected
|
||||
}
|
||||
|
||||
agent.executeCSRJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected last status 'Failed', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteCSRJob_CSRSubmissionRejected_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/csr") && r.Method == http.MethodPost:
|
||||
// Server rejects the CSR with 400 Bad Request
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"CSR validation failed"}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-csr-rejected",
|
||||
CertificateID: "mc-rejected",
|
||||
Type: "csr",
|
||||
CommonName: "rejected.example.com",
|
||||
}
|
||||
|
||||
agent.executeCSRJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected last status 'Failed' after CSR rejection, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// executeDeploymentJob
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// generateTestCertAndKey builds an ephemeral self-signed cert + ECDSA P-256 key
|
||||
// for use as test fixture data in deployment tests.
|
||||
func generateTestCertAndKey(t *testing.T, cn string) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalECPrivateKey: %v", err)
|
||||
}
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteDeploymentJob_FetchFails_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/certificates/") && r.Method == http.MethodGet:
|
||||
// Fail the certificate fetch
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-deploy-fetch-fail",
|
||||
CertificateID: "mc-fetch-fail",
|
||||
Type: "deployment",
|
||||
TargetType: "nginx",
|
||||
}
|
||||
|
||||
agent.executeDeploymentJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected status 'Failed' after fetch failure, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteDeploymentJob_KeyMissing_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
certPEM, _ := generateTestCertAndKey(t, "deploy-test.example.com")
|
||||
// Note: key file is intentionally NOT written to keyDir — exercises the
|
||||
// "local private key missing" failure path in executeDeploymentJob.
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/certificates/") && r.Method == http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": "mc-no-key",
|
||||
"common_name": "deploy-test.example.com",
|
||||
"pem_content": certPEM,
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-deploy-no-key",
|
||||
CertificateID: "mc-no-key",
|
||||
Type: "deployment",
|
||||
TargetType: "nginx",
|
||||
}
|
||||
|
||||
agent.executeDeploymentJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected status 'Failed' after key-missing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteDeploymentJob_UnknownTargetType_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, "deploy-test.example.com")
|
||||
keyPath := filepath.Join(keyDir, "mc-unknown-tgt.key")
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0600); err != nil {
|
||||
t.Fatalf("WriteFile key: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/certificates/") && r.Method == http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": "mc-unknown-tgt",
|
||||
"common_name": "deploy-test.example.com",
|
||||
"pem_content": certPEM,
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-unknown-target",
|
||||
CertificateID: "mc-unknown-tgt",
|
||||
Type: "deployment",
|
||||
TargetType: "frobnicator-9000", // unknown connector type
|
||||
}
|
||||
|
||||
agent.executeDeploymentJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected status 'Failed' after unknown target type, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// markRetired — single-shot retirement signal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_MarkRetired_ClosesSignalOnce(t *testing.T) {
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: "http://example.invalid",
|
||||
APIKey: "k",
|
||||
AgentID: "a-retired-test",
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
// First mark — channel should close
|
||||
agent.markRetired("test-source-1", 410, "agent retired")
|
||||
select {
|
||||
case <-agent.retiredSignal:
|
||||
// expected — closed channel reads return zero immediately
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatalf("expected retiredSignal to be closed after markRetired")
|
||||
}
|
||||
|
||||
// Second mark — must not panic (sync.Once guards the close)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("second markRetired panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
agent.markRetired("test-source-2", 410, "agent retired again")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getEnvDefault / getEnvBoolDefault
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetEnvDefault_FallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_NONEXISTENT_VAR", "")
|
||||
got := getEnvDefault("TESTONLY_AGENT_NONEXISTENT_VAR", "fallback")
|
||||
if got != "fallback" {
|
||||
t.Errorf("expected fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvDefault_UsesEnvWhenSet(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_VAR", "from-env")
|
||||
got := getEnvDefault("TESTONLY_AGENT_VAR", "fallback")
|
||||
if got != "from-env" {
|
||||
t.Errorf("expected from-env, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_TruthyValues(t *testing.T) {
|
||||
for _, v := range []string{"1", "t", "true", "yes", "on", "TRUE", "True"} {
|
||||
t.Run(v, func(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", v)
|
||||
if !getEnvBoolDefault("TESTONLY_AGENT_BOOL", false) {
|
||||
t.Errorf("expected true for %q", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_FalsyValues(t *testing.T) {
|
||||
for _, v := range []string{"0", "f", "false", "no", "off"} {
|
||||
t.Run(v, func(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", v)
|
||||
if getEnvBoolDefault("TESTONLY_AGENT_BOOL", true) {
|
||||
t.Errorf("expected false for %q", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_UnrecognizedReturnsDefault(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", "frobnicate")
|
||||
if !getEnvBoolDefault("TESTONLY_AGENT_BOOL", true) {
|
||||
t.Errorf("expected default(true) for unrecognized value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_EmptyReturnsDefault(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", "")
|
||||
if !getEnvBoolDefault("TESTONLY_AGENT_BOOL", true) {
|
||||
t.Errorf("expected default(true) for empty value")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Run() — graceful shutdown via context cancellation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_Run_ContextCancelExitsCleanly(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/agents/a-run-test/heartbeat":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/api/v1/agents/a-run-test/work":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(WorkResponse{Jobs: []JobItem{}, Count: 0})
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-run-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, err := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewAgent: %v", err)
|
||||
}
|
||||
// Speed up tickers so the test exits in <500ms
|
||||
agent.heartbeatInterval = 50 * time.Millisecond
|
||||
agent.pollInterval = 50 * time.Millisecond
|
||||
agent.discoveryInterval = 24 * time.Hour
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- agent.Run(ctx)
|
||||
}()
|
||||
|
||||
// Let one heartbeat + poll fire, then cancel.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != context.Canceled {
|
||||
t.Errorf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Run did not exit within 2s after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// verifyAndReportDeployment
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_VerifyAndReportDeployment_ProbeFailure_ReportsError(t *testing.T) {
|
||||
// Server with no TLS listener at the target — probe will fail.
|
||||
var verificationReported atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/verify") || strings.Contains(r.URL.Path, "/verification") {
|
||||
verificationReported.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
tgtID := "tgt-test"
|
||||
job := JobItem{
|
||||
ID: "j-verify",
|
||||
TargetID: &tgtID,
|
||||
}
|
||||
|
||||
// Probe a closed port — will fail quickly.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Should not panic; failure surfaces via reportVerificationResult.
|
||||
agent.verifyAndReportDeployment(ctx, job, "127.0.0.1", 1, "")
|
||||
// Test passes if no panic.
|
||||
}
|
||||
|
||||
func TestAgent_VerifyAndReportDeployment_NilTargetID_LogsAndReturns(t *testing.T) {
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: "http://example.invalid",
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-no-tgt",
|
||||
TargetID: nil, // nil target — should short-circuit cleanly
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Should not panic and should return without making any HTTP call.
|
||||
agent.verifyAndReportDeployment(ctx, job, "127.0.0.1", 1, "")
|
||||
}
|
||||
|
||||
func TestAgent_Run_RetiredSignalExitsWithErrAgentRetired(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
// Server returns 410 Gone on heartbeat — the documented retirement signal.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/agents/a-retired/heartbeat":
|
||||
w.WriteHeader(http.StatusGone)
|
||||
_, _ = w.Write([]byte(`{"error":"agent retired"}`))
|
||||
case "/api/v1/agents/a-retired/work":
|
||||
w.WriteHeader(http.StatusGone)
|
||||
default:
|
||||
w.WriteHeader(http.StatusGone)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-retired",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
agent.heartbeatInterval = 30 * time.Millisecond
|
||||
agent.pollInterval = 30 * time.Millisecond
|
||||
agent.discoveryInterval = 24 * time.Hour
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- agent.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != ErrAgentRetired {
|
||||
t.Errorf("expected ErrAgentRetired, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Run did not surface ErrAgentRetired within 2s")
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Bundle-9 / Audit L-002 + L-003 (agent edition).
|
||||
//
|
||||
// The agent generates an ECDSA P-256 key locally and writes it to disk with
|
||||
// mode 0600 in a directory it expects to be 0700. The duplication of the
|
||||
// local-issuer helpers (instead of importing from internal/...) is deliberate:
|
||||
//
|
||||
// - cmd/agent is a separate binary with its own threat model (runs on every
|
||||
// deployment target, not just the control plane). Coupling it to
|
||||
// internal/connector/issuer/local would pull deployment-target footprint
|
||||
// into a connector that's only relevant on the server.
|
||||
// - The behavior is small and self-contained; copy-paste is cheaper than
|
||||
// a refactor that introduces an internal/keystore package.
|
||||
//
|
||||
// If a third call site emerges, lift these into internal/keystore.
|
||||
|
||||
// marshalAgentKeyAndZeroize marshals an ECDSA private key to DER and invokes
|
||||
// onDER with the bytes; the buffer is zeroized via builtin clear() after
|
||||
// onDER returns. Caller must NOT retain the slice.
|
||||
func marshalAgentKeyAndZeroize(priv *ecdsa.PrivateKey, onDER func([]byte) error) error {
|
||||
if priv == nil {
|
||||
return fmt.Errorf("marshalAgentKeyAndZeroize: nil private key")
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal EC private key: %w", err)
|
||||
}
|
||||
defer clear(der)
|
||||
return onDER(der)
|
||||
}
|
||||
|
||||
// ensureAgentKeyDirSecure creates dir (and ancestors) with mode 0700 or
|
||||
// asserts an existing dir is owner-only. If a pre-existing dir is more
|
||||
// permissive than 0700 we tighten it to 0700 (logging-free; this is a
|
||||
// startup-style invariant, not a per-request check).
|
||||
func ensureAgentKeyDirSecure(dir string) error {
|
||||
if dir == "" || dir == "." || dir == "/" {
|
||||
return fmt.Errorf("ensureAgentKeyDirSecure: refuse empty/root dir %q", dir)
|
||||
}
|
||||
clean := filepath.Clean(dir)
|
||||
info, err := os.Stat(clean)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
if mkErr := os.MkdirAll(clean, 0o700); mkErr != nil {
|
||||
return fmt.Errorf("create agent key dir %q: %w", clean, mkErr)
|
||||
}
|
||||
info, err = os.Stat(clean)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat newly-created agent key dir %q: %w", clean, err)
|
||||
}
|
||||
fallthrough
|
||||
case err == nil:
|
||||
mode := info.Mode().Perm()
|
||||
if mode == 0o700 || mode&0o077 == 0 {
|
||||
return nil
|
||||
}
|
||||
if chmodErr := os.Chmod(clean, 0o700); chmodErr != nil {
|
||||
return fmt.Errorf("tighten agent key dir %q from %#o to 0700: %w", clean, mode, chmodErr)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("stat agent key dir %q: %w", clean, err)
|
||||
}
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
package main
|
||||
|
||||
// Bundle 0.7 (Coverage Audit Closure) — cmd/agent key-handling regression coverage.
|
||||
//
|
||||
// Closes finding C-008 (CRTCTL-COVAUDIT-2026-04-27-0034). The two functions in
|
||||
// keymem.go are the agent's defense-in-depth for ECDSA P-256 private-key
|
||||
// memory hygiene (Bundle 9 / Audit L-002 + L-003 — agent edition). They
|
||||
// shipped with regression-test coverage of 0.0% / 11.1% respectively. This
|
||||
// file pins:
|
||||
//
|
||||
// - marshalAgentKeyAndZeroize: rejects nil keys, propagates onDER errors,
|
||||
// and ZEROIZES the DER backing buffer after onDER returns regardless of
|
||||
// whether onDER errored. The zeroization invariant is verified observably
|
||||
// (capture the slice header inside onDER, then assert every byte is 0x00
|
||||
// after the function returns) — NOT just asserted in prose.
|
||||
//
|
||||
// - ensureAgentKeyDirSecure: refuses empty / "." / "/", creates missing
|
||||
// dirs with mode 0700 (incl. nested ancestors), accepts existing 0700
|
||||
// and any owner-only-no-write mode (mode&0o077 == 0), tightens any other
|
||||
// mode to 0700, normalizes paths via filepath.Clean, is idempotent, is
|
||||
// safe under concurrent invocation, and propagates the documented error
|
||||
// messages from os.Stat / os.MkdirAll / os.Chmod failures.
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustGenAgentECDSAKey(t *testing.T) *ecdsa.PrivateKey {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// marshalAgentKeyAndZeroize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_HappyPath confirms onDER receives well-formed
|
||||
// DER bytes that the caller can use during the closure (e.g. to PEM-encode).
|
||||
func TestMarshalAgentKeyAndZeroize_HappyPath(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
called := false
|
||||
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
called = true
|
||||
if len(der) == 0 {
|
||||
t.Fatalf("der is empty inside onDER")
|
||||
}
|
||||
// First byte of an ECPrivateKey DER blob is the ASN.1 SEQUENCE tag 0x30.
|
||||
if der[0] != 0x30 {
|
||||
t.Errorf("expected DER to start with SEQUENCE tag 0x30, got %#x", der[0])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("onDER was never invoked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_NilKey confirms the early-return guard;
|
||||
// onDER must NOT be invoked when priv is nil.
|
||||
func TestMarshalAgentKeyAndZeroize_NilKey(t *testing.T) {
|
||||
called := false
|
||||
err := marshalAgentKeyAndZeroize(nil, func([]byte) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on nil key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nil private key") {
|
||||
t.Errorf("expected error mentioning %q, got: %v", "nil private key", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("onDER must not be invoked when priv is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_OnDERReturnsError confirms upstream errors
|
||||
// are propagated verbatim via errors.Is.
|
||||
func TestMarshalAgentKeyAndZeroize_OnDERReturnsError(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
sentinel := errors.New("simulated downstream failure")
|
||||
got := marshalAgentKeyAndZeroize(k, func([]byte) error { return sentinel })
|
||||
if !errors.Is(got, sentinel) {
|
||||
t.Errorf("expected upstream sentinel via errors.Is; got: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_BackingBufferZeroizedAfterReturn is the
|
||||
// CRITICAL invariant test. It captures the slice header (NOT a deep copy)
|
||||
// inside onDER and re-inspects after the function returns. Because Go slices
|
||||
// share their backing array, the captured slice observes the zeroization
|
||||
// performed by `defer clear(der)` in marshalAgentKeyAndZeroize.
|
||||
//
|
||||
// A future refactor that drops the `defer clear(der)` would break this test
|
||||
// even if HappyPath / NilKey / OnDERReturnsError still pass.
|
||||
func TestMarshalAgentKeyAndZeroize_BackingBufferZeroizedAfterReturn(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
var captured []byte
|
||||
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
// SHARE the backing array — do NOT take a defensive copy.
|
||||
captured = der
|
||||
if len(der) == 0 {
|
||||
t.Fatal("der is empty inside onDER")
|
||||
}
|
||||
// Sanity check: while still inside onDER, the bytes are live
|
||||
// (defer clear has NOT run yet).
|
||||
nonZero := false
|
||||
for _, b := range der {
|
||||
if b != 0 {
|
||||
nonZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !nonZero {
|
||||
t.Fatal("DER is all-zero INSIDE onDER; that should be impossible (clear hasn't run yet)")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
if len(captured) == 0 {
|
||||
t.Fatal("captured slice is empty post-return")
|
||||
}
|
||||
// After return, defer clear(der) has run. The captured slice shares the
|
||||
// backing array, so every byte must read 0x00.
|
||||
for i, b := range captured {
|
||||
if b != 0 {
|
||||
t.Errorf("captured[%d] = %#x; expected 0x00 (zeroized)", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_BufferZeroizedEvenOnError confirms the
|
||||
// `defer clear(der)` fires regardless of onDER's return — the security
|
||||
// invariant is "buffer is always zeroized after the function returns,"
|
||||
// happy path or error path.
|
||||
func TestMarshalAgentKeyAndZeroize_BufferZeroizedEvenOnError(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
sentinel := errors.New("upstream boom")
|
||||
var captured []byte
|
||||
gotErr := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
captured = der // share backing array
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(gotErr, sentinel) {
|
||||
t.Fatalf("expected sentinel via errors.Is, got: %v", gotErr)
|
||||
}
|
||||
if len(captured) == 0 {
|
||||
t.Fatal("captured slice empty post-return")
|
||||
}
|
||||
for i, b := range captured {
|
||||
if b != 0 {
|
||||
t.Errorf("captured[%d] = %#x; expected 0x00 (defer clear must run on error path)", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_ContractViolatorSeesZeros frames the same
|
||||
// observation as a defense-in-depth contract test. The docstring states
|
||||
// "Caller must NOT retain the slice." If a caller violates that contract
|
||||
// and reads the slice after onDER returns, they observe zeros — not the
|
||||
// private scalar. This test pins that defense.
|
||||
func TestMarshalAgentKeyAndZeroize_ContractViolatorSeesZeros(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
var leaked []byte // simulating a buggy caller that retains the slice
|
||||
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
leaked = der
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
// The contract violator now reads from `leaked`. Defense-in-depth: it's zeros.
|
||||
for i, b := range leaked {
|
||||
if b != 0 {
|
||||
t.Errorf("contract-violator read leaked[%d] = %#x; expected 0x00", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ensureAgentKeyDirSecure — table-driven coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEnsureAgentKeyDirSecure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
// setup returns the dir argument to pass to ensureAgentKeyDirSecure.
|
||||
// base is a fresh t.TempDir() unique to each subtest.
|
||||
setup func(t *testing.T, base string) string
|
||||
// wantErrSubstr; "" means no error is expected.
|
||||
wantErrSubstr string
|
||||
// wantMode; if set, asserted via os.Stat after the call. Set to 0
|
||||
// to skip the mode assertion (e.g. for error-path rows where the
|
||||
// dir wasn't created or wasn't intended to change).
|
||||
wantMode os.FileMode
|
||||
}
|
||||
cases := []tc{
|
||||
// Refuse-empty/root invariants
|
||||
{
|
||||
name: "empty_string_refused",
|
||||
setup: func(t *testing.T, _ string) string {
|
||||
return ""
|
||||
},
|
||||
wantErrSubstr: `refuse empty/root dir ""`,
|
||||
},
|
||||
{
|
||||
name: "dot_refused",
|
||||
setup: func(t *testing.T, _ string) string {
|
||||
return "."
|
||||
},
|
||||
wantErrSubstr: `refuse empty/root dir "."`,
|
||||
},
|
||||
{
|
||||
name: "root_refused",
|
||||
setup: func(t *testing.T, _ string) string {
|
||||
return "/"
|
||||
},
|
||||
wantErrSubstr: `refuse empty/root dir "/"`,
|
||||
},
|
||||
|
||||
// Non-existent path — MkdirAll(0700) path
|
||||
{
|
||||
name: "creates_with_0700",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
return filepath.Join(base, "newdir")
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "creates_nested_0700",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
return filepath.Join(base, "a", "b", "c")
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
|
||||
// Existing 0700 — no-op (mode == 0o700 branch).
|
||||
{
|
||||
name: "existing_0700_noop",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0700")
|
||||
if err := os.Mkdir(d, 0o700); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
|
||||
// Existing more-permissive — chmod tighten to 0700.
|
||||
{
|
||||
name: "existing_0750_tightened",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0750")
|
||||
if err := os.Mkdir(d, 0o750); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o750); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "existing_0755_tightened",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0755")
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "existing_0777_tightened",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0777")
|
||||
if err := os.Mkdir(d, 0o777); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o777); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
|
||||
// Existing owner-only-no-write modes accepted as-is via the
|
||||
// `mode&0o077 == 0` branch (no chmod, mode preserved).
|
||||
{
|
||||
name: "existing_0500_accepted_no_chmod",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0500")
|
||||
if err := os.Mkdir(d, 0o700); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o500); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(d, 0o700) }) // let TempDir cleanup
|
||||
return d
|
||||
},
|
||||
wantMode: 0o500,
|
||||
},
|
||||
{
|
||||
name: "existing_0400_accepted_no_chmod",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0400")
|
||||
if err := os.Mkdir(d, 0o700); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o400); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(d, 0o700) })
|
||||
return d
|
||||
},
|
||||
wantMode: 0o400,
|
||||
},
|
||||
|
||||
// filepath.Clean normalization paths.
|
||||
{
|
||||
name: "trailing_slash_normalized",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "trail")
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d + "/"
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "dot_prefix_normalized",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
// The function uses filepath.Clean which strips redundant
|
||||
// "./" segments. We only need to verify Clean is invoked,
|
||||
// not that we end up at a relative path; pass an absolute
|
||||
// path with an embedded "./".
|
||||
d := filepath.Join(base, "dotprefix")
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return filepath.Join(base, ".", "dotprefix")
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
dir := tc.setup(t, base)
|
||||
|
||||
err := ensureAgentKeyDirSecure(dir)
|
||||
if tc.wantErrSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErrSubstr) {
|
||||
t.Errorf("error %q does not contain %q", err, tc.wantErrSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ensureAgentKeyDirSecure: %v", err)
|
||||
}
|
||||
if tc.wantMode != 0 {
|
||||
clean := filepath.Clean(dir)
|
||||
info, statErr := os.Stat(clean)
|
||||
if statErr != nil {
|
||||
t.Fatalf("post-call stat: %v", statErr)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != tc.wantMode {
|
||||
t.Errorf("dir mode = %#o; want %#o", got, tc.wantMode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_Idempotent confirms a second call on a
|
||||
// just-created dir is a no-op (hits the `mode == 0o700` short-circuit).
|
||||
func TestEnsureAgentKeyDirSecure_Idempotent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "idempotent")
|
||||
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected 0700, got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_Concurrent runs the function from many
|
||||
// goroutines simultaneously on the same fresh path. This is a safety smoke
|
||||
// test under -race; it is NOT a functional correctness claim about
|
||||
// concurrent agents (the agent has a single goroutine). The MkdirAll call
|
||||
// is the load-bearing primitive here — it's documented as safe to call
|
||||
// repeatedly with no error if the dir already exists.
|
||||
func TestEnsureAgentKeyDirSecure_Concurrent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "concurrent")
|
||||
const workers = 8
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, workers)
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Errorf("concurrent caller returned error: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("post-concurrent stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected 0700 after concurrent calls, got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_PathIsAFile pins the function's behavior when
|
||||
// passed a regular file. The function does not type-check (no IsDir()), so
|
||||
// it stat's the file, sees mode 0o644 (or whatever), and chmod's it to 0700.
|
||||
//
|
||||
// This is "silently accepts a file path" behavior. It is not a correctness
|
||||
// bug per the function's caller (cmd/agent/main.go always passes
|
||||
// filepath.Dir(keyPath), which is a directory), but it is a hardening
|
||||
// candidate. Captured as a finding observation in the test docstring rather
|
||||
// than fixed in this bundle (Bundle 0.7 ships no production-code changes).
|
||||
func TestEnsureAgentKeyDirSecure_PathIsAFile(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
base := t.TempDir()
|
||||
filePath := filepath.Join(base, "not-a-dir.txt")
|
||||
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("setup writefile: %v", err)
|
||||
}
|
||||
err := ensureAgentKeyDirSecure(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("current behavior: function chmod's a file silently and returns nil; got err = %v", err)
|
||||
}
|
||||
info, statErr := os.Stat(filePath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("post-call stat: %v", statErr)
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Fatal("file became a directory; that's not a thing")
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected mode 0700 (current behavior), got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_MkdirErrorPropagated forces the MkdirAll
|
||||
// branch to fail by chmod'ing the parent to 0o500 (read+exec but no write).
|
||||
// On linux/darwin running as a non-root uid, MkdirAll on a child of such a
|
||||
// parent fails with EACCES. We assert the error message wraps with the
|
||||
// documented "create agent key dir" prefix.
|
||||
//
|
||||
// Skipped if running as root (root bypasses unix dir-write checks).
|
||||
func TestEnsureAgentKeyDirSecure_MkdirErrorPropagated(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("running as root; cannot revoke parent dir write permission")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
if err := os.Chmod(parent, 0o500); err != nil {
|
||||
t.Fatalf("setup chmod parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
|
||||
child := filepath.Join(parent, "no-can-create")
|
||||
err := ensureAgentKeyDirSecure(child)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when MkdirAll cannot write to read-only parent")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "create agent key dir") {
|
||||
t.Errorf("error %q should contain %q", err.Error(), "create agent key dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_StatErrorPropagated forces os.Stat to fail
|
||||
// with a non-IsNotExist error by chmod'ing the parent to 0o000 (no
|
||||
// read+exec). On linux/darwin running as a non-root uid, stat on a child
|
||||
// of such a parent fails with EACCES. We assert the error message wraps
|
||||
// with "stat agent key dir".
|
||||
//
|
||||
// Skipped if running as root.
|
||||
func TestEnsureAgentKeyDirSecure_StatErrorPropagated(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("running as root; cannot revoke parent dir read+exec permission")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
child := filepath.Join(parent, "victim")
|
||||
if err := os.Chmod(parent, 0o000); err != nil {
|
||||
t.Fatalf("setup chmod parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
|
||||
err := ensureAgentKeyDirSecure(child)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when stat cannot traverse unreadable parent")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stat agent key dir") {
|
||||
t.Errorf("error %q should contain %q", err.Error(), "stat agent key dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_ChmodErrorPropagated forces os.Chmod to fail
|
||||
// on an existing more-permissive dir. We achieve this by:
|
||||
// 1. Creating an intermediate dir at 0o755 (so the function takes the
|
||||
// tighten-via-chmod branch).
|
||||
// 2. Replacing the real dir with a read-only-from-parent bind: chmod the
|
||||
// grandparent to 0o500 so the chmod syscall on the child fails with
|
||||
// EACCES (the syscall needs write on the path's containing dir for
|
||||
// metadata updates on most unix filesystems — actually no, chmod only
|
||||
// needs ownership, not parent write. So we instead drop the file's
|
||||
// owner via... no — we cannot change ownership without root.)
|
||||
//
|
||||
// Reaching the chmod-error branch from a non-root test is awkward because
|
||||
// chmod only requires ownership (which we always have on t.TempDir()).
|
||||
// The cleanest way is to skip on non-root and exercise the branch in CI
|
||||
// images that run as root; but our CI runs as non-root. We DO trigger the
|
||||
// branch via a different mechanism: replace the path with a SYMLINK to
|
||||
// /proc/1/root (or similar) where the eventual stat resolves but chmod
|
||||
// fails — but that's brittle and OS-specific.
|
||||
//
|
||||
// Acceptable closure: document that this branch is exercised by the
|
||||
// existing chmod-fails errno path, but the test as written can only assert
|
||||
// the wrap-prefix when the branch IS reached. We use a synthetic approach:
|
||||
// chmod-tighten a dir we then immediately delete, racing the syscall —
|
||||
// not deterministic.
|
||||
//
|
||||
// Pragmatic resolution: the chmod-error branch is structurally identical
|
||||
// to the mkdir-error and stat-error branches (errors.Wrap with a
|
||||
// distinct prefix), and is exercised in production via os.Chmod ENOENT
|
||||
// or read-only-filesystem failures. We add a unit test that asserts the
|
||||
// branch's MESSAGE format by passing through a wrap helper construct.
|
||||
// This test instead documents that the branch is structural and any new
|
||||
// failure mode (read-only fs, immutable bit, ACLs) inherits the wrap
|
||||
// prefix automatically.
|
||||
//
|
||||
// To still get coverage on the chmod-error branch, we use os.Chmod against
|
||||
// a dir whose immediate parent we delete mid-call. This is racy. Instead,
|
||||
// we make chmod fail by passing a path that filepath.Clean rewrites to
|
||||
// a symlink whose target was just chmod-stripped. Too brittle.
|
||||
//
|
||||
// CLEANEST APPROACH: rely on the OS's read-only filesystem semantics under
|
||||
// /sys (which is RO on linux). os.Chmod on a path under /sys returns EROFS.
|
||||
// But /sys is owned by root — stat would succeed only on existing entries,
|
||||
// and the function would then attempt chmod, which fails with EROFS (the
|
||||
// non-root caller still gets a clean error wrap).
|
||||
//
|
||||
// We cannot find a well-defined non-root chmod-fail path on darwin. So the
|
||||
// test runs only on linux and skips elsewhere.
|
||||
func TestEnsureAgentKeyDirSecure_ChmodErrorPropagated(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("chmod-error branch is only reliably triggerable on linux via /sys (read-only fs)")
|
||||
}
|
||||
// /sys is mounted read-only on Linux. Pick a stable subdir we can stat
|
||||
// (kernel-class). os.Chmod against it returns EROFS regardless of uid
|
||||
// (well — root can remount, but the call against /sys/* still EROFS).
|
||||
candidate := "/sys/kernel"
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Skipf("/sys/kernel not stat-able as a dir on this host; skipping (%v)", err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
if mode == 0o700 || mode&0o077 == 0 {
|
||||
// Already in the no-chmod branch; this test cannot exercise the
|
||||
// chmod-fail branch on this host. Skip rather than false-positive.
|
||||
t.Skipf("/sys/kernel mode %#o already satisfies no-chmod branch", mode)
|
||||
}
|
||||
chmodErr := ensureAgentKeyDirSecure(candidate)
|
||||
if chmodErr == nil {
|
||||
t.Fatal("expected chmod failure on /sys (read-only fs)")
|
||||
}
|
||||
if !strings.Contains(chmodErr.Error(), "tighten agent key dir") {
|
||||
t.Errorf("error %q should contain %q", chmodErr.Error(), "tighten agent key dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_FmtErrorMessageIncludesPath confirms each
|
||||
// error wrap includes the cleaned path (debuggability invariant).
|
||||
func TestEnsureAgentKeyDirSecure_FmtErrorMessageIncludesPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("running as root; cannot revoke parent dir write permission")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
if err := os.Chmod(parent, 0o500); err != nil {
|
||||
t.Fatalf("setup chmod parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
child := filepath.Join(parent, "child")
|
||||
want := filepath.Clean(child)
|
||||
|
||||
err := ensureAgentKeyDirSecure(child)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q should reference cleaned path %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-cutting: end-to-end smoke confirming the two functions compose
|
||||
// the way main.go uses them (Bundle 9 / L-002 / L-003 flow).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestKeymem_AgentMainFlowSmoke replays the cmd/agent/main.go composition:
|
||||
// ensureAgentKeyDirSecure(dir) → marshalAgentKeyAndZeroize(priv, onDER).
|
||||
// Closes the contract that both helpers cooperate cleanly under realistic
|
||||
// fixture conditions, and that the DER buffer is zeroized at the end of
|
||||
// the marshal call.
|
||||
func TestKeymem_AgentMainFlowSmoke(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
keyDir := filepath.Join(t.TempDir(), "agent-keys")
|
||||
if err := ensureAgentKeyDirSecure(keyDir); err != nil {
|
||||
t.Fatalf("ensureAgentKeyDirSecure: %v", err)
|
||||
}
|
||||
info, err := os.Stat(keyDir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("key dir not at 0700, got %#o", info.Mode().Perm())
|
||||
}
|
||||
|
||||
priv := mustGenAgentECDSAKey(t)
|
||||
var captured []byte
|
||||
if err := marshalAgentKeyAndZeroize(priv, func(der []byte) error {
|
||||
captured = der // share backing array
|
||||
// Pretend caller does pem.EncodeToMemory(...) here; we just check
|
||||
// the DER is a valid SEQUENCE.
|
||||
if len(der) == 0 || der[0] != 0x30 {
|
||||
return fmt.Errorf("unexpected DER shape (len=%d, first=%#x)", len(der), der)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
for i, b := range captured {
|
||||
if b != 0 {
|
||||
t.Fatalf("post-flow DER buffer not zeroized at byte %d (%#x)", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
+787
-49
@@ -1,14 +1,18 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -19,11 +23,27 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
||||
pf "github.com/shankar0123/certctl/internal/connector/target/postfix"
|
||||
sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||
jks "github.com/shankar0123/certctl/internal/connector/target/javakeystore"
|
||||
k8s "github.com/shankar0123/certctl/internal/connector/target/k8ssecret"
|
||||
wcs "github.com/shankar0123/certctl/internal/connector/target/wincertstore"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/traefik"
|
||||
)
|
||||
|
||||
// AgentConfig represents the agent-side configuration.
|
||||
@@ -42,7 +62,7 @@ type AgentConfig struct {
|
||||
// ErrAgentRetired is the sentinel returned by [Agent.Run] when the control
|
||||
// plane responds with HTTP 410 Gone to a heartbeat or work-poll request — the
|
||||
// canonical signal that this agent's row has been soft-retired server-side
|
||||
// (see I-004 in the project's coverage-gap audit). The binary must
|
||||
// (see I-004 in cowork/certctl-coverage-gap-audit.md). The binary must
|
||||
// terminate cleanly: an init-system restart would only produce another 410
|
||||
// and wedge the host in a restart loop. main() translates this sentinel into
|
||||
// a zero exit code so systemd (Restart=on-failure) and launchd do not respawn
|
||||
@@ -60,10 +80,10 @@ type Agent struct {
|
||||
client *http.Client
|
||||
|
||||
// Configuration
|
||||
heartbeatInterval time.Duration
|
||||
pollInterval time.Duration
|
||||
discoveryInterval time.Duration
|
||||
consecutiveFailures int
|
||||
heartbeatInterval time.Duration
|
||||
pollInterval time.Duration
|
||||
discoveryInterval time.Duration
|
||||
consecutiveFailures int
|
||||
|
||||
// I-004: terminal retirement signal. retiredSignal is closed exactly once
|
||||
// (guarded by retiredOnce) when either sendHeartbeat or pollForWork
|
||||
@@ -75,47 +95,6 @@ type Agent struct {
|
||||
// race with ctx.Done() and other cases.
|
||||
retiredOnce sync.Once
|
||||
retiredSignal chan struct{}
|
||||
|
||||
// Deploy-hardening I Phase 2: per-target deploy mutex.
|
||||
// Two cert renewals against the same target ID (e.g., two SAN
|
||||
// entries renewing in the same window, or a fast-cycling
|
||||
// renewal-then-test workflow) MUST serialize at the agent
|
||||
// dispatch site. Without this lock, the underlying connector's
|
||||
// temp-file path could collide and the reload command would
|
||||
// race against itself.
|
||||
//
|
||||
// Granularity is one mutex per target ID, NOT per (target, cert)
|
||||
// pair — frozen decision 0.5. Cert deploy throughput is
|
||||
// operator-grade tens-per-minute; coarse serialization is fine
|
||||
// and simplifies reasoning about reload-side race windows.
|
||||
//
|
||||
// sync.Map is sized for thousands of unique target IDs without
|
||||
// rehash thrash; LoadOrStore is atomic + lock-free on the
|
||||
// hot path. Mutexes live for the agent's lifetime — no janitor
|
||||
// because target IDs are bounded and the per-target memory
|
||||
// (~16 bytes per entry) is negligible vs. typical agent heap.
|
||||
//
|
||||
// Job items without a TargetID (e.g., agent-managed cert + no
|
||||
// connector dispatch — should never happen for deploy jobs but
|
||||
// defended anyway) bypass the lock to avoid a singleton
|
||||
// serialization point.
|
||||
deployMutexes sync.Map // map[string]*sync.Mutex, keyed on JobItem.TargetID
|
||||
}
|
||||
|
||||
// targetDeployMutex returns the per-target-ID *sync.Mutex,
|
||||
// lazy-initialising one on first acquisition. Returns nil when
|
||||
// targetID is empty (caller should skip the lock entirely).
|
||||
//
|
||||
// Phase 2 of the deploy-hardening I master bundle: the load-bearing
|
||||
// serialization point that defends against concurrent deploys to the
|
||||
// same target stomping each other's temp-file paths or reload
|
||||
// commands.
|
||||
func (a *Agent) targetDeployMutex(targetID string) *sync.Mutex {
|
||||
if targetID == "" {
|
||||
return nil
|
||||
}
|
||||
v, _ := a.deployMutexes.LoadOrStore(targetID, &sync.Mutex{})
|
||||
return v.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// WorkResponse represents the response from the work polling endpoint.
|
||||
@@ -369,6 +348,532 @@ func (a *Agent) sendHeartbeat(ctx context.Context) {
|
||||
a.logger.Debug("heartbeat acknowledged")
|
||||
}
|
||||
|
||||
// pollForWork queries the control plane for actionable jobs and processes them.
|
||||
// Jobs may be deployment jobs (Pending) or CSR jobs (AwaitingCSR).
|
||||
// GET /api/v1/agents/{agentID}/work
|
||||
func (a *Agent) pollForWork(ctx context.Context) {
|
||||
a.logger.Debug("polling for work", "agent_id", a.config.AgentID)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/work", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
a.logger.Error("work poll failed", "error", err)
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// I-004: same terminal-retirement handling as sendHeartbeat. Work-poll is the
|
||||
// other hot path that can observe an agent's soft-retirement; if the
|
||||
// heartbeat tick happens to fire after a work-poll tick within the same
|
||||
// retirement window, this branch catches it first. markRetired's sync.Once
|
||||
// guards idempotency so racing both paths in the same tick only closes the
|
||||
// signal channel once. No consecutiveFailures increment — retirement is
|
||||
// not a transient failure.
|
||||
if resp.StatusCode == http.StatusGone {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.markRetired("work_poll", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("work poll rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
var workResp WorkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&workResp); err != nil {
|
||||
a.logger.Error("failed to decode work response", "error", err)
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
a.consecutiveFailures = 0
|
||||
|
||||
if workResp.Count == 0 {
|
||||
a.logger.Debug("no pending work")
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("received work", "job_count", workResp.Count)
|
||||
|
||||
// Process each job based on type and status
|
||||
for _, job := range workResp.Jobs {
|
||||
switch {
|
||||
case job.Status == "AwaitingCSR":
|
||||
// Agent keygen mode: generate key locally, create CSR, submit to server
|
||||
a.executeCSRJob(ctx, job)
|
||||
case job.Type == "Deployment":
|
||||
a.executeDeploymentJob(ctx, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeCSRJob handles an AwaitingCSR job: generates a private key locally, creates a CSR,
|
||||
// and submits it to the control plane for signing. The private key is stored on the local
|
||||
// filesystem with 0600 permissions and NEVER sent to the server.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Generate ECDSA P-256 key pair
|
||||
// 2. Store private key to disk (keyDir/certID.key) with 0600 permissions
|
||||
// 3. Create CSR with common name and SANs from work response
|
||||
// 4. Submit CSR to control plane via POST /agents/{id}/csr
|
||||
// 5. Server signs the CSR and creates a cert version + deployment jobs
|
||||
func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
||||
a.logger.Info("executing CSR job (agent-side key generation)",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"common_name", job.CommonName)
|
||||
|
||||
// Step 1: Generate ECDSA P-256 key pair
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to generate private key",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key generation failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("generated ECDSA P-256 key pair locally",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID)
|
||||
|
||||
// Step 2: Store private key to disk with secure permissions
|
||||
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
|
||||
privKeyDER, err := x509.MarshalECPrivateKey(privKey)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to marshal private key",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key marshal failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
privKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "EC PRIVATE KEY",
|
||||
Bytes: privKeyDER,
|
||||
})
|
||||
|
||||
if err := os.WriteFile(keyPath, privKeyPEM, 0600); err != nil {
|
||||
a.logger.Error("failed to write private key to disk",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key storage failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("private key stored securely",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"permissions", "0600")
|
||||
|
||||
// Validate common name is present
|
||||
if job.CommonName == "" {
|
||||
a.logger.Error("empty common name in CSR job", "job_id", job.ID)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", "empty common name"); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Create CSR with common name and SANs
|
||||
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||
var dnsNames []string
|
||||
var emailAddresses []string
|
||||
for _, san := range job.SANs {
|
||||
if strings.Contains(san, "@") {
|
||||
emailAddresses = append(emailAddresses, san)
|
||||
} else {
|
||||
dnsNames = append(dnsNames, san)
|
||||
}
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: job.CommonName,
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emailAddresses,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create CSR",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR creation failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrDER,
|
||||
}))
|
||||
|
||||
// Step 4: Submit CSR to the control plane (only the public key leaves the agent)
|
||||
a.logger.Info("submitting CSR to control plane",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID)
|
||||
|
||||
submitPath := fmt.Sprintf("/api/v1/agents/%s/csr", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, submitPath, map[string]string{
|
||||
"csr_pem": csrPEM,
|
||||
"certificate_id": job.CertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error("failed to submit CSR",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR submission failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("CSR submission rejected",
|
||||
"job_id", job.ID,
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR rejected: %s", string(body))); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("CSR submitted and signed successfully",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"key_path", keyPath)
|
||||
}
|
||||
|
||||
// executeDeploymentJob executes a deployment job by fetching the certificate and deploying it
|
||||
// to the target system using the appropriate connector (NGINX, F5 BIG-IP, or IIS).
|
||||
//
|
||||
// For agent keygen mode, the private key is read from the local key store (keyDir/certID.key)
|
||||
// rather than fetched from the server. The deployment includes the locally-held key.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Report job as Running
|
||||
// 2. Fetch the certificate PEM from the control plane
|
||||
// 3. Load local private key if it exists (agent keygen mode)
|
||||
// 4. Instantiate the target connector based on target_type from the work response
|
||||
// 5. Call DeployCertificate on the connector
|
||||
// 6. Report job as Completed (or Failed)
|
||||
func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
|
||||
a.logger.Info("executing deployment job",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"target_type", job.TargetType)
|
||||
|
||||
// Report job as running
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Running", ""); err != nil {
|
||||
a.logger.Error("failed to report job running", "error", err)
|
||||
}
|
||||
|
||||
// Fetch the certificate from the control plane
|
||||
certPEM, err := a.fetchCertificate(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to fetch certificate",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("cert fetch failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("certificate fetched for deployment",
|
||||
"job_id", job.ID,
|
||||
"cert_length", len(certPEM))
|
||||
|
||||
// Split PEM into cert and chain (separated by double newline between PEM blocks)
|
||||
certOnly, chainPEM := splitPEMChain(certPEM)
|
||||
|
||||
// Check for locally-stored private key (agent keygen mode)
|
||||
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
|
||||
var keyPEM string
|
||||
keyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to read local private key for deployment",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key read failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
keyPEM = string(keyData)
|
||||
a.logger.Info("loaded local private key for deployment",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath)
|
||||
|
||||
// Deploy to the target using the appropriate connector
|
||||
if job.TargetType != "" {
|
||||
connector, err := a.createTargetConnector(job.TargetType, job.TargetConfig)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create target connector",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("connector init failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
deployReq := target.DeploymentRequest{
|
||||
CertPEM: certOnly,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
TargetConfig: job.TargetConfig,
|
||||
Metadata: map[string]string{
|
||||
"certificate_id": job.CertificateID,
|
||||
"job_id": job.ID,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, deployReq)
|
||||
if err != nil {
|
||||
a.logger.Error("deployment failed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("deployment failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("target connector deployment completed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"success", result.Success,
|
||||
"message", result.Message)
|
||||
|
||||
// If verification is enabled, verify the deployment by probing the live TLS endpoint
|
||||
targetHost, targetPort, err := extractTargetHostAndPort(job.TargetConfig)
|
||||
if err != nil {
|
||||
a.logger.Warn("could not extract target host/port for verification",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
} else {
|
||||
a.verifyAndReportDeployment(ctx, job, targetHost, targetPort, certOnly)
|
||||
}
|
||||
} else {
|
||||
a.logger.Info("no target type specified, skipping connector invocation",
|
||||
"job_id", job.ID)
|
||||
}
|
||||
|
||||
// Report job as completed
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Completed", ""); err != nil {
|
||||
a.logger.Error("failed to report job completed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("deployment job completed", "job_id", job.ID)
|
||||
}
|
||||
|
||||
// createTargetConnector instantiates the appropriate target connector based on type.
|
||||
func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMessage) (target.Connector, error) {
|
||||
switch targetType {
|
||||
case "NGINX":
|
||||
var cfg nginx.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid NGINX config: %w", err)
|
||||
}
|
||||
}
|
||||
return nginx.New(&cfg, a.logger), nil
|
||||
|
||||
case "Apache":
|
||||
var cfg apache.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Apache config: %w", err)
|
||||
}
|
||||
}
|
||||
return apache.New(&cfg, a.logger), nil
|
||||
|
||||
case "HAProxy":
|
||||
var cfg haproxy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid HAProxy config: %w", err)
|
||||
}
|
||||
}
|
||||
return haproxy.New(&cfg, a.logger), nil
|
||||
|
||||
case "F5":
|
||||
var cfg f5.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid F5 config: %w", err)
|
||||
}
|
||||
}
|
||||
conn, err := f5.New(&cfg, a.logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create F5 connector: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
case "IIS":
|
||||
var cfg iis.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid IIS config: %w", err)
|
||||
}
|
||||
}
|
||||
return iis.New(&cfg, a.logger)
|
||||
|
||||
case "Traefik":
|
||||
var cfg traefik.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Traefik config: %w", err)
|
||||
}
|
||||
}
|
||||
return traefik.New(&cfg, a.logger), nil
|
||||
|
||||
case "Caddy":
|
||||
var cfg caddy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Caddy config: %w", err)
|
||||
}
|
||||
}
|
||||
return caddy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Envoy":
|
||||
var cfg envoy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Envoy config: %w", err)
|
||||
}
|
||||
}
|
||||
return envoy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Postfix":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "postfix"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Postfix config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
case "Dovecot":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "dovecot"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Dovecot config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
case "SSH":
|
||||
var cfg sshconn.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid SSH config: %w", err)
|
||||
}
|
||||
}
|
||||
return sshconn.New(&cfg, a.logger)
|
||||
|
||||
case "WinCertStore":
|
||||
var cfg wcs.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid WinCertStore config: %w", err)
|
||||
}
|
||||
}
|
||||
return wcs.New(&cfg, a.logger)
|
||||
|
||||
case "JavaKeystore":
|
||||
var cfg jks.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid JavaKeystore config: %w", err)
|
||||
}
|
||||
}
|
||||
return jks.New(&cfg, a.logger), nil
|
||||
|
||||
case "KubernetesSecrets":
|
||||
var cfg k8s.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid KubernetesSecrets config: %w", err)
|
||||
}
|
||||
}
|
||||
return k8s.New(&cfg, a.logger)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||
}
|
||||
}
|
||||
|
||||
// splitPEMChain splits a PEM chain into the first certificate (cert) and the rest (chain).
|
||||
// The control plane returns the full chain as a single string with PEM blocks concatenated.
|
||||
func splitPEMChain(pemChain string) (string, string) {
|
||||
data := []byte(pemChain)
|
||||
block, rest := pem.Decode(data)
|
||||
if block == nil {
|
||||
return pemChain, ""
|
||||
}
|
||||
cert := string(pem.EncodeToMemory(block))
|
||||
|
||||
// Skip whitespace between cert and chain
|
||||
chain := strings.TrimSpace(string(rest))
|
||||
if chain == "" {
|
||||
return cert, ""
|
||||
}
|
||||
return cert, chain
|
||||
}
|
||||
|
||||
// fetchCertificate retrieves the certificate PEM chain from the control plane.
|
||||
// GET /api/v1/agents/{agentID}/certificates/{certID}
|
||||
func (a *Agent) fetchCertificate(ctx context.Context, certID string) (string, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/certificates/%s", a.config.AgentID, certID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var certResp struct {
|
||||
CertificatePEM string `json:"certificate_pem"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&certResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return certResp.CertificatePEM, nil
|
||||
}
|
||||
|
||||
// reportJobStatus reports the result of a job back to the control plane.
|
||||
// POST /api/v1/agents/{agentID}/jobs/{jobID}/status
|
||||
func (a *Agent) reportJobStatus(ctx context.Context, jobID string, status string, errorMsg string) error {
|
||||
@@ -430,6 +935,239 @@ func (a *Agent) makeRequest(ctx context.Context, method, path string, body inter
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// runDiscoveryScan walks configured directories, parses certificate files, and reports
|
||||
// discovered certificates to the control plane.
|
||||
// Supports PEM and DER encoded X.509 certificates.
|
||||
func (a *Agent) runDiscoveryScan(ctx context.Context) {
|
||||
a.logger.Info("starting filesystem certificate discovery scan",
|
||||
"directories", a.config.DiscoveryDirs)
|
||||
|
||||
startTime := time.Now()
|
||||
var certs []discoveredCertEntry
|
||||
var scanErrors []string
|
||||
|
||||
for _, dir := range a.config.DiscoveryDirs {
|
||||
a.logger.Debug("scanning directory", "path", dir)
|
||||
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("walk error at %s: %v", path, err))
|
||||
return nil // continue walking
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files larger than 1MB (unlikely to be a certificate)
|
||||
if info.Size() > 1*1024*1024 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".pem", ".crt", ".cer", ".cert":
|
||||
found := a.parsePEMFile(path)
|
||||
certs = append(certs, found...)
|
||||
case ".der":
|
||||
if entry, err := a.parseDERFile(path); err == nil {
|
||||
certs = append(certs, entry)
|
||||
} else {
|
||||
a.logger.Debug("skipping non-cert DER file", "path", path, "error", err)
|
||||
}
|
||||
default:
|
||||
// Try PEM parsing for extensionless files or unknown extensions
|
||||
if ext == "" || ext == ".key" {
|
||||
return nil // skip key files and extensionless
|
||||
}
|
||||
found := a.parsePEMFile(path)
|
||||
if len(found) > 0 {
|
||||
certs = append(certs, found...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("failed to walk %s: %v", dir, err))
|
||||
}
|
||||
}
|
||||
|
||||
scanDuration := time.Since(startTime)
|
||||
a.logger.Info("discovery scan completed",
|
||||
"certificates_found", len(certs),
|
||||
"errors", len(scanErrors),
|
||||
"duration_ms", scanDuration.Milliseconds())
|
||||
|
||||
if len(certs) == 0 && len(scanErrors) == 0 {
|
||||
a.logger.Debug("no certificates found and no errors, skipping report")
|
||||
return
|
||||
}
|
||||
|
||||
// Build report payload
|
||||
entries := make([]map[string]interface{}, len(certs))
|
||||
for i, c := range certs {
|
||||
entries[i] = map[string]interface{}{
|
||||
"fingerprint_sha256": c.FingerprintSHA256,
|
||||
"common_name": c.CommonName,
|
||||
"sans": c.SANs,
|
||||
"serial_number": c.SerialNumber,
|
||||
"issuer_dn": c.IssuerDN,
|
||||
"subject_dn": c.SubjectDN,
|
||||
"not_before": c.NotBefore,
|
||||
"not_after": c.NotAfter,
|
||||
"key_algorithm": c.KeyAlgorithm,
|
||||
"key_size": c.KeySize,
|
||||
"is_ca": c.IsCA,
|
||||
"pem_data": c.PEMData,
|
||||
"source_path": c.SourcePath,
|
||||
"source_format": c.SourceFormat,
|
||||
}
|
||||
}
|
||||
|
||||
report := map[string]interface{}{
|
||||
"agent_id": a.config.AgentID,
|
||||
"directories": a.config.DiscoveryDirs,
|
||||
"certificates": entries,
|
||||
"errors": scanErrors,
|
||||
"scan_duration_ms": int(scanDuration.Milliseconds()),
|
||||
}
|
||||
|
||||
// Submit to control plane
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/discoveries", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, report)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to submit discovery report", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("discovery report rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("discovery report submitted successfully",
|
||||
"certificates", len(certs),
|
||||
"errors", len(scanErrors))
|
||||
}
|
||||
|
||||
// discoveredCertEntry holds parsed certificate metadata for reporting.
|
||||
type discoveredCertEntry struct {
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
SubjectDN string `json:"subject_dn"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
PEMData string `json:"pem_data"`
|
||||
SourcePath string `json:"source_path"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
}
|
||||
|
||||
// parsePEMFile reads a file and extracts all X.509 certificates from PEM blocks.
|
||||
func (a *Agent) parsePEMFile(path string) []discoveredCertEntry {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to read file", "path", path, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []discoveredCertEntry
|
||||
rest := data
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to parse certificate in PEM", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
pemStr := string(pem.EncodeToMemory(block))
|
||||
entries = append(entries, certToEntry(cert, path, "PEM", pemStr))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// parseDERFile reads a DER-encoded certificate file.
|
||||
func (a *Agent) parseDERFile(path string) (discoveredCertEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("read failed: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(data)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("parse failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert to PEM for storage
|
||||
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: data}))
|
||||
return certToEntry(cert, path, "DER", pemStr), nil
|
||||
}
|
||||
|
||||
// certToEntry converts a parsed x509.Certificate into a discoveredCertEntry.
|
||||
func certToEntry(cert *x509.Certificate, path, format, pemData string) discoveredCertEntry {
|
||||
// Compute SHA-256 fingerprint
|
||||
fingerprint := fmt.Sprintf("%x", sha256Sum(cert.Raw))
|
||||
|
||||
// Determine key algorithm and size
|
||||
keyAlg, keySize := certKeyInfo(cert)
|
||||
|
||||
return discoveredCertEntry{
|
||||
FingerprintSHA256: fingerprint,
|
||||
CommonName: cert.Subject.CommonName,
|
||||
SANs: cert.DNSNames,
|
||||
SerialNumber: cert.SerialNumber.Text(16),
|
||||
IssuerDN: cert.Issuer.String(),
|
||||
SubjectDN: cert.Subject.String(),
|
||||
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339),
|
||||
NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
|
||||
KeyAlgorithm: keyAlg,
|
||||
KeySize: keySize,
|
||||
IsCA: cert.IsCA,
|
||||
PEMData: pemData,
|
||||
SourcePath: path,
|
||||
SourceFormat: format,
|
||||
}
|
||||
}
|
||||
|
||||
// sha256Sum returns the SHA-256 hash of data.
|
||||
func sha256Sum(data []byte) [32]byte {
|
||||
return sha256.Sum256(data)
|
||||
}
|
||||
|
||||
// certKeyInfo extracts key algorithm name and size from a certificate.
|
||||
func certKeyInfo(cert *x509.Certificate) (string, int) {
|
||||
switch pub := cert.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return "ECDSA", pub.Curve.Params().BitSize
|
||||
case *rsa.PublicKey:
|
||||
return "RSA", pub.N.BitLen()
|
||||
default:
|
||||
switch cert.PublicKeyAlgorithm {
|
||||
case x509.Ed25519:
|
||||
return "Ed25519", 256
|
||||
default:
|
||||
return cert.PublicKeyAlgorithm.String(), 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse command-line flags (with env var fallbacks for Docker deployment)
|
||||
serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "https://localhost:8443"), "Control plane server URL (must be https://)")
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 12 (2026-05-14): extracted from
|
||||
// cmd/agent/main.go via the Option B sibling-file pattern (mirrors
|
||||
// the Sprint 8 cmd/server cut). Package stays `main`; all methods
|
||||
// are still defined on *Agent so every call site continues to
|
||||
// resolve through Go's same-package method-set without any
|
||||
// import-path change.
|
||||
//
|
||||
// This file holds the WORK-POLLING entry point + CSR-job execution
|
||||
// — the inbound side of the agent's pull-only deployment model
|
||||
// (per CLAUDE.md "Pull-only deployment model" architecture
|
||||
// decision):
|
||||
//
|
||||
// - pollForWork: queries GET /api/v1/agents/{id}/work each tick;
|
||||
// dispatches each returned JobItem to the appropriate
|
||||
// executor (CSR vs deployment).
|
||||
// - executeCSRJob: handles AwaitingCSR jobs by generating an
|
||||
// ECDSA P-256 key locally, persisting it to keyDir/<certID>.key
|
||||
// with 0600 permissions (key NEVER leaves the agent — see
|
||||
// CLAUDE.md "Agent-based key management"), creating the CSR,
|
||||
// and POSTing it to the control plane for signing.
|
||||
//
|
||||
// The deployment-job executor lives in deploy.go alongside the
|
||||
// target connector factory + deploy-only helpers (splitPEMChain,
|
||||
// fetchCertificate). The discovery scan lives in discovery.go.
|
||||
|
||||
// pollForWork queries the control plane for actionable jobs and processes them.
|
||||
// Jobs may be deployment jobs (Pending) or CSR jobs (AwaitingCSR).
|
||||
// GET /api/v1/agents/{agentID}/work
|
||||
func (a *Agent) pollForWork(ctx context.Context) {
|
||||
a.logger.Debug("polling for work", "agent_id", a.config.AgentID)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/work", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
a.logger.Error("work poll failed", "error", err)
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// I-004: same terminal-retirement handling as sendHeartbeat. Work-poll is the
|
||||
// other hot path that can observe an agent's soft-retirement; if the
|
||||
// heartbeat tick happens to fire after a work-poll tick within the same
|
||||
// retirement window, this branch catches it first. markRetired's sync.Once
|
||||
// guards idempotency so racing both paths in the same tick only closes the
|
||||
// signal channel once. No consecutiveFailures increment — retirement is
|
||||
// not a transient failure.
|
||||
if resp.StatusCode == http.StatusGone {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.markRetired("work_poll", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("work poll rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
var workResp WorkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&workResp); err != nil {
|
||||
a.logger.Error("failed to decode work response", "error", err)
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
a.consecutiveFailures = 0
|
||||
|
||||
if workResp.Count == 0 {
|
||||
a.logger.Debug("no pending work")
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("received work", "job_count", workResp.Count)
|
||||
|
||||
// Process each job based on type and status
|
||||
for _, job := range workResp.Jobs {
|
||||
switch {
|
||||
case job.Status == "AwaitingCSR":
|
||||
// Agent keygen mode: generate key locally, create CSR, submit to server
|
||||
a.executeCSRJob(ctx, job)
|
||||
case job.Type == "Deployment":
|
||||
a.executeDeploymentJob(ctx, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeCSRJob handles an AwaitingCSR job: generates a private key locally, creates a CSR,
|
||||
// and submits it to the control plane for signing. The private key is stored on the local
|
||||
// filesystem with 0600 permissions and NEVER sent to the server.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Generate ECDSA P-256 key pair
|
||||
// 2. Store private key to disk (keyDir/certID.key) with 0600 permissions
|
||||
// 3. Create CSR with common name and SANs from work response
|
||||
// 4. Submit CSR to control plane via POST /agents/{id}/csr
|
||||
// 5. Server signs the CSR and creates a cert version + deployment jobs
|
||||
func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
||||
a.logger.Info("executing CSR job (agent-side key generation)",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"common_name", job.CommonName)
|
||||
|
||||
// Step 1: Generate ECDSA P-256 key pair
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to generate private key",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key generation failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("generated ECDSA P-256 key pair locally",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID)
|
||||
|
||||
// Step 2: Store private key to disk with secure permissions.
|
||||
//
|
||||
// Bundle-9 / Audit L-002 + L-003: marshal+write through helpers that
|
||||
// (a) zeroize the in-heap DER buffer immediately after the PEM block is
|
||||
// constructed so the private scalar's exposure window is bounded by
|
||||
// this function call, and (b) assert the key directory is mode 0700
|
||||
// before any write touches disk. Also defer-clear the PEM buffer for
|
||||
// the same reason — the encoded key isn't sensitive in transit (it's
|
||||
// going to disk) but lingers on the heap if we don't.
|
||||
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
|
||||
if err := ensureAgentKeyDirSecure(filepath.Dir(keyPath)); err != nil {
|
||||
a.logger.Error("agent key dir hardening failed", "job_id", job.ID, "error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key dir hardening failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
var privKeyPEM []byte
|
||||
if marshalErr := marshalAgentKeyAndZeroize(privKey, func(der []byte) error {
|
||||
privKeyPEM = pem.EncodeToMemory(&pem.Block{
|
||||
Type: "EC PRIVATE KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
return nil
|
||||
}); marshalErr != nil {
|
||||
a.logger.Error("failed to marshal private key",
|
||||
"job_id", job.ID,
|
||||
"error", marshalErr)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key marshal failed: %v", marshalErr)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer clear(privKeyPEM)
|
||||
|
||||
if err := os.WriteFile(keyPath, privKeyPEM, 0600); err != nil {
|
||||
a.logger.Error("failed to write private key to disk",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key storage failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("private key stored securely",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"permissions", "0600")
|
||||
|
||||
// Validate common name is present
|
||||
if job.CommonName == "" {
|
||||
a.logger.Error("empty common name in CSR job", "job_id", job.ID)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", "empty common name"); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Create CSR with common name and SANs
|
||||
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||
var dnsNames []string
|
||||
var emailAddresses []string
|
||||
for _, san := range job.SANs {
|
||||
if strings.Contains(san, "@") {
|
||||
emailAddresses = append(emailAddresses, san)
|
||||
} else {
|
||||
dnsNames = append(dnsNames, san)
|
||||
}
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: job.CommonName,
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emailAddresses,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create CSR",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR creation failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrDER,
|
||||
}))
|
||||
|
||||
// Step 4: Submit CSR to the control plane (only the public key leaves the agent)
|
||||
a.logger.Info("submitting CSR to control plane",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID)
|
||||
|
||||
submitPath := fmt.Sprintf("/api/v1/agents/%s/csr", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, submitPath, map[string]string{
|
||||
"csr_pem": csrPEM,
|
||||
"certificate_id": job.CertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error("failed to submit CSR",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR submission failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("CSR submission rejected",
|
||||
"job_id", job.ID,
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR rejected: %s", string(body))); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("CSR submitted and signed successfully",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"key_path", keyPath)
|
||||
}
|
||||
+9
-12
@@ -1,6 +1,3 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -78,8 +75,8 @@ func verifyDeployment(
|
||||
// calls, issuer connector communication, or any operation that trusts the
|
||||
// certificate. The verification result compares SHA-256 fingerprints only.
|
||||
// See TICKET-016 for full security audit rationale.
|
||||
InsecureSkipVerify: true, //nolint:gosec // verification probe; documented above + docs/tls.md L-001 table
|
||||
ServerName: targetHost, // For SNI
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: targetHost, // For SNI
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s: %w", address, err)
|
||||
@@ -164,11 +161,11 @@ func (a *Agent) reportVerificationResult(
|
||||
|
||||
// Build the request payload
|
||||
payload := map[string]interface{}{
|
||||
"target_id": targetID,
|
||||
"expected_fingerprint": result.ExpectedFingerprint,
|
||||
"actual_fingerprint": result.ActualFingerprint,
|
||||
"verified": result.Verified,
|
||||
"error": result.Error,
|
||||
"target_id": targetID,
|
||||
"expected_fingerprint": result.ExpectedFingerprint,
|
||||
"actual_fingerprint": result.ActualFingerprint,
|
||||
"verified": result.Verified,
|
||||
"error": result.Error,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
@@ -250,7 +247,7 @@ func (a *Agent) verifyAndReportDeployment(
|
||||
) {
|
||||
// Perform verification with configured timeout and delay
|
||||
result, err := verifyDeployment(ctx, targetHost, targetPort, certPEM,
|
||||
2*time.Second, // delay before probing
|
||||
2*time.Second, // delay before probing
|
||||
10*time.Second, // timeout for TLS connection
|
||||
a.logger)
|
||||
|
||||
@@ -264,7 +261,7 @@ func (a *Agent) verifyAndReportDeployment(
|
||||
}
|
||||
// Probe failure: report error but continue
|
||||
result = &VerificationResult{
|
||||
Error: err.Error(),
|
||||
Error: err.Error(),
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +114,9 @@ func TestExtractTargetHostAndPort_InvalidJSON(t *testing.T) {
|
||||
|
||||
func TestExtractTargetHostAndPort_AlternativeFieldNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]interface{}
|
||||
expected string
|
||||
name string
|
||||
config map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{"host", map[string]interface{}{"host": "host1.com"}, "host1.com"},
|
||||
{"hostname", map[string]interface{}{"hostname": "host2.com"}, "host2.com"},
|
||||
@@ -391,13 +391,7 @@ func TestVerifyDeployment_FingerprintComparison(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): defensive skip — httptest.NewTLSServer
|
||||
// always provisions a self-signed certificate at construction time, so this
|
||||
// branch is currently unreachable in practice. Kept as a guard against
|
||||
// future test-server constructions that swap in a custom *tls.Config with
|
||||
// no Certificates slice (the path below dereferences server.TLS.Certificates[0]
|
||||
// and would panic). The skip preserves the assertion logic for the normal
|
||||
// fixture path; if it ever fires, it's a fixture bug, not a product bug.
|
||||
// Get the server's TLS certificate from TLS config
|
||||
if len(server.TLS.Certificates) == 0 {
|
||||
t.Skip("no TLS certificates configured on test server")
|
||||
}
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/cli"
|
||||
)
|
||||
|
||||
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
|
||||
//
|
||||
// The existing `main_test.go` only covered `validateHTTPSScheme`. This file
|
||||
// pins every dispatch arm in `handleCerts`, `handleAgents`, `handleJobs`,
|
||||
// `handleImport`, `handleStatus` — both the "missing arg" usage prints and
|
||||
// the happy-path delegation to `*cli.Client`.
|
||||
//
|
||||
// Strategy: spin up an `httptest.Server` mocking the relevant API routes so
|
||||
// the client can exercise its end-to-end code path without a live server.
|
||||
// For arms that print usage and return without calling the client, we pass
|
||||
// a freshly-constructed client (still no network call — the client method
|
||||
// is never invoked).
|
||||
|
||||
// newDispatchTestClient returns a `*cli.Client` pointed at the given test
|
||||
// server. Calls `t.Fatal` on construction error.
|
||||
func newDispatchTestClient(t *testing.T, server *httptest.Server) *cli.Client {
|
||||
t.Helper()
|
||||
// Configure the client with `insecure=true` because httptest.Server's
|
||||
// self-signed TLS cert won't chain to a system root.
|
||||
c, err := cli.NewClient(server.URL, "test-key", "json", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// stubServer returns an httptest.Server (TLS) that responds with the given
|
||||
// JSON body and status code for any request. Tests that want to assert on
|
||||
// the request shape can wrap it in a more specific handler.
|
||||
func stubServer(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleCerts dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleCerts_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{}); err != nil {
|
||||
t.Errorf("handleCerts({}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"frobnicate"}); err != nil {
|
||||
t.Errorf("handleCerts({frobnicate}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"get"}); err != nil {
|
||||
t.Errorf("handleCerts({get}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_RenewWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"renew"}); err != nil {
|
||||
t.Errorf("handleCerts({renew}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_RevokeWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"revoke"}); err != nil {
|
||||
t.Errorf("handleCerts({revoke}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_List_HitsClientPath(t *testing.T) {
|
||||
// Asserts dispatch-path: handleCerts → c.ListCertificates → GET /api/v1/certificates.
|
||||
var hits int
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
if r.Method != "GET" || !strings.HasPrefix(r.URL.Path, "/api/v1/certificates") {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"list"}); err != nil {
|
||||
t.Errorf("handleCerts({list}): err=%v", err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Errorf("expected 1 server hit, got %d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_Get_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"id":"mc-x","name":"x"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"get", "mc-x"}); err != nil {
|
||||
t.Errorf("handleCerts({get, mc-x}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/api/v1/certificates/mc-x") {
|
||||
t.Errorf("expected GET on /api/v1/certificates/mc-x, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_Renew_HitsClientPath(t *testing.T) {
|
||||
var lastPath, lastMethod string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
lastMethod = r.Method
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"job_id":"job-1","status":"ok"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"renew", "mc-x"}); err != nil {
|
||||
t.Errorf("handleCerts({renew, mc-x}): err=%v", err)
|
||||
}
|
||||
if lastMethod != "POST" || !strings.Contains(lastPath, "/renew") {
|
||||
t.Errorf("expected POST .../renew, got %s %s", lastMethod, lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_Revoke_HitsClientPath(t *testing.T) {
|
||||
var lastPath, lastMethod, lastBody string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
lastMethod = r.Method
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := r.Body.Read(buf)
|
||||
lastBody = string(buf[:n])
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"status":"revoked"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
// 2026-05-05 parity-defaults-cleanup (P3-2): reason must be a canonical
|
||||
// RFC 5280 §5.3.1 code (camelCase or snake_case both accepted; this
|
||||
// test asserts the snake_case path normalises to the camelCase wire
|
||||
// format that the local issuer + ACME server expect).
|
||||
if err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "key_compromise"}); err != nil {
|
||||
t.Errorf("handleCerts({revoke ...}): err=%v", err)
|
||||
}
|
||||
if lastMethod != "POST" || !strings.Contains(lastPath, "/revoke") {
|
||||
t.Errorf("expected POST .../revoke, got %s %s", lastMethod, lastPath)
|
||||
}
|
||||
if !strings.Contains(lastBody, "keyCompromise") {
|
||||
t.Errorf("expected normalised reason 'keyCompromise' in body, got %q", lastBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCerts_Revoke_RequiresReason pins the 2026-05-05 parity-defaults-
|
||||
// cleanup (P3-2, Option A) strict-reason contract: empty --reason is a
|
||||
// fatal error, not a silent fallback to "unspecified".
|
||||
func TestHandleCerts_Revoke_RequiresReason(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
err := handleCerts(c, []string{"revoke", "mc-x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --reason is omitted; got nil (regression on P3-2 strict path)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reason") {
|
||||
t.Errorf("expected error to mention 'reason', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCerts_Revoke_RejectsUnknownReason pins that off-RFC reason
|
||||
// codes are rejected at the CLI dispatch layer (P3-2 anti-typo guard).
|
||||
func TestHandleCerts_Revoke_RejectsUnknownReason(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "compromise"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical reason; got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "compromise") {
|
||||
t.Errorf("expected error to echo bad reason 'compromise', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCerts_Renew_ForceFlag pins the 2026-05-05 parity-defaults-
|
||||
// cleanup (P3-1) wire: --force on the renew dispatch sends ?force=true.
|
||||
// CLI convention: ID is positional and precedes the flags (matches
|
||||
// `agents retire <id> [--force]`), so the flag MUST come after the ID.
|
||||
func TestHandleCerts_Renew_ForceFlag(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantQuery string
|
||||
}{
|
||||
{"no-force", []string{"renew", "mc-x"}, ""},
|
||||
{"force-after-id", []string{"renew", "mc-x", "--force"}, "force=true"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var lastQuery string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastQuery = r.URL.RawQuery
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, tc.args); err != nil {
|
||||
t.Fatalf("handleCerts: %v", err)
|
||||
}
|
||||
if lastQuery != tc.wantQuery {
|
||||
t.Errorf("query: got %q want %q", lastQuery, tc.wantQuery)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_BulkRevoke_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"total_matched":0,"total_revoked":0,"total_skipped":0,"total_failed":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"bulk-revoke", "--reason", "test"}); err != nil {
|
||||
t.Errorf("handleCerts({bulk-revoke ...}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/bulk-revoke") {
|
||||
t.Errorf("expected /bulk-revoke path, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleAgents dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleAgents_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{}); err != nil {
|
||||
t.Errorf("handleAgents({}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"frobnicate"}); err != nil {
|
||||
t.Errorf("handleAgents({frobnicate}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"get"}); err != nil {
|
||||
t.Errorf("handleAgents({get}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_RetireWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"retire"}); err != nil {
|
||||
t.Errorf("handleAgents({retire}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_List_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"list"}); err != nil {
|
||||
t.Errorf("handleAgents({list}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/api/v1/agents") {
|
||||
t.Errorf("expected /api/v1/agents path, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_ListRetired_HitsRetiredEndpoint(t *testing.T) {
|
||||
// I-004: --retired flag splits to a separate /agents/retired endpoint.
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"list", "--retired"}); err != nil {
|
||||
t.Errorf("handleAgents({list --retired}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/agents/retired") {
|
||||
t.Errorf("expected --retired to hit /agents/retired, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_Get_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"id":"ag-x","status":"online"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"get", "ag-x"}); err != nil {
|
||||
t.Errorf("handleAgents({get, ag-x}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/agents/ag-x") {
|
||||
t.Errorf("expected /agents/ag-x, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleJobs dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleJobs_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{}); err != nil {
|
||||
t.Errorf("handleJobs({}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"frobnicate"}); err != nil {
|
||||
t.Errorf("handleJobs({frobnicate}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"get"}); err != nil {
|
||||
t.Errorf("handleJobs({get}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_CancelWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"cancel"}); err != nil {
|
||||
t.Errorf("handleJobs({cancel}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_List_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"list"}); err != nil {
|
||||
t.Errorf("handleJobs({list}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/api/v1/jobs") {
|
||||
t.Errorf("expected /api/v1/jobs path, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_Get_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"id":"job-x"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"get", "job-x"}); err != nil {
|
||||
t.Errorf("handleJobs({get, job-x}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/jobs/job-x") {
|
||||
t.Errorf("expected /jobs/job-x, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_Cancel_HitsClientPath(t *testing.T) {
|
||||
var lastPath, lastMethod string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
lastMethod = r.Method
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"status":"cancelled"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"cancel", "job-x"}); err != nil {
|
||||
t.Errorf("handleJobs({cancel, job-x}): err=%v", err)
|
||||
}
|
||||
if lastMethod != "POST" || !strings.Contains(lastPath, "/cancel") {
|
||||
t.Errorf("expected POST .../cancel, got %s %s", lastMethod, lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleImport / handleStatus dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleImport_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleImport(c, []string{}); err != nil {
|
||||
t.Errorf("handleImport({}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatus_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
// GetStatus expects {"status":..., "stats":...} or similar.
|
||||
// Provide a minimal valid JSON object.
|
||||
_, _ = w.Write([]byte(`{"status":"healthy","version":"v2.X","db":"connected"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleStatus(c); err != nil {
|
||||
// GetStatus's table output may complain about missing fields; we only
|
||||
// care that the dispatch arm fired and the request reached the server.
|
||||
_ = err
|
||||
}
|
||||
if lastPath == "" {
|
||||
t.Errorf("expected handleStatus to make at least one request")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CLI client TLS sanity (Q.1: confirms NewClient configures TLS correctly).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCliClient_RejectsUntrustedCert_WhenNotInsecure(t *testing.T) {
|
||||
// Without insecure=true, the self-signed httptest cert must fail TLS
|
||||
// verification. This pins the security default.
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c, err := cli.NewClient(srv.URL, "k", "json", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
// Try a status call — should error out with a TLS verification failure,
|
||||
// not silently succeed.
|
||||
if err := c.GetStatus(); err == nil {
|
||||
t.Errorf("expected TLS verification error against self-signed cert; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCliClient_ParsesJSONResponse asserts the do() path's JSON unmarshalling
|
||||
// succeeds end-to-end (one of the more error-prone paths in the client).
|
||||
func TestCliClient_ParsesJSONResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
body := map[string]interface{}{
|
||||
"data": []map[string]interface{}{{"id": "mc-1", "name": "site-1"}},
|
||||
"total": 1,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c, err := cli.NewClient(srv.URL, "k", "json", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
if err := c.ListCertificates(nil); err != nil {
|
||||
t.Errorf("ListCertificates: err=%v", err)
|
||||
}
|
||||
}
|
||||
+8
-220
@@ -1,6 +1,3 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,7 +7,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/cli"
|
||||
"github.com/shankar0123/certctl/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -44,14 +41,6 @@ Commands:
|
||||
Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id
|
||||
Optional: --name-template (default {cn}), --environment (default imported)
|
||||
|
||||
est cacerts --profile <p> EST GET cacerts (RFC 7030 §4.1)
|
||||
est csrattrs --profile <p> EST GET csrattrs (RFC 7030 §4.5)
|
||||
est enroll --profile <p> --csr <path> EST POST simpleenroll (RFC 7030 §4.2)
|
||||
est reenroll --profile <p> --csr <path> EST POST simplereenroll (RFC 7030 §4.2.2)
|
||||
est serverkeygen --profile <p> --csr <path> --out <prefix>
|
||||
EST POST serverkeygen (RFC 7030 §4.4)
|
||||
est test --profile <p> Smoke-test cacerts + csrattrs
|
||||
|
||||
status Show server health + summary stats
|
||||
version Show CLI version
|
||||
|
||||
@@ -110,12 +99,8 @@ Examples:
|
||||
err = handleJobs(client, cmdArgs)
|
||||
case "import":
|
||||
err = handleImport(client, cmdArgs)
|
||||
case "est":
|
||||
err = handleEST(client, cmdArgs)
|
||||
case "status":
|
||||
err = handleStatus(client)
|
||||
case "auth":
|
||||
err = handleAuth(client, cmdArgs)
|
||||
case "version":
|
||||
fmt.Println("certctl-cli version 0.1.0")
|
||||
default:
|
||||
@@ -149,70 +134,22 @@ func handleCerts(client *cli.Client, args []string) error {
|
||||
}
|
||||
return client.GetCertificate(subArgs[0])
|
||||
case "renew":
|
||||
// 2026-05-05 parity-defaults-cleanup (P3-1): expose --force as an
|
||||
// explicit operator flag instead of the historical hardcoded
|
||||
// `force=false` body field. force=true overrides the server-side
|
||||
// RenewalInProgress block — used to recover stuck in-flight
|
||||
// renewals. Archived/Expired remain terminal regardless.
|
||||
//
|
||||
// CLI convention: `certs renew <id> [--force]` — the ID is a
|
||||
// positional arg that precedes the flags. Mirrors `agents retire
|
||||
// <id>`'s pattern (Go's flag package stops at the first non-flag
|
||||
// token, so we pull subArgs[0] as the ID and hand subArgs[1:] to
|
||||
// the flag parser).
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs renew <id> [--force]\n")
|
||||
fmt.Fprintf(os.Stderr, "usage: certs renew <id>\n")
|
||||
return nil
|
||||
}
|
||||
id := subArgs[0]
|
||||
fs := flag.NewFlagSet("certs renew", flag.ContinueOnError)
|
||||
force := fs.Bool("force", false, "Force renewal even when the cert is currently in RenewalInProgress (clears stuck in-flight renewals; does NOT override Archived/Expired terminal states)")
|
||||
if err := fs.Parse(subArgs[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.RenewCertificate(id, *force)
|
||||
return client.RenewCertificate(subArgs[0])
|
||||
case "revoke":
|
||||
// 2026-05-05 parity-defaults-cleanup (P3-2, Option A): --reason is
|
||||
// strictly required. Empty reason refuses to dispatch and prints
|
||||
// the RFC 5280 §5.3.1 reason-code menu so operators pick a real
|
||||
// value. The pre-2026-05-05 silent fallback to "unspecified"
|
||||
// defeated compliance reporting (PCI-DSS §3.6, HIPAA §164.312)
|
||||
// because every revocation looked the same in the audit trail.
|
||||
//
|
||||
// CLI convention: `certs revoke <id> --reason <reason>` — same
|
||||
// ID-first ordering as `certs renew`.
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> --reason <reason>\n")
|
||||
fmt.Fprintf(os.Stderr, "\nValid RFC 5280 §5.3.1 reasons:\n")
|
||||
for _, r := range cli.ValidRevokeReasons() {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", r)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> [--reason <reason>]\n")
|
||||
return nil
|
||||
}
|
||||
id := subArgs[0]
|
||||
fs := flag.NewFlagSet("certs revoke", flag.ContinueOnError)
|
||||
reason := fs.String("reason", "", "RFC 5280 revocation reason (required). Valid values: keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL, privilegeWithdrawn, aaCompromise, unspecified")
|
||||
if err := fs.Parse(subArgs[1:]); err != nil {
|
||||
return err
|
||||
reason := "unspecified"
|
||||
if len(subArgs) > 2 && subArgs[1] == "--reason" {
|
||||
reason = subArgs[2]
|
||||
}
|
||||
if *reason == "" {
|
||||
fmt.Fprintf(os.Stderr, "error: --reason is required (no silent fallback to 'unspecified' — pick a real RFC 5280 §5.3.1 code).\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Valid reasons:\n")
|
||||
for _, r := range cli.ValidRevokeReasons() {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", r)
|
||||
}
|
||||
return fmt.Errorf("--reason is required")
|
||||
}
|
||||
canonical, ok := cli.NormalizeRevokeReason(*reason)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "error: %q is not a valid RFC 5280 §5.3.1 reason code.\n\n", *reason)
|
||||
fmt.Fprintf(os.Stderr, "Valid reasons (camelCase or snake_case both accepted):\n")
|
||||
for _, r := range cli.ValidRevokeReasons() {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", r)
|
||||
}
|
||||
return fmt.Errorf("invalid --reason: %q", *reason)
|
||||
}
|
||||
return client.RevokeCertificate(id, canonical)
|
||||
return client.RevokeCertificate(id, reason)
|
||||
case "bulk-revoke":
|
||||
return client.BulkRevokeCertificates(subArgs)
|
||||
default:
|
||||
@@ -318,35 +255,6 @@ func handleStatus(client *cli.Client) error {
|
||||
return client.GetStatus()
|
||||
}
|
||||
|
||||
// handleEST dispatches the `est` subcommands. Mirrors the existing
|
||||
// handleCerts / handleAgents pattern verbatim. EST RFC 7030 hardening
|
||||
// master bundle Phase 9.1.
|
||||
func handleEST(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: est <cacerts|csrattrs|enroll|reenroll|serverkeygen|test> [options]\n")
|
||||
return nil
|
||||
}
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
switch subcommand {
|
||||
case "cacerts":
|
||||
return client.EstCacerts(subArgs)
|
||||
case "csrattrs":
|
||||
return client.EstCsrattrs(subArgs)
|
||||
case "enroll":
|
||||
return client.EstEnroll(subArgs)
|
||||
case "reenroll":
|
||||
return client.EstReEnroll(subArgs)
|
||||
case "serverkeygen":
|
||||
return client.EstServerKeygen(subArgs)
|
||||
case "test":
|
||||
return client.EstTest(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: est %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateHTTPSScheme rejects plaintext and empty-scheme server URLs at
|
||||
// startup so operators get a fail-loud diagnostic before any network call,
|
||||
// not a TCP-refused or TLS-handshake-error downstream. See docs/upgrade-to-tls.md.
|
||||
@@ -369,123 +277,3 @@ func validateHTTPSScheme(serverURL string) error {
|
||||
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuth dispatches the `certctl-cli auth ...` subcommand tree.
|
||||
// Bundle 1 Phase 5: ships read + grant operations against the
|
||||
// /api/v1/auth/* surface introduced in Phase 4. Mutations like role
|
||||
// create / update / delete can be added in a Phase 5.5 follow-up; this
|
||||
// commit ships the operator-facing subset most useful for migration
|
||||
// and day-2 scope-down (`auth keys list` + `auth keys assign` +
|
||||
// `auth me`).
|
||||
func handleAuth(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth <roles|permissions|keys|me> [...]\n")
|
||||
return nil
|
||||
}
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "roles":
|
||||
return handleAuthRoles(client, subArgs)
|
||||
case "permissions":
|
||||
return handleAuthPermissions(client, subArgs)
|
||||
case "keys":
|
||||
return handleAuthKeys(client, subArgs)
|
||||
case "me":
|
||||
return client.AuthMe()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown auth subcommand: %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthRoles(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles <list|get> [id]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListRoles()
|
||||
case "get":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthGetRole(args[1])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown roles subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthPermissions(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 || args[0] != "list" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth permissions list\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthListPermissions()
|
||||
}
|
||||
|
||||
func handleAuthKeys(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys <list|assign|revoke|scope-down> [...]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListKeys()
|
||||
case "assign":
|
||||
// auth keys assign <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys assign <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthAssignRoleToKey(args[1], args[3])
|
||||
case "revoke":
|
||||
// auth keys revoke <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys revoke <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthRevokeRoleFromKey(args[1], args[3])
|
||||
case "scope-down":
|
||||
// Bundle 1 Phase 7 — interactive (default), --non-interactive
|
||||
// <config.json>, or --suggest [--apply].
|
||||
return handleAuthKeysScopeDown(client, args[1:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuthKeysScopeDown dispatches the three scope-down modes:
|
||||
//
|
||||
// auth keys scope-down → interactive
|
||||
// auth keys scope-down --non-interactive <config> → JSON-driven
|
||||
// auth keys scope-down --suggest [--apply] → audit-driven suggestions
|
||||
func handleAuthKeysScopeDown(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return client.AuthScopeDown()
|
||||
}
|
||||
switch args[0] {
|
||||
case "--non-interactive":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys scope-down --non-interactive <config.json>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthScopeDownNonInteractive(args[1])
|
||||
case "--suggest":
|
||||
apply := false
|
||||
for _, a := range args[1:] {
|
||||
if a == "--apply" {
|
||||
apply = true
|
||||
}
|
||||
}
|
||||
return client.AuthScopeDownSuggest(apply)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown scope-down flag: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@ func TestValidateHTTPSScheme(t *testing.T) {
|
||||
wantErrSub: "plaintext http://",
|
||||
},
|
||||
{
|
||||
name: "bare host missing scheme rejected",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
name: "bare host missing scheme rejected",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
// url.Parse treats "localhost:8443" as scheme=localhost, opaque=8443
|
||||
// — exercises the default arm (unsupported scheme) rather than the
|
||||
// empty-scheme arm. Both are fail-closed, which is what we care about.
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -14,7 +11,7 @@ import (
|
||||
|
||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/mcp"
|
||||
"github.com/shankar0123/certctl/internal/mcp"
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags.
|
||||
|
||||
@@ -47,9 +47,9 @@ func TestValidateHTTPSScheme(t *testing.T) {
|
||||
wantErrSub: "plaintext http://",
|
||||
},
|
||||
{
|
||||
name: "bare host missing scheme rejected",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
name: "bare host missing scheme rejected",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
// url.Parse treats "localhost:8443" as scheme=localhost, opaque=8443
|
||||
// — exercises the default arm (unsupported scheme) rather than the
|
||||
// empty-scheme arm. Both are fail-closed, which is what we care about.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// assembleNamedAPIKeys translates the operator's CERTCTL_API_KEYS_NAMED
|
||||
// env-var (preferred) or CERTCTL_AUTH_SECRET (legacy) into the
|
||||
// auth.NamedAPIKey slice the rest of the boot path consumes.
|
||||
//
|
||||
// Authentication unification (M-002): every authenticated request now
|
||||
// carries a named actor in the request context so audit events record
|
||||
// the real key identity instead of the hardcoded "api-key-user"
|
||||
// string. Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For
|
||||
// backward compatibility CERTCTL_AUTH_SECRET is synthesized into
|
||||
// legacy-key-N entries with Admin=false.
|
||||
func assembleNamedAPIKeys(cfg *config.Config, logger *slog.Logger) []auth.NamedAPIKey {
|
||||
if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone {
|
||||
return nil
|
||||
}
|
||||
var out []auth.NamedAPIKey
|
||||
for _, nk := range cfg.Auth.NamedKeys {
|
||||
out = append(out, auth.NamedAPIKey{
|
||||
Name: nk.Name,
|
||||
Key: nk.Key,
|
||||
Admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 && cfg.Auth.Secret != "" {
|
||||
idx := 0
|
||||
for _, p := range strings.Split(cfg.Auth.Secret, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, auth.NamedAPIKey{
|
||||
Name: fmt.Sprintf("legacy-key-%d", idx),
|
||||
Key: p,
|
||||
Admin: false,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
if len(out) > 0 && logger != nil {
|
||||
logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating",
|
||||
"synthesized_keys", len(out))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actorRoleGranter is the narrow interface backfillNamedKeyActorRoles
|
||||
// needs from the postgres ActorRoleRepository. Pulled out so the unit
|
||||
// test can inject a fake without spinning up the full repo / DB.
|
||||
type actorRoleGranter interface {
|
||||
Grant(ctx context.Context, ar *authdomain.ActorRole) error
|
||||
}
|
||||
|
||||
// backfillNamedKeyActorRoles is the Bundle 1 Phase 3 closure (C2)
|
||||
// startup hook that ensures every CERTCTL_API_KEYS_NAMED entry — and
|
||||
// every legacy CERTCTL_AUTH_SECRET synthesized fallback — has an
|
||||
// actor_roles row before the HTTP server accepts requests. Admin-flagged
|
||||
// keys grant `r-admin` (full canonical permission set); non-admin keys
|
||||
// grant `r-viewer` (read-only surface), matching the pre-Phase-3.5
|
||||
// capability shape.
|
||||
//
|
||||
// Idempotent via ON CONFLICT DO NOTHING in the repo Grant — reboots
|
||||
// don't create duplicates. Failures are logged but non-fatal: the server
|
||||
// still starts, and the operator can fix the grant via the RBAC API.
|
||||
//
|
||||
// The function is package-private + extracted from main() so the unit
|
||||
// test in auth_backfill_test.go can pin the role-mapping invariant
|
||||
// without depending on the full server bootstrap path.
|
||||
func backfillNamedKeyActorRoles(
|
||||
ctx context.Context,
|
||||
repo actorRoleGranter,
|
||||
keys []auth.NamedAPIKey,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
for _, nk := range keys {
|
||||
role := authdomain.RoleIDViewer
|
||||
if nk.Admin {
|
||||
role = authdomain.RoleIDAdmin
|
||||
}
|
||||
if err := repo.Grant(ctx, &authdomain.ActorRole{
|
||||
ActorID: nk.Name,
|
||||
ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey),
|
||||
RoleID: role,
|
||||
TenantID: authdomain.DefaultTenantID,
|
||||
GrantedBy: "bootstrap",
|
||||
}); err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("api-key actor-role backfill failed; key authenticates but RBAC routes will 403 until grant is added via /v1/auth/keys",
|
||||
"key", nk.Name, "role", role, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// fakeGranter is a tiny in-memory stand-in for the postgres ActorRoleRepository
|
||||
// — enough surface area for backfillNamedKeyActorRoles to call Grant against.
|
||||
type fakeGranter struct {
|
||||
calls []*authdomain.ActorRole
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error {
|
||||
f.calls = append(f.calls, ar)
|
||||
return f.err
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_RoleMapping pins the Bundle 1 Phase 3
|
||||
// closure (C2) invariant: admin-flagged named keys grant r-admin,
|
||||
// non-admin keys grant r-viewer, both at TenantID t-default with
|
||||
// ActorType APIKey and GrantedBy=bootstrap.
|
||||
func TestBackfillNamedKeyActorRoles_RoleMapping(t *testing.T) {
|
||||
repo := &fakeGranter{}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice-admin", Key: "AAA", Admin: true},
|
||||
{Name: "bob-viewer", Key: "BBB", Admin: false},
|
||||
{Name: "carol-admin", Key: "CCC", Admin: true},
|
||||
}
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
||||
|
||||
if len(repo.calls) != 3 {
|
||||
t.Fatalf("Grant call count = %d, want 3", len(repo.calls))
|
||||
}
|
||||
type want struct {
|
||||
actor, role string
|
||||
}
|
||||
wants := []want{
|
||||
{actor: "alice-admin", role: authdomain.RoleIDAdmin},
|
||||
{actor: "bob-viewer", role: authdomain.RoleIDViewer},
|
||||
{actor: "carol-admin", role: authdomain.RoleIDAdmin},
|
||||
}
|
||||
for i, w := range wants {
|
||||
got := repo.calls[i]
|
||||
if got.ActorID != w.actor {
|
||||
t.Errorf("call[%d].ActorID = %q, want %q", i, got.ActorID, w.actor)
|
||||
}
|
||||
if got.RoleID != w.role {
|
||||
t.Errorf("call[%d].RoleID = %q, want %q", i, got.RoleID, w.role)
|
||||
}
|
||||
if got.TenantID != authdomain.DefaultTenantID {
|
||||
t.Errorf("call[%d].TenantID = %q, want %q", i, got.TenantID, authdomain.DefaultTenantID)
|
||||
}
|
||||
if string(got.ActorType) != "APIKey" {
|
||||
t.Errorf("call[%d].ActorType = %q, want APIKey", i, got.ActorType)
|
||||
}
|
||||
if got.GrantedBy != "bootstrap" {
|
||||
t.Errorf("call[%d].GrantedBy = %q, want bootstrap", i, got.GrantedBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp confirms the boot path
|
||||
// is safe when no named keys are configured (typical CERTCTL_AUTH_TYPE=
|
||||
// none deploy). No Grant calls; no panic.
|
||||
func TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp(t *testing.T) {
|
||||
repo := &fakeGranter{}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, nil, logger)
|
||||
if len(repo.calls) != 0 {
|
||||
t.Errorf("Grant called %d times for empty keys, want 0", len(repo.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal confirms the
|
||||
// closure invariant that a Grant failure logs a warning and proceeds
|
||||
// rather than crashing the server during boot. Subsequent keys still
|
||||
// get processed.
|
||||
func TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal(t *testing.T) {
|
||||
repo := &fakeGranter{err: errors.New("simulated DB error")}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice", Key: "A", Admin: true},
|
||||
{Name: "bob", Key: "B", Admin: false},
|
||||
}
|
||||
// Should not panic.
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
||||
|
||||
if len(repo.calls) != 2 {
|
||||
t.Errorf("Grant calls = %d, want 2 (every key processed even when prior Grant errored)", len(repo.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_NilLoggerIsSafe pins that callers
|
||||
// passing nil for the logger don't NPE the goroutine. Belt-and-braces
|
||||
// for tests + future call sites that may not have a logger plumbed.
|
||||
func TestBackfillNamedKeyActorRoles_NilLoggerIsSafe(t *testing.T) {
|
||||
repo := &fakeGranter{err: errors.New("simulated")}
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice", Key: "A", Admin: true},
|
||||
}
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, nil)
|
||||
if len(repo.calls) != 1 {
|
||||
t.Errorf("Grant calls = %d, want 1", len(repo.calls))
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/router"
|
||||
)
|
||||
|
||||
// Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt
|
||||
// allowlist. cmd/server/main.go::buildFinalHandler decides per-request
|
||||
// whether a path goes through the authenticated apiHandler or the
|
||||
// no-auth handler. This test:
|
||||
//
|
||||
// - constructs a buildFinalHandler with two sentinel handlers (one
|
||||
// for "auth", one for "no-auth") so we can observe which path is
|
||||
// taken from the response body.
|
||||
// - probes every prefix listed in router.AuthExemptDispatchPrefixes
|
||||
// and confirms it routes to no-auth.
|
||||
// - probes a few representative authenticated routes and confirms
|
||||
// they route to auth.
|
||||
// - probes the static-route allowlist (/health, /ready, etc.) that
|
||||
// also bypasses auth at this layer.
|
||||
//
|
||||
// Adding a new auth-bypass to buildFinalHandler without updating the
|
||||
// router.AuthExemptDispatchPrefixes constant fails this test.
|
||||
|
||||
func TestBuildFinalHandler_AuthExemptDispatchAllowlist(t *testing.T) {
|
||||
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("AUTH"))
|
||||
})
|
||||
noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("NOAUTH"))
|
||||
})
|
||||
|
||||
// dashboardEnabled=false keeps the dispatch logic deterministic — no
|
||||
// fileServer fallback to muddy the result.
|
||||
final := buildFinalHandler(apiHandler, noAuthHandler, "/nonexistent", false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
// AuthExemptRouterRoutes (also enforced at this layer)
|
||||
{"health", "/health", "NOAUTH"},
|
||||
{"ready", "/ready", "NOAUTH"},
|
||||
{"auth_info", "/api/v1/auth/info", "NOAUTH"},
|
||||
{"version", "/api/v1/version", "NOAUTH"},
|
||||
|
||||
// AuthExemptDispatchPrefixes — every documented prefix
|
||||
{"pki_crl", "/.well-known/pki/crl", "NOAUTH"},
|
||||
{"pki_ocsp", "/.well-known/pki/ocsp", "NOAUTH"},
|
||||
{"est_simpleenroll", "/.well-known/est/simpleenroll", "NOAUTH"},
|
||||
{"est_cacerts", "/.well-known/est/cacerts", "NOAUTH"},
|
||||
{"scep_root", "/scep", "NOAUTH"},
|
||||
{"scep_op", "/scep/pkiclient.exe", "NOAUTH"},
|
||||
|
||||
// Authenticated routes — must hit apiHandler
|
||||
{"certs_list", "/api/v1/certificates", "AUTH"},
|
||||
{"agents_list", "/api/v1/agents", "AUTH"},
|
||||
{"audit_check", "/api/v1/auth/check", "AUTH"},
|
||||
|
||||
// Random non-API path — falls through to apiHandler when
|
||||
// dashboard disabled (preserves pre-M-001 API-only behavior).
|
||||
{"unknown", "/some-other-path", "AUTH"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
final.ServeHTTP(rec, req)
|
||||
got := rec.Body.String()
|
||||
if got != tc.want {
|
||||
t.Errorf("path %q routed to %q; want %q (this is the M-002 dispatch-layer pin)", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatch_NoUndocumentedBypasses asserts that for every prefix the
|
||||
// dispatch layer routes to noAuthHandler, that prefix appears in the
|
||||
// router.AuthExemptDispatchPrefixes constant. This is the inverse pin —
|
||||
// adding a new bypass to buildFinalHandler without updating the constant
|
||||
// fails this test.
|
||||
//
|
||||
// We probe a curated set of "would-be-bypasses" derived from the actual
|
||||
// dispatch source by reading buildFinalHandler's lines. If the dispatch
|
||||
// logic adds a new prefix that ends up in the no-auth chain, the
|
||||
// curated set must be extended in the same commit that updates the
|
||||
// constant — this fails-loud rather than silently allowing a bypass.
|
||||
func TestDispatch_NoUndocumentedBypasses(t *testing.T) {
|
||||
for _, prefix := range router.AuthExemptDispatchPrefixes {
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
t.Errorf("AuthExemptDispatchPrefixes entry %q must start with / for prefix matching", prefix)
|
||||
}
|
||||
}
|
||||
// Every entry in router.AuthExemptDispatchPrefixes must round-trip
|
||||
// through buildFinalHandler to noAuthHandler (covered by the table
|
||||
// test above). This test additionally asserts the inverse: known
|
||||
// authenticated prefixes do NOT match any documented bypass prefix.
|
||||
authenticatedPrefixes := []string{
|
||||
"/api/v1/certificates",
|
||||
"/api/v1/agents",
|
||||
"/api/v1/audit",
|
||||
}
|
||||
for _, ap := range authenticatedPrefixes {
|
||||
for _, bypass := range router.AuthExemptDispatchPrefixes {
|
||||
if strings.HasPrefix(ap, bypass) {
|
||||
t.Errorf("authenticated prefix %q overlaps with documented bypass %q — auth bypass risk", ap, bypass)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
-1572
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -10,11 +10,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/api/router"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/api/router"
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// TestMain_HealthEndpointBypassesAuth verifies that health check endpoints
|
||||
@@ -45,8 +44,9 @@ func TestMain_HealthEndpointBypassesAuth(t *testing.T) {
|
||||
})
|
||||
|
||||
// Build the handler chain the same way main.go does
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: "test-secret-key"},
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-secret-key",
|
||||
})
|
||||
|
||||
// API handler with auth
|
||||
@@ -160,8 +160,9 @@ func TestMain_AuthMiddlewareRejectsUnauthorized(t *testing.T) {
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: "test-secret-key"},
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-secret-key",
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
@@ -188,8 +189,9 @@ func TestMain_AuthMiddlewareAllowsWithValidKey(t *testing.T) {
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: testKey},
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: testKey,
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
@@ -460,8 +462,9 @@ func TestMain_AuthNoneMode(t *testing.T) {
|
||||
})
|
||||
|
||||
// Wrap with auth middleware in "none" mode
|
||||
// auth=none equivalent: empty named-keys list is a no-op pass-through.
|
||||
authMiddleware := auth.NewAuthWithNamedKeys(nil)
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "none",
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 8b (2026-05-14): the deferred half of
|
||||
// Sprint 8. Extracts the boot-time migration handling from main()'s
|
||||
// inline body into two unexported helpers. Different shape from
|
||||
// Sprints 1-7 (data-type relocation) and from Sprint 8a (existing
|
||||
// helper-function relocation) — this sprint crosses the
|
||||
// behavior-change boundary Sprint 8 first identified.
|
||||
//
|
||||
// What lives here
|
||||
// ===============
|
||||
// parseMigrateOnlyFlag() bool
|
||||
// Hand-parses os.Args for `--migrate-only` (NOT flag.Parse — the
|
||||
// server's config surface is otherwise env-var driven via
|
||||
// config.Load; introducing flag.Parse's global state risks
|
||||
// conflicting with other binaries that may import cmd/server later).
|
||||
//
|
||||
// runBootMigrations(cfg, db, logger, migrateOnly) (exitNow bool)
|
||||
// Owns the Phase 4 DEPL-M1 migration-via-hook posture: the
|
||||
// migrationsViaHook env-var read, the RunMigrations + RunSeed
|
||||
// gate, the --migrate-only early-exit signal, and the
|
||||
// CERTCTL_DEMO_SEED demo-overlay branch.
|
||||
//
|
||||
// Returns true ONLY when --migrate-only was set and migrations +
|
||||
// seed completed cleanly. The caller (main) translates that to
|
||||
// `return` rather than os.Exit(0) — which is the SOLE intentional
|
||||
// behavior change in this sprint (see below).
|
||||
//
|
||||
// Behavior preservation contract
|
||||
// ==============================
|
||||
// Every error path inside runBootMigrations calls os.Exit(1)
|
||||
// directly, matching the original inline behavior byte-for-byte
|
||||
// (same log message, same exit code, same no-defer-run-on-fatal
|
||||
// semantics). The error-path os.Exit(1) is intentional: when
|
||||
// migration fails at boot, the server cannot recover, and bailing
|
||||
// out without running defers is the original Go-idiomatic shape.
|
||||
//
|
||||
// The ONE behavior change: the --migrate-only SUCCESS path now
|
||||
// returns to main() rather than calling os.Exit(0) inline. This
|
||||
// has one observable effect: the `defer db.Close()` registered in
|
||||
// main() now runs at clean exit instead of being skipped. That's
|
||||
// strictly better hygiene (clean DB connection shutdown vs OS
|
||||
// reclaim). The migration work is synchronous + complete before
|
||||
// the return; nothing async is left running that db.Close() could
|
||||
// truncate.
|
||||
//
|
||||
// All other paths — the migration log messages, the seed log
|
||||
// messages, the migrationsViaHook env-var read order, the
|
||||
// RunDemoSeed gating, the per-step success/skip log lines — are
|
||||
// byte-identical to the pre-Sprint-8b inline form. Verified via
|
||||
// `go test ./cmd/server/... -count=1 -short` (which runs the
|
||||
// existing main_test.go assertions through the new call site).
|
||||
//
|
||||
// Why this is a separate commit
|
||||
// =============================
|
||||
// Sprint 8a (commit see git log) extracted the bottom-of-file
|
||||
// helpers + adapter types — pure mechanical relocation that
|
||||
// couldn't change runtime semantics. Sprint 8b crosses the boundary
|
||||
// where mechanical relocation ends: introducing a new function
|
||||
// call frame changes defer scope, panic recovery, and (in this
|
||||
// case) the exit semantics for the --migrate-only path. The
|
||||
// Phase 9 prompt's "refactor is mechanical relocation; behavior
|
||||
// change is a separate concern" rule guards against exactly this
|
||||
// shape of risk being landed without a focused review.
|
||||
//
|
||||
// Splitting Sprint 8a (mechanical) from Sprint 8b (behavior-aware)
|
||||
// means the operator's git log shows:
|
||||
// 3f1344e8 ... wire.go — no behavior change possible
|
||||
// <this> ... migrations.go — one specific behavior shift,
|
||||
// documented + intentional
|
||||
//
|
||||
// Anyone bisecting a future bug to one of these two commits gets a
|
||||
// clean "is it mechanical or did the behavior change" signal.
|
||||
|
||||
// parseMigrateOnlyFlag scans os.Args for the `--migrate-only` token
|
||||
// and returns true if found. Hand-parsed instead of using flag.Parse
|
||||
// because:
|
||||
//
|
||||
// 1. The server's entire config surface is env-var driven via
|
||||
// config.Load(). flag.Parse() introduces a global package-state
|
||||
// dependency that future binaries importing cmd/server (test
|
||||
// harnesses, CLI tools, embedded variants) would have to
|
||||
// coordinate around.
|
||||
// 2. The only flag we care about is the migration-vs-server-lifecycle
|
||||
// toggle; a hand-parser is 6 lines and has no transitive cost.
|
||||
// 3. The flag is Helm-pre-install-hook-facing (see
|
||||
// deploy/helm/certctl/templates/migration-job.yaml). Its shape is
|
||||
// pinned by that template, not by anything else; we don't need
|
||||
// flag.Parse's auto-help generation or type coercion.
|
||||
//
|
||||
// Bare arg match — no `=` value form, no short alias, no override
|
||||
// from env. Anyone passing `--migrate-only` ANYWHERE in os.Args[1:]
|
||||
// flips the flag on. Matches the original inline behavior exactly.
|
||||
func parseMigrateOnlyFlag() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--migrate-only" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runBootMigrations owns the Phase 4 DEPL-M1 boot-time migration
|
||||
// posture. Three lifecycles to support:
|
||||
//
|
||||
// (a) Compose / VM / bare-metal: server runs migrations at boot.
|
||||
// Default behavior — preserved unchanged.
|
||||
// (b) Helm with pre-install/pre-upgrade hook: the migration Job
|
||||
// runs `certctl-server --migrate-only`, does its work, and
|
||||
// exits. The server Deployment's pods then start with
|
||||
// CERTCTL_MIGRATIONS_VIA_HOOK=true set; they see the env
|
||||
// var and skip their boot-time RunMigrations call so the
|
||||
// Job's work isn't duplicated.
|
||||
// (c) Bare `certctl-server --migrate-only` invocation (e.g.
|
||||
// operator running a one-shot migration from the CLI):
|
||||
// runs migrations + seed and returns true so main returns
|
||||
// cleanly without starting the HTTP listener / scheduler /
|
||||
// signing setup.
|
||||
//
|
||||
// migrateOnly captures case (c); CERTCTL_MIGRATIONS_VIA_HOOK
|
||||
// captures case (b). Both paths converge on the same RunMigrations
|
||||
// + RunSeed code below.
|
||||
//
|
||||
// Returns true ONLY when migrateOnly is set; caller (main) handles
|
||||
// the clean exit via `return` so deferred cleanup (db.Close) runs.
|
||||
// Returns false in every other case — caller continues normal boot.
|
||||
// On any migration / seed error: os.Exit(1) inline (matches the
|
||||
// pre-extraction shape; recovery is not possible at this boot
|
||||
// stage).
|
||||
func runBootMigrations(cfg *config.Config, db *sql.DB, logger *slog.Logger, migrateOnly bool) bool {
|
||||
migrationsViaHook := strings.EqualFold(os.Getenv("CERTCTL_MIGRATIONS_VIA_HOOK"), "true")
|
||||
|
||||
if migrateOnly || !migrationsViaHook {
|
||||
logger.Info("running migrations", "path", cfg.Database.MigrationsPath)
|
||||
if err := postgres.RunMigrations(db, cfg.Database.MigrationsPath); err != nil {
|
||||
logger.Error("failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("migrations completed")
|
||||
} else {
|
||||
logger.Info("skipping migrations at boot (CERTCTL_MIGRATIONS_VIA_HOOK=true — Helm pre-install/pre-upgrade hook owns this work)")
|
||||
}
|
||||
|
||||
// Apply baseline seed data.
|
||||
//
|
||||
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 seed.sql was mounted
|
||||
// into postgres `/docker-entrypoint-initdb.d/` alongside a hand-curated
|
||||
// subset of migrations. Adding a migration that introduced a new column
|
||||
// referenced by seed.sql (cat-o-retry_interval_unit_mismatch /
|
||||
// policy_rules.severity / etc.) without also updating the compose volume
|
||||
// mounts caused initdb to crash on first up. Post-U-3 the compose stack
|
||||
// drops all initdb mounts; postgres comes up with empty schema, the
|
||||
// server runs RunMigrations above, then this RunSeed call lands the
|
||||
// baseline data — all from a single source of truth (this binary).
|
||||
// See internal/repository/postgres/db.go::RunSeed for the contract.
|
||||
//
|
||||
// Phase 4 DEPL-M1: same migration-via-hook gating as RunMigrations.
|
||||
// When the hook owns migrations it also owns the seed pass.
|
||||
if migrateOnly || !migrationsViaHook {
|
||||
logger.Info("applying baseline seed", "path", cfg.Database.MigrationsPath)
|
||||
if err := postgres.RunSeed(db, cfg.Database.MigrationsPath); err != nil {
|
||||
logger.Error("failed to apply seed data", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("seed completed")
|
||||
} else {
|
||||
logger.Info("skipping baseline seed at boot (CERTCTL_MIGRATIONS_VIA_HOOK=true — hook applies seed alongside migrations)")
|
||||
}
|
||||
|
||||
// Phase 4 DEPL-M1: --migrate-only early-exit. Migrations + seed are
|
||||
// done; the operator only asked for the migration pass. Signal main
|
||||
// to return cleanly so deferred db.Close runs (Sprint 8b improvement
|
||||
// over the pre-extraction os.Exit(0) which skipped defers).
|
||||
if migrateOnly {
|
||||
logger.Info("--migrate-only: migrations + seed complete; exiting without starting server lifecycle")
|
||||
return true
|
||||
}
|
||||
|
||||
// Apply demo overlay seed when CERTCTL_DEMO_SEED=true. Pre-U-3 the demo
|
||||
// overlay (deploy/docker-compose.demo.yml) mounted seed_demo.sql into
|
||||
// postgres `/docker-entrypoint-initdb.d/`; that broke once U-3 dropped
|
||||
// the initdb migration mounts (the demo seed references tables that
|
||||
// wouldn't exist at initdb time). The runtime path here is the
|
||||
// post-U-3 replacement. Default-off so a vanilla deploy never lands
|
||||
// fake-history rows. See postgres.RunDemoSeed for the contract.
|
||||
if cfg.Database.DemoSeed {
|
||||
logger.Info("applying demo seed (CERTCTL_DEMO_SEED=true)", "path", cfg.Database.MigrationsPath)
|
||||
if err := postgres.RunDemoSeed(db, cfg.Database.MigrationsPath); err != nil {
|
||||
logger.Error("failed to apply demo seed data", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("demo seed completed")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Audit 2026-05-11 A-8 — demo-mode residual-grants detector. Closes the
|
||||
// deferred Phase 2 leg of HIGH-12 (cowork/auth-bundles-fixes-2026-05-10/
|
||||
// 11-high-12-demo-mode-guard.md). The HIGH-12 closure (`b81588e`) added
|
||||
// the fail-closed bind-address guard at config.Validate; the deferred
|
||||
// leg here adds a startup-time WARN (or strict refuse-startup) when
|
||||
// `actor-demo-anon` has live role grants under a non-`none` auth type.
|
||||
//
|
||||
// Why this matters: migration 000029 unconditionally seeds the
|
||||
// `ar-demo-anon-admin` row granting r-admin to actor-demo-anon. The
|
||||
// row is dormant under auth_type=api-key|oidc (the middleware chain
|
||||
// never injects the synthetic actor as the request principal), but
|
||||
// it represents a security debt: any future regression in the
|
||||
// middleware chain (a misrouted CORS preflight, a fallback in a new
|
||||
// auth-exempt route) that resolves to actor-demo-anon would re-elevate
|
||||
// to admin. The canonical acquisition-readiness narrative — "we have
|
||||
// an RBAC primitive with no synthetic-admin fallback" — requires this
|
||||
// row to be either gone or explicitly acknowledged.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// preflightDemoModeResidual runs after the DB connection is open and
|
||||
// the audit service is constructed, before the HTTPS listener starts.
|
||||
//
|
||||
// Behaviour:
|
||||
// - cfg.Auth.Type == "none" (demo mode): no-op. The residual IS the
|
||||
// runtime state at that auth type.
|
||||
// - cfg.Auth.Type != "none" + no residue: returns nil silently.
|
||||
// - cfg.Auth.Type != "none" + residue + strict=false: emits a WARN
|
||||
// log AND an `auth.demo_residual_grants_detected` audit row
|
||||
// listing the grant IDs, then returns nil.
|
||||
// - cfg.Auth.Type != "none" + residue + strict=true: emits the same
|
||||
// WARN + audit, then returns a non-nil error so the caller can
|
||||
// refuse startup.
|
||||
//
|
||||
// The audit row's actor is `system` / ActorTypeSystem; category is
|
||||
// EventCategoryAuth so audit consumers filtering on auth events see it.
|
||||
func preflightDemoModeResidual(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
db *sql.DB,
|
||||
audit *service.AuditService,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
if cfg.Auth.Type == "none" {
|
||||
// Demo mode itself. The residual is the runtime state at
|
||||
// this auth type, so warning about it would be noise.
|
||||
return nil
|
||||
}
|
||||
|
||||
residue, err := queryDemoAnonResidue(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preflight demo-mode residual: %w", err)
|
||||
}
|
||||
if len(residue) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted := make([]string, 0, len(residue))
|
||||
for _, r := range residue {
|
||||
formatted = append(formatted, r.String())
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"production startup warning: actor-demo-anon has %d residual role grant(s) "+
|
||||
"from the migration 000029 baseline or a prior demo-mode run: %s. "+
|
||||
"These grants are DORMANT at the current auth_type (%s) but represent a "+
|
||||
"security debt — any future regression that resolves an unauthenticated "+
|
||||
"request to actor-demo-anon would re-elevate to admin. Clean up via "+
|
||||
"POST /api/v1/auth/demo-residual/cleanup (requires auth.role.assign) or "+
|
||||
"`DELETE FROM actor_roles WHERE actor_id = 'actor-demo-anon';`. Set "+
|
||||
"CERTCTL_DEMO_MODE_RESIDUAL_STRICT=true to refuse startup until cleanup.",
|
||||
len(residue), strings.Join(formatted, "; "), cfg.Auth.Type,
|
||||
)
|
||||
if logger != nil {
|
||||
logger.Warn(msg, "auth_type", cfg.Auth.Type, "residue_count", len(residue))
|
||||
} else {
|
||||
slog.Warn(msg)
|
||||
}
|
||||
|
||||
if audit != nil {
|
||||
details := map[string]interface{}{
|
||||
"auth_type": cfg.Auth.Type,
|
||||
"residue_count": len(residue),
|
||||
"residue": formatted,
|
||||
}
|
||||
if err := audit.RecordEventWithCategory(
|
||||
ctx, "system", domain.ActorTypeSystem,
|
||||
"auth.demo_residual_grants_detected",
|
||||
domain.EventCategoryAuth,
|
||||
"actor_roles", authdomain.DemoAnonActorID,
|
||||
details,
|
||||
); err != nil {
|
||||
// Don't fail startup over an audit-write error; just log.
|
||||
if logger != nil {
|
||||
logger.Warn("preflight demo-mode residual: audit record failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Auth.DemoModeResidualStrict {
|
||||
return fmt.Errorf(
|
||||
"startup refused: actor-demo-anon has %d residual role grant(s) and "+
|
||||
"CERTCTL_DEMO_MODE_RESIDUAL_STRICT=true. Remove the rows before restarting",
|
||||
len(residue),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// demoAnonResidueRow describes a single live actor_roles row whose
|
||||
// actor_id matches the synthetic demo-anon ID.
|
||||
type demoAnonResidueRow struct {
|
||||
RoleID string
|
||||
ScopeType string
|
||||
ScopeID string
|
||||
GrantedAt time.Time
|
||||
}
|
||||
|
||||
// String renders one row as `role@scope (granted ts)`. Used both in
|
||||
// the WARN log message and in the audit row's residue list.
|
||||
func (r demoAnonResidueRow) String() string {
|
||||
scope := r.ScopeType
|
||||
if r.ScopeID != "" {
|
||||
scope = fmt.Sprintf("%s/%s", r.ScopeType, r.ScopeID)
|
||||
}
|
||||
return fmt.Sprintf("%s@%s (granted %s)", r.RoleID, scope, r.GrantedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// queryDemoAnonResidue runs the canonical query for the residue
|
||||
// detector + the cleanup endpoint. Kept in one place so the two
|
||||
// surfaces can't drift on which rows count as "live".
|
||||
//
|
||||
// "Live" = not expired. Rows with expires_at <= NOW() are treated
|
||||
// as already gone (they have no effect even if the actor were to be
|
||||
// injected as the principal).
|
||||
func queryDemoAnonResidue(ctx context.Context, db *sql.DB) ([]demoAnonResidueRow, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("db is nil")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT role_id, scope_type, COALESCE(scope_id, '') AS scope_id, granted_at
|
||||
FROM actor_roles
|
||||
WHERE actor_id = $1
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY granted_at ASC, role_id ASC, scope_type ASC, COALESCE(scope_id, '') ASC
|
||||
`, authdomain.DemoAnonActorID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query actor_roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []demoAnonResidueRow
|
||||
for rows.Next() {
|
||||
var r demoAnonResidueRow
|
||||
if err := rows.Scan(&r.RoleID, &r.ScopeType, &r.ScopeID, &r.GrantedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan actor_roles row: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate actor_roles rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deleteDemoAnonResidue removes every live actor_roles row for the
|
||||
// synthetic demo-anon actor. Returns the count removed. Used by the
|
||||
// POST /api/v1/auth/demo-residual/cleanup handler. Idempotent — a
|
||||
// follow-up call returns 0.
|
||||
func deleteDemoAnonResidue(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, errors.New("db is nil")
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `
|
||||
DELETE FROM actor_roles
|
||||
WHERE actor_id = $1
|
||||
`, authdomain.DemoAnonActorID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete actor_roles: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// Audit 2026-05-11 A-8 — preflight + cleanup regression tests for the
|
||||
// demo-mode residual-grants detector. Testcontainers-backed because the
|
||||
// preflight runs raw SQL against actor_roles; mock-DB-only would not
|
||||
// catch a SQL-shape regression. Gated by testing.Short() to keep the
|
||||
// fast loop fast (matching internal/repository/postgres/* pattern).
|
||||
|
||||
var (
|
||||
a8DBOnce sync.Once
|
||||
a8DB *sql.DB
|
||||
a8Skip bool
|
||||
a8SkipMu sync.Mutex
|
||||
)
|
||||
|
||||
func setupA8DB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("preflight A-8 test requires Postgres (testcontainers); skipping under -short")
|
||||
}
|
||||
a8DBOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "postgres:16-alpine",
|
||||
ExposedPorts: []string{"5432/tcp"},
|
||||
Env: map[string]string{
|
||||
"POSTGRES_DB": "certctl_test_a8",
|
||||
"POSTGRES_USER": "certctl",
|
||||
"POSTGRES_PASSWORD": "certctl",
|
||||
},
|
||||
WaitingFor: wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
|
||||
}
|
||||
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
a8SkipMu.Lock()
|
||||
a8Skip = true
|
||||
a8SkipMu.Unlock()
|
||||
t.Logf("skipping A-8 testcontainers preflight (docker unavailable): %v", err)
|
||||
return
|
||||
}
|
||||
host, err := c.Host(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get container host: %v", err)
|
||||
}
|
||||
port, err := c.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
t.Fatalf("get mapped port: %v", err)
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://certctl:certctl@%s:%s/certctl_test_a8?sslmode=disable", host, port.Port())
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
// Run all migrations so actor_roles exists with the migration
|
||||
// 000029 seed row (`ar-demo-anon-admin`).
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "migrations")
|
||||
if _, err := os.Stat(migrationsDir); err != nil {
|
||||
t.Fatalf("locate migrations dir %q: %v", migrationsDir, err)
|
||||
}
|
||||
if err := postgres.RunMigrations(db, migrationsDir); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
a8DB = db
|
||||
})
|
||||
|
||||
a8SkipMu.Lock()
|
||||
skip := a8Skip
|
||||
a8SkipMu.Unlock()
|
||||
if skip {
|
||||
t.Skip("A-8 testcontainers unavailable; skipping")
|
||||
}
|
||||
return a8DB
|
||||
}
|
||||
|
||||
// resetA8Residue clears the actor_roles rows for actor-demo-anon AND
|
||||
// re-inserts the migration 000029 baseline. Used by tests that need a
|
||||
// known "post-fresh-migration" state.
|
||||
func resetA8Residue(t *testing.T, db *sql.DB, seedBaseline bool) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(context.Background(),
|
||||
`DELETE FROM actor_roles WHERE actor_id = 'actor-demo-anon'`); err != nil {
|
||||
t.Fatalf("reset actor_roles: %v", err)
|
||||
}
|
||||
if seedBaseline {
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, granted_by, tenant_id)
|
||||
VALUES ('ar-demo-anon-admin', 'actor-demo-anon', 'Anonymous', 'r-admin', NOW(), 'system', 't-default')
|
||||
`); err != nil {
|
||||
t.Fatalf("reseed baseline: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_DemoModeActive_Skips proves the
|
||||
// preflight short-circuits when Auth.Type=none regardless of residue.
|
||||
// Demo mode IS the active runtime state at that auth type, so warning
|
||||
// would be noise.
|
||||
func TestPreflightDemoModeResidual_DemoModeActive_Skips(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true) // baseline IS present
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "none"
|
||||
cfg.Auth.DemoModeResidualStrict = true // would refuse if checked
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil under Auth.Type=none, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_NoResidue_Passes proves a fully-clean
|
||||
// actor_roles state passes without WARN.
|
||||
func TestPreflightDemoModeResidual_NoResidue_Passes(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, false) // explicitly empty
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "api-key"
|
||||
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil with empty residue, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_HasResidue_LogsAndAudits proves the
|
||||
// migration 000029 baseline produces a WARN + audit row but does NOT
|
||||
// fail startup in default (non-strict) mode.
|
||||
func TestPreflightDemoModeResidual_HasResidue_LogsAndAudits(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "api-key"
|
||||
cfg.Auth.DemoModeResidualStrict = false
|
||||
|
||||
auditRepo := postgres.NewAuditRepository(db)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, auditService, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("non-strict mode must NOT fail startup with residue, got %v", err)
|
||||
}
|
||||
|
||||
// Audit row should be present for the call.
|
||||
rows, err := db.QueryContext(context.Background(), `
|
||||
SELECT action, event_category, resource_id
|
||||
FROM audit_events
|
||||
WHERE action = 'auth.demo_residual_grants_detected'
|
||||
ORDER BY occurred_at DESC LIMIT 1
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("audit_events query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("expected at least one auth.demo_residual_grants_detected row")
|
||||
}
|
||||
var action, category, resourceID string
|
||||
if err := rows.Scan(&action, &category, &resourceID); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if action != "auth.demo_residual_grants_detected" {
|
||||
t.Errorf("action = %q, want auth.demo_residual_grants_detected", action)
|
||||
}
|
||||
if category != "auth" {
|
||||
t.Errorf("event_category = %q, want auth", category)
|
||||
}
|
||||
if resourceID != "actor-demo-anon" {
|
||||
t.Errorf("resource_id = %q, want actor-demo-anon", resourceID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_StrictMode_RefusesStartup proves the
|
||||
// flag pivots WARN → fail.
|
||||
func TestPreflightDemoModeResidual_StrictMode_RefusesStartup(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "api-key"
|
||||
cfg.Auth.DemoModeResidualStrict = true
|
||||
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("strict mode + residue: expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "actor-demo-anon") {
|
||||
t.Errorf("err = %q, want mention of actor-demo-anon", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CERTCTL_DEMO_MODE_RESIDUAL_STRICT") {
|
||||
t.Errorf("err = %q, want mention of CERTCTL_DEMO_MODE_RESIDUAL_STRICT", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDemoAnonResidueRow_String pins the formatting of the residue
|
||||
// detail entry — used both in the WARN log AND the audit row's
|
||||
// `residue` slice. Two cases: NULL scope_id (global scope) and
|
||||
// non-empty scope_id (profile/issuer scope).
|
||||
func TestDemoAnonResidueRow_String(t *testing.T) {
|
||||
ts, _ := time.Parse(time.RFC3339, "2026-05-11T12:34:56Z")
|
||||
cases := []struct {
|
||||
name string
|
||||
r demoAnonResidueRow
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "global_scope",
|
||||
r: demoAnonResidueRow{RoleID: "r-admin", ScopeType: "global", ScopeID: "", GrantedAt: ts},
|
||||
want: "r-admin@global (granted 2026-05-11T12:34:56Z)",
|
||||
},
|
||||
{
|
||||
name: "scoped",
|
||||
r: demoAnonResidueRow{RoleID: "r-operator", ScopeType: "profile", ScopeID: "p-prod", GrantedAt: ts},
|
||||
want: "r-operator@profile/p-prod (granted 2026-05-11T12:34:56Z)",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.r.String()
|
||||
if got != c.want {
|
||||
t.Errorf("String() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteDemoAnonResidue_Idempotent proves the cleanup helper is
|
||||
// re-entrant: a second call after a successful first call returns 0.
|
||||
func TestDeleteDemoAnonResidue_Idempotent(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true)
|
||||
|
||||
n, err := deleteDemoAnonResidue(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("first delete: %v", err)
|
||||
}
|
||||
if n < 1 {
|
||||
t.Fatalf("first delete: count = %d, want >= 1", n)
|
||||
}
|
||||
|
||||
n, err = deleteDemoAnonResidue(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("second delete (idempotent): count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryDemoAnonResidue_NilDB pins the nil-safety contract.
|
||||
func TestQueryDemoAnonResidue_NilDB(t *testing.T) {
|
||||
_, err := queryDemoAnonResidue(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on nil db, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteDemoAnonResidue_NilDB pins the nil-safety contract.
|
||||
func TestDeleteDemoAnonResidue_NilDB(t *testing.T) {
|
||||
_, err := deleteDemoAnonResidue(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on nil db, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 + Intune master prompt §13 line 1853 acceptance —
|
||||
// boot regression tests for preflightSCEPIntuneTrustAnchor. Closed in
|
||||
// the 2026-04-29 audit-closure bundle (Phase F).
|
||||
//
|
||||
// Spec text:
|
||||
// "clean boot with Intune disabled (backward compat)" and
|
||||
// "refuses-to-start with broken per-profile config (PathID logged)."
|
||||
//
|
||||
// These three tests exercise the function the cmd/server/main.go boot
|
||||
// loop calls per profile. We can't (and don't want to) run main()
|
||||
// itself in a unit test — that would require docker compose + a real
|
||||
// listener. Instead we drive the function directly and assert its
|
||||
// contract holds: nil error on disabled, structured error containing
|
||||
// the PathID on enabled-but-broken.
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||
}
|
||||
|
||||
// TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat — when
|
||||
// the profile has Intune disabled, preflight returns (nil, nil) and
|
||||
// MUST NOT touch the filesystem. This is the dominant path in
|
||||
// production: most operators run SCEP without Intune. A regression
|
||||
// here would make every non-Intune deploy fail boot with a confusing
|
||||
// "trust anchor missing" error.
|
||||
func TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat(t *testing.T) {
|
||||
holder, err := preflightSCEPIntuneTrustAnchor(false, "corp", "", discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("disabled preflight should be a no-op, got error: %v", err)
|
||||
}
|
||||
if holder != nil {
|
||||
t.Errorf("disabled preflight should return nil holder, got %#v", holder)
|
||||
}
|
||||
|
||||
// Confirm the no-touch contract: even if PathID + path are both
|
||||
// non-empty, disabled=false short-circuits before any I/O. Pass a
|
||||
// path that doesn't exist — the call MUST still succeed.
|
||||
holder, err = preflightSCEPIntuneTrustAnchor(false, "iot", "/tmp/this-file-does-not-exist-12345.pem", discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("disabled preflight with non-existent path should still succeed: %v", err)
|
||||
}
|
||||
if holder != nil {
|
||||
t.Error("disabled preflight should return nil holder even with non-existent path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID —
|
||||
// when the profile has Intune enabled but the trust-anchor file
|
||||
// doesn't exist, preflight returns an error whose text contains the
|
||||
// literal PathID. Operators grep their boot log for the PathID to
|
||||
// triage which profile is broken in a multi-profile deploy.
|
||||
func TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID(t *testing.T) {
|
||||
missingPath := filepath.Join(t.TempDir(), "this-trust-anchor-was-never-written.pem")
|
||||
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp", missingPath, discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when trust anchor file is missing, got nil")
|
||||
}
|
||||
if holder != nil {
|
||||
t.Errorf("expected nil holder on broken config, got %#v", holder)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="corp"`) {
|
||||
t.Errorf("error should contain PathID for operator log-grep: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), missingPath) {
|
||||
t.Errorf("error should contain the path for operator log-grep: %v", err)
|
||||
}
|
||||
|
||||
// Empty PathID (legacy /scep root) — the error MUST surface a
|
||||
// readable label, not an empty quoted string that looks like a
|
||||
// missing variable.
|
||||
_, err = preflightSCEPIntuneTrustAnchor(true, "", missingPath, discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error on broken legacy-root config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="<root>"`) {
|
||||
t.Errorf("error should label empty PathID as <root>: %v", err)
|
||||
}
|
||||
|
||||
// Empty path with enabled=true — distinct error path (path-empty
|
||||
// vs file-missing). Spec requires this branch ALSO surfaces the
|
||||
// PathID so the operator's grep narrows to the profile.
|
||||
_, err = preflightSCEPIntuneTrustAnchor(true, "iot", "", discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when trust anchor path is empty")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="iot"`) {
|
||||
t.Errorf("empty-path error should contain PathID for operator log-grep: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses — an
|
||||
// expired Connector signing cert in the trust anchor file is the
|
||||
// silent-failure mode this preflight is built to catch. Without the
|
||||
// gate, the SCEP server boots cleanly and then rejects every Intune
|
||||
// enrollment at runtime with "no trust anchor recognizes this
|
||||
// signature" — confusing for the operator whose Connector is healthy
|
||||
// (the cert just expired without rotation). Pin the contract: the
|
||||
// boot MUST refuse with an error that names the expired cert's
|
||||
// subject CN so the operator knows what to rotate.
|
||||
func TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses(t *testing.T) {
|
||||
// Build a deterministic ECDSA cert with NotAfter 1 hour in the past.
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "intune-connector-rotated-must-replace"},
|
||||
NotBefore: now.Add(-2 * time.Hour),
|
||||
NotAfter: now.Add(-1 * time.Hour), // expired
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(t.TempDir(), "intune-expired.pem")
|
||||
if err := os.WriteFile(bundlePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatalf("write expired cert: %v", err)
|
||||
}
|
||||
|
||||
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp-expired", bundlePath, discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected refuse-to-start on expired trust anchor cert, got nil error")
|
||||
}
|
||||
if holder != nil {
|
||||
t.Errorf("expected nil holder on expired-cert refusal, got %#v", holder)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="corp-expired"`) {
|
||||
t.Errorf("error should contain PathID for operator log-grep: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "intune-connector-rotated-must-replace") {
|
||||
t.Errorf("error should contain the expired cert's subject CN so the operator knows what to rotate: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 Phase 1: preflightSCEPRACertKey covers the six failure
|
||||
// modes spelled out in the helper's docblock plus the no-op-when-disabled
|
||||
// path. Mirrors TestPreflightEnrollmentIssuer's table-driven shape so the
|
||||
// suite stays uniform for the next reviewer.
|
||||
//
|
||||
// Each test materialises a real ECDSA P-256 cert/key pair on disk (rather
|
||||
// than mocking) so the tls.X509KeyPair path is exercised end-to-end —
|
||||
// catches drift in stdlib cert-parsing semantics that a mock would hide.
|
||||
|
||||
func TestPreflightSCEPRACertKey_Disabled_NoOp(t *testing.T) {
|
||||
// Enabled=false short-circuits before any path validation; should pass
|
||||
// even with empty paths (mirrors preflightSCEPChallengePassword).
|
||||
if err := preflightSCEPRACertKey(false, "", ""); err != nil {
|
||||
t.Fatalf("disabled SCEP returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_EnabledMissingPaths_Refuses(t *testing.T) {
|
||||
// Validate() also catches this; preflight reports the specific failure
|
||||
// with a more actionable error string + os.Exit(1) at the call site.
|
||||
cases := []struct {
|
||||
name string
|
||||
certPath string
|
||||
keyPath string
|
||||
}{
|
||||
{"both_empty", "", ""},
|
||||
{"cert_only", "/tmp/ra.crt", ""},
|
||||
{"key_only", "", "/tmp/ra.key"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := preflightSCEPRACertKey(true, tc.certPath, tc.keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing paths, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "RA pair missing") {
|
||||
t.Errorf("error should mention RA pair missing, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_KeyWorldReadable_Refuses(t *testing.T) {
|
||||
// Defense-in-depth: even a perfectly-valid RA pair must be rejected if
|
||||
// the key file is mode 0644 (world-readable). The deploy convention is
|
||||
// 0600 — owner read/write only.
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
// Re-chmod the key to 0644 to trigger the gate.
|
||||
if err := os.Chmod(keyPath, 0o644); err != nil {
|
||||
t.Fatalf("chmod failed: %v", err)
|
||||
}
|
||||
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for world-readable key, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "insecure permissions") {
|
||||
t.Errorf("error should mention insecure permissions, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_ValidPair_Accepts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
if err := preflightSCEPRACertKey(true, certPath, keyPath); err != nil {
|
||||
t.Fatalf("valid RA pair rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_ExpiredCert_Refuses(t *testing.T) {
|
||||
// An RA cert past NotAfter would cause every conformant SCEP client to
|
||||
// reject the CertRep signature. Catch it at startup.
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(-1*time.Hour))
|
||||
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for expired cert, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "expired") {
|
||||
t.Errorf("error should mention expired, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_MismatchedPair_Refuses(t *testing.T) {
|
||||
// tls.X509KeyPair detects the cert/key mismatch; preflight should
|
||||
// surface it with an actionable error (cert + key are halves of
|
||||
// different RA pairs — common multi-profile typo).
|
||||
dir := t.TempDir()
|
||||
certPath, _ := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
_, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
// Re-write the key path under a unique name to avoid collision with
|
||||
// the first pair's file (writeECDSARAPair would have overwritten).
|
||||
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for mismatched pair, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("error should mention invalid pair, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_MissingFiles_Refuses(t *testing.T) {
|
||||
// Both files referenced but neither exists — a typo or a fresh deploy
|
||||
// where the operator forgot to mount the secret. Cert-path failure mode
|
||||
// is checked first because key-path stat is the first os call after
|
||||
// the empty-string check.
|
||||
dir := t.TempDir()
|
||||
missingCert := filepath.Join(dir, "ra.crt")
|
||||
missingKey := filepath.Join(dir, "ra.key")
|
||||
err := preflightSCEPRACertKey(true, missingCert, missingKey)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing files, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stat failed") && !strings.Contains(err.Error(), "read failed") {
|
||||
t.Errorf("error should mention stat/read failure, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_UnsupportedAlg_Refuses(t *testing.T) {
|
||||
// Ed25519 isn't supported by the CMS signature path RFC 8894 §3.5.2
|
||||
// advertises. Catch this at startup to avoid runtime failures the
|
||||
// first time a client sends a real PKIMessage.
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "ra.crt")
|
||||
keyPath := filepath.Join(dir, "ra.key")
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "ra-ed25519"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
|
||||
err = preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for ed25519 RA cert, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported public-key algorithm") &&
|
||||
!strings.Contains(err.Error(), "invalid") {
|
||||
// tls.X509KeyPair may reject ed25519 SCEP-signing keys earlier
|
||||
// than our explicit alg gate; accept either failure path so the
|
||||
// test is robust against stdlib changes.
|
||||
t.Errorf("error should mention algorithm/invalid, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeECDSARAPair generates a fresh ECDSA P-256 self-signed cert + key,
|
||||
// writes them to dir/ra-<rand>.crt + ra-<rand>.key with the cert at 0644
|
||||
// and the key at 0600 (the production deploy mode). Returns the two paths.
|
||||
func writeECDSARAPair(t *testing.T, dir string, notAfter time.Time) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: "ra-test"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
// Use a unique suffix so successive calls within the same test don't
|
||||
// overwrite each other (the mismatched-pair test relies on this).
|
||||
suffix := tmpl.SerialNumber.String()
|
||||
certPath = filepath.Join(dir, "ra-"+suffix+".crt")
|
||||
keyPath = filepath.Join(dir, "ra-"+suffix+".key")
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
|
||||
type fakeIssuerConn struct {
|
||||
caCertPEM string
|
||||
caCertErr error
|
||||
}
|
||||
|
||||
func (f *fakeIssuerConn) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeIssuerConn) GenerateCRL(ctx context.Context, revokedCerts []service.CRLEntry) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) SignOCSPResponse(ctx context.Context, req service.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return f.caCertPEM, f.caCertErr
|
||||
}
|
||||
func (f *fakeIssuerConn) GetRenewalInfo(ctx context.Context, certPEM string) (*service.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestPreflightEnrollmentIssuer covers Bundle-4 / L-005 startup validation
|
||||
// for EST/SCEP issuer binding.
|
||||
func TestPreflightEnrollmentIssuer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
issuer service.IssuerConnector
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "nil_connector_fails",
|
||||
issuer: nil,
|
||||
wantErr: true,
|
||||
errContains: "connector is nil",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_error_fails",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertErr: errStub("ACME issuers do not provide a static CA certificate"),
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "cannot serve CA certificate",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_empty_pem_fails",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertPEM: "",
|
||||
caCertErr: nil,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "empty PEM",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_valid_pem_succeeds",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertPEM: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
|
||||
caCertErr: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := preflightEnrollmentIssuer(context.Background(), "EST", "iss-test", tc.issuer)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tc.wantErr && tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
|
||||
t.Fatalf("error %q missing substring %q", err.Error(), tc.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errStub is a tiny error wrapper so test cases can use string literals
|
||||
// without importing fmt in every test struct entry.
|
||||
type errStub string
|
||||
|
||||
func (e errStub) Error() string { return string(e) }
|
||||
@@ -1,11 +1,7 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -138,37 +134,6 @@ func buildServerTLSConfig(holder *certHolder) *tls.Config {
|
||||
}
|
||||
}
|
||||
|
||||
// buildServerTLSConfigWithMTLS extends buildServerTLSConfig with a client-cert
|
||||
// trust pool for the SCEP/EST mTLS sibling routes.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5 introduced this for the
|
||||
// /scep-mtls/<pathID> route; EST RFC 7030 hardening master bundle Phase 2
|
||||
// extended it so the same TLS listener also serves /.well-known/est-mtls/
|
||||
// <pathID>. Both protocols' mTLS profiles contribute their trust bundles
|
||||
// to a UNION pool that the caller (cmd/server/main.go) builds by walking
|
||||
// every enabled mTLS profile's bundle bytes once. The per-protocol
|
||||
// handlers re-verify against just THIS profile's bundle (so an EST-mTLS
|
||||
// bootstrap cert can't enroll against a SCEP-mTLS profile and vice versa).
|
||||
//
|
||||
// ClientAuth: VerifyClientCertIfGiven — request a cert during handshake; if
|
||||
// the client presents one, verify it against the union pool; if absent, the
|
||||
// request still reaches the handler and the per-route handler decides
|
||||
// whether to accept. Critical that we do NOT use RequireAndVerifyClientCert
|
||||
// here — that would break the standard /scep + /.well-known/est routes
|
||||
// (challenge-password-only / unauth-or-Basic, no client cert expected).
|
||||
//
|
||||
// Pass clientCAs == nil to disable mTLS (no profile opted in across either
|
||||
// protocol). The function then returns the same shape as
|
||||
// buildServerTLSConfig.
|
||||
func buildServerTLSConfigWithMTLS(holder *certHolder, clientCAs *x509.CertPool) *tls.Config {
|
||||
cfg := buildServerTLSConfig(holder)
|
||||
if clientCAs != nil {
|
||||
cfg.ClientCAs = clientCAs
|
||||
cfg.ClientAuth = tls.VerifyClientCertIfGiven
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// preflightServerTLS is the fail-loud startup gate for HTTPS. Returns a
|
||||
// non-nil error when the TLS configuration is missing or the cert+key pair
|
||||
// cannot be parsed, so the caller refuses to start the control plane
|
||||
|
||||
@@ -1,758 +0,0 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/handler"
|
||||
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
||||
"github.com/certctl-io/certctl/internal/auth/session"
|
||||
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
||||
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 8 (2026-05-14): extracted from
|
||||
// cmd/server/main.go. Different shape from the config.go cuts —
|
||||
// the move is by FUNCTIONAL CONCERN (boot-time preflight + DI
|
||||
// adapter wiring), not by TYPE FAMILY.
|
||||
//
|
||||
// Sprint 8 ships TWO of the three files the Phase 9 prompt names:
|
||||
// - main.go — entrypoint (unchanged; what's left after the cut)
|
||||
// - wire.go — this file (DI assembly: preflight helpers +
|
||||
// adapter types that bridge package boundaries)
|
||||
//
|
||||
// The third file the prompt names — migrations.go — is NOT in this
|
||||
// commit. See "What's NOT in this sprint" below for the deferral
|
||||
// rationale.
|
||||
//
|
||||
// What lives here
|
||||
// ===============
|
||||
// Seven preflight + DI helper functions:
|
||||
// - preflightSCEPChallengePassword (H-2 fix: SCEP needs non-empty
|
||||
// shared secret if enabled)
|
||||
// - preflightSCEPMTLSTrustBundle (SCEP Phase 6.5: per-profile
|
||||
// mTLS CA bundle validation)
|
||||
// - preflightESTMTLSClientCATrustBundle (EST Phase 2.5: same shape,
|
||||
// returns SIGHUP-reloadable
|
||||
// *trustanchor.Holder)
|
||||
// - preflightSCEPIntuneTrustAnchor (SCEP Phase 8.2: Intune
|
||||
// Connector signing-cert bundle)
|
||||
// - loadSCEPRAPair (post-preflight cert+key load)
|
||||
// - preflightSCEPRACertKey (RA cert/key validation: file
|
||||
// mode 0600, cert+key match,
|
||||
// NotAfter, RSA-or-ECDSA alg)
|
||||
// - preflightEnrollmentIssuer (L-005: EST/SCEP issuer can
|
||||
// serve GetCACertPEM)
|
||||
// - buildFinalHandler (M-001 option D: HTTP dispatch
|
||||
// wrapper routing
|
||||
// authenticated vs no-auth
|
||||
// chains by URL prefix)
|
||||
//
|
||||
// Five adapter types that bridge package boundaries (avoid import
|
||||
// cycles between internal/auth, internal/service/auth,
|
||||
// internal/api/handler, internal/auth/oidc, internal/auth/session,
|
||||
// internal/auth/breakglass):
|
||||
// - authPermissionCheckerAdapter (typed-string → plain-string
|
||||
// auth.PermissionChecker
|
||||
// interface)
|
||||
// - authCheckResolverAdapter (postgres ActorRoleRepository
|
||||
// → handler.AuthCheckResolver)
|
||||
// - sessionMinterAdapter (session.Service → OIDC
|
||||
// SessionMinter port)
|
||||
// - breakglassSessionMinterAdapter (session.Service → breakglass
|
||||
// SessionMinter port + audit
|
||||
// 2026-05-10 HIGH-1 revoke-all)
|
||||
// - oidcProvidersListAdapter (postgres OIDCProviderRepository
|
||||
// → handler.OIDCProvidersListResolver
|
||||
// with MED-9 enabled-filter)
|
||||
//
|
||||
// Plus the silenceUnusedImports var-block that pins
|
||||
// oidcdomain.OIDCProvider as a load-bearing reference (the adapter
|
||||
// types use *userdomain.User and repository.OIDCProviderRepository
|
||||
// indirectly; oidcdomain.OIDCProvider isn't named in any function
|
||||
// signature here but is part of the Phase 3 SessionMinter contract).
|
||||
//
|
||||
// What's NOT in this sprint (and why)
|
||||
// ===================================
|
||||
// migrations.go is deferred. The Phase 9 prompt asks for three files:
|
||||
// main.go (entrypoint) + wire.go (this file) + migrations.go (boot-
|
||||
// time migration handling). The migration code (Phase 4 DEPL-M1
|
||||
// --migrate-only flag handling + RunMigrations + RunSeed call +
|
||||
// CERTCTL_MIGRATIONS_VIA_HOOK gating) lives INLINE inside the 2300-
|
||||
// line main() function — lines ~59-264 in the original — not as a
|
||||
// standalone helper.
|
||||
//
|
||||
// Extracting it into a migrations.go would require:
|
||||
// 1. Creating a new unexported function (e.g.,
|
||||
// runMigrations(ctx, cfg, db, logger) error) that consolidates
|
||||
// lines ~71-77 (--migrate-only parse) + ~199-248 (the migration
|
||||
// branch + --migrate-only early-exit) + ~250-264 (the demo
|
||||
// overlay seed branch).
|
||||
// 2. Replacing the inline block in main() with a single call.
|
||||
// 3. Threading the early-exit semantics out (os.Exit(0) vs return
|
||||
// "migration done" sentinel error vs a third option) so main's
|
||||
// defer ordering doesn't change.
|
||||
//
|
||||
// That's behavior-change territory — a new function call frame, a
|
||||
// new defer scope, error-handling pattern shift. Different risk
|
||||
// shape from the pure-data type relocations Sprints 1-7 did. The
|
||||
// Phase 9 prompt says "Do NOT change exported type signatures; the
|
||||
// refactor is mechanical relocation; behavior change is a separate
|
||||
// concern." Extracting an inline block from main() into a new
|
||||
// function is the same shape of risk that rule was guarding against.
|
||||
//
|
||||
// Recommended path for the migrations.go cut:
|
||||
// - Land it as a separate, smaller PR with its own review focus
|
||||
// (the runMigrations function shape, the early-exit semantics,
|
||||
// unit tests for the new function via the existing main_test.go
|
||||
// fixture). The infrastructure for the PR exists today; only
|
||||
// the operator's go-ahead on the behavior-change risk is needed.
|
||||
// - Estimated impact: another ~80-120 LOC out of main.go (the
|
||||
// migration + seed + early-exit block) into a new migrations.go.
|
||||
// - Phase 4's --migrate-only code path already runs through this
|
||||
// code section, so the extracted function should reproduce that
|
||||
// exact flow without behavior change beyond the call-frame
|
||||
// introduction.
|
||||
//
|
||||
// Public-surface invariant
|
||||
// ========================
|
||||
// The moved helpers + adapter types are all in package `main`
|
||||
// (which Go cannot expose to external importers). No exported
|
||||
// surface changes. The reorganization is invisible outside
|
||||
// cmd/server/. Same-package callers in main.go (preflight*
|
||||
// invocations, adapter instantiation) resolve via the package
|
||||
// symbol table without modification.
|
||||
|
||||
// preflightSCEPChallengePassword enforces the H-2 fix: if SCEP is enabled, a
|
||||
// non-empty challenge password MUST be configured. Returns a non-nil error
|
||||
// otherwise so the caller can refuse to start the control plane (CWE-306,
|
||||
// missing authentication for a critical function).
|
||||
//
|
||||
// This helper is extracted so the check can be unit tested without booting
|
||||
// the full server. The caller (main) is responsible for translating the
|
||||
// returned error into a structured log line and os.Exit(1).
|
||||
func preflightSCEPChallengePassword(enabled bool, challengePassword string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if challengePassword == "" {
|
||||
return fmt.Errorf("SCEP enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty: " +
|
||||
"SCEP enrollment would accept any client (CWE-306); " +
|
||||
"configure a non-empty shared secret or set CERTCTL_SCEP_ENABLED=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightSCEPMTLSTrustBundle validates a per-profile mTLS client-CA
|
||||
// trust bundle. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
||||
//
|
||||
// Mirrors preflightSCEPRACertKey's no-op-when-disabled pattern; otherwise
|
||||
// the checks are:
|
||||
//
|
||||
// 1. Path is non-empty (the Validate() refuse covers this too, but
|
||||
// preflight reports the specific failure with an actionable error
|
||||
// string + os.Exit(1) at the call site).
|
||||
// 2. File exists + readable.
|
||||
// 3. PEM-decodes to ≥1 CERTIFICATE block.
|
||||
// 4. None of the bundled certs is past NotAfter — an expired trust
|
||||
// anchor would silently reject every client cert at runtime.
|
||||
//
|
||||
// On success, returns the parsed *x509.CertPool ready to inject into the
|
||||
// per-profile SCEPHandler via SetMTLSTrustPool. Each bundled cert also
|
||||
// contributes to the union pool that backs the TLS-layer
|
||||
// VerifyClientCertIfGiven.
|
||||
func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPool, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if bundlePath == "" {
|
||||
return nil, fmt.Errorf("MTLS enabled but trust bundle path empty: " +
|
||||
"set CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH to a PEM file " +
|
||||
"containing the bootstrap-CA certs the operator allows to enroll")
|
||||
}
|
||||
body, err := os.ReadFile(bundlePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read MTLS trust bundle: %w (path=%s)", err, bundlePath)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
rest := body
|
||||
count := 0
|
||||
now := time.Now()
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse MTLS trust bundle cert: %w (path=%s)", err, bundlePath)
|
||||
}
|
||||
if now.After(cert.NotAfter) {
|
||||
return nil, fmt.Errorf("MTLS trust bundle cert expired at %s (subject=%q, path=%s) — replace before restart",
|
||||
cert.NotAfter.Format(time.RFC3339), cert.Subject.CommonName, bundlePath)
|
||||
}
|
||||
pool.AddCert(cert)
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("MTLS trust bundle contained no CERTIFICATE PEM blocks (path=%s)", bundlePath)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// preflightESTMTLSClientCATrustBundle validates a per-profile EST mTLS
|
||||
// client-CA trust bundle and returns a SIGHUP-reloadable holder.
|
||||
//
|
||||
// EST RFC 7030 hardening master bundle Phase 2.5.
|
||||
//
|
||||
// Mirrors preflightSCEPMTLSTrustBundle's checks (file exists, parses as
|
||||
// PEM, ≥1 cert, none expired) but returns a *trustanchor.Holder rather
|
||||
// than a raw *x509.CertPool — the EST handler stores the holder so a
|
||||
// SIGHUP rotates the trust bundle live without a server restart, exactly
|
||||
// the way the Intune trust anchor rotation works (Phase 8.5 of the SCEP
|
||||
// bundle). The handler-side .Pool() accessor on the holder rebuilds an
|
||||
// x509.CertPool from the current snapshot for each Verify call.
|
||||
//
|
||||
// Uses the shared internal/trustanchor.LoadBundle (extracted in EST
|
||||
// hardening Phase 2.1 from the original Intune-only path) so the EST
|
||||
// + Intune callers exercise the same loader semantics — empty bundle
|
||||
// rejected, expired cert rejected with subject in error message,
|
||||
// non-CERTIFICATE PEM blocks tolerated.
|
||||
func preflightESTMTLSClientCATrustBundle(enabled bool, pathID, bundlePath string, logger *slog.Logger) (*trustanchor.Holder, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if bundlePath == "" {
|
||||
return nil, fmt.Errorf("EST profile (PathID=%q) MTLS enabled but trust bundle path empty: "+
|
||||
"set CERTCTL_EST_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH to a PEM file "+
|
||||
"containing the bootstrap-CA certs the operator allows to enroll", pathID)
|
||||
}
|
||||
holder, err := trustanchor.New(bundlePath, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EST profile (PathID=%q) MTLS trust bundle preflight: %w", pathID, err)
|
||||
}
|
||||
holder.SetLabelForLog(fmt.Sprintf("EST mTLS client CA bundle (PathID=%q)", pathID))
|
||||
return holder, nil
|
||||
}
|
||||
|
||||
// preflightSCEPIntuneTrustAnchor validates a per-profile Microsoft Intune
|
||||
// Certificate Connector signing-cert trust bundle.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 8.2.
|
||||
//
|
||||
// No-op when this profile has Intune disabled (the common case for
|
||||
// non-Intune SCEP deploys). When enabled:
|
||||
//
|
||||
// 1. Path is non-empty (Validate() refuse covers this too; we re-check
|
||||
// here so the caller can os.Exit(1) with the specific PathID in the
|
||||
// log line).
|
||||
// 2. File exists + readable.
|
||||
// 3. PEM-decodes to ≥1 CERTIFICATE block (intune.LoadTrustAnchor enforces
|
||||
// this and skips non-CERTIFICATE blocks like accidentally-pasted
|
||||
// priv-key blocks).
|
||||
// 4. None of the bundled certs is past NotAfter — an expired Intune
|
||||
// trust anchor would silently reject every Connector challenge at
|
||||
// runtime, which is a much worse failure mode than failing fast at
|
||||
// boot. intune.LoadTrustAnchor enforces this and surfaces the subject
|
||||
// CN in the error message so the operator knows which cert to rotate.
|
||||
//
|
||||
// On success returns the freshly-built *intune.TrustAnchorHolder ready to
|
||||
// inject into the per-profile SCEPService via SetIntuneIntegration. The
|
||||
// holder also installs the SIGHUP watcher (started by the caller).
|
||||
func preflightSCEPIntuneTrustAnchor(enabled bool, pathID, path string, logger *slog.Logger) (*intune.TrustAnchorHolder, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
// pathIDLabel renders the empty-string PathID as "<root>" so the
|
||||
// operator's boot-log error doesn't read like a missing variable.
|
||||
pathIDLabel := pathID
|
||||
if pathIDLabel == "" {
|
||||
pathIDLabel = "<root>"
|
||||
}
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE enabled but trust anchor path empty: "+
|
||||
"set CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH to a PEM bundle "+
|
||||
"of the Microsoft Intune Certificate Connector's signing certs", pathIDLabel)
|
||||
}
|
||||
holder, err := intune.NewTrustAnchorHolder(path, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE trust anchor load failed: %w (path=%s)", pathIDLabel, err, path)
|
||||
}
|
||||
return holder, nil
|
||||
}
|
||||
|
||||
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
|
||||
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
|
||||
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
|
||||
// indicate a TOCTOU race or a filesystem change between preflight and
|
||||
// the load (rare).
|
||||
//
|
||||
// Cert PEM may carry a chain (CA + RA + intermediate); we use the FIRST
|
||||
// CERTIFICATE block, matching the RFC 8894 §3.5.1 single-cert convention
|
||||
// for the GetCACert response.
|
||||
func loadSCEPRAPair(certPath, keyPath string) (*x509.Certificate, crypto.PrivateKey, error) {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read RA cert: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read RA key: %w", err)
|
||||
}
|
||||
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse RA pair: %w", err)
|
||||
}
|
||||
if len(pair.Certificate) == 0 {
|
||||
return nil, nil, fmt.Errorf("RA cert PEM contained no certificate blocks")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse RA cert: %w", err)
|
||||
}
|
||||
return leaf, pair.PrivateKey, nil
|
||||
}
|
||||
|
||||
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
|
||||
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
|
||||
// pattern; otherwise the checks are:
|
||||
//
|
||||
// 1. Both paths are non-empty (the Validate() refuse covers this too,
|
||||
// but preflight reports the specific failure mode + os.Exit(1) so the
|
||||
// operator sees a clear log line in addition to the config error).
|
||||
// 2. The key file mode is 0600 (refuse world-/group-readable RA key —
|
||||
// defense-in-depth against credential leak via a misconfigured
|
||||
// deploy that leaves /etc/certctl/scep/*.key as 0644).
|
||||
// 3. Cert PEM parses to exactly one x509.Certificate.
|
||||
// 4. Key PEM parses to a Go crypto.Signer (RSA or ECDSA — RFC 8894
|
||||
// §3.5.2 advertises those as the CMS-compatible algorithms).
|
||||
// 5. The cert's PublicKey matches the key's Public() — refuses pairs
|
||||
// accidentally swapped between profiles in a multi-profile config.
|
||||
// 6. The cert's NotAfter is in the future — an expired RA cert would
|
||||
// fail TLS handshake on EnvelopedData decryption per RFC 5652.
|
||||
//
|
||||
// Each check returns a wrapped error; the caller (main) is responsible for
|
||||
// translating to a structured slog.Error + os.Exit(1) so the helper stays
|
||||
// unit-testable without booting the full server.
|
||||
func preflightSCEPRACertKey(enabled bool, raCertPath, raKeyPath string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if raCertPath == "" || raKeyPath == "" {
|
||||
return fmt.Errorf("SCEP enabled but RA pair missing: " +
|
||||
"set CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH " +
|
||||
"(RFC 8894 §3.2.2 requires an RA pair so clients can encrypt the " +
|
||||
"CSR to the RA cert and the server can sign the CertRep response)")
|
||||
}
|
||||
|
||||
// File mode check FIRST so a world-readable key never gets read into the
|
||||
// process address space. Ignored on Windows (Stat().Mode() doesn't carry
|
||||
// POSIX bits there); the production deploy is Linux per the Dockerfile.
|
||||
keyInfo, err := os.Stat(raKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH stat failed: %w (path=%s)", err, raKeyPath)
|
||||
}
|
||||
mode := keyInfo.Mode().Perm()
|
||||
if mode&0o077 != 0 {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH has insecure permissions %#o; "+
|
||||
"RA private key must be mode 0600 (owner read/write only) — "+
|
||||
"chmod 0600 %s and restart", mode, raKeyPath)
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(raCertPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_CERT_PATH read failed: %w (path=%s)", err, raCertPath)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(raKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH read failed: %w (path=%s)", err, raKeyPath)
|
||||
}
|
||||
|
||||
// tls.X509KeyPair validates that the cert + key parse, share an algorithm,
|
||||
// and the cert's PublicKey matches the key's Public() — three of our six
|
||||
// checks in a single stdlib call, so we use it rather than re-implementing.
|
||||
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RA cert/key pair invalid: %w "+
|
||||
"(cert=%s key=%s) — verify the cert and key are matching halves of "+
|
||||
"the same RA pair, both PEM-encoded, with the cert containing exactly "+
|
||||
"one CERTIFICATE block and the key containing one PRIVATE KEY block",
|
||||
err, raCertPath, raKeyPath)
|
||||
}
|
||||
if len(pair.Certificate) == 0 {
|
||||
// Defensive — tls.X509KeyPair already errors on this, but the contract
|
||||
// for the next x509.ParseCertificate call needs the slice non-empty.
|
||||
return fmt.Errorf("RA cert PEM at %s contains no certificate blocks", raCertPath)
|
||||
}
|
||||
|
||||
// Re-parse the leaf so we can read NotAfter + the public-key alg.
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("RA cert at %s does not parse as x509: %w", raCertPath, err)
|
||||
}
|
||||
if time.Now().After(leaf.NotAfter) {
|
||||
return fmt.Errorf("RA cert at %s expired at %s — "+
|
||||
"generate a fresh RA pair (the SCEP CertRep signature would be "+
|
||||
"rejected by every conformant client)", raCertPath, leaf.NotAfter.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// CMS-compatible public-key algorithm gate. RFC 8894 §3.5.2 advertises RSA
|
||||
// and AES; the responder cert algorithm pertains to the signature scheme
|
||||
// used on the CertRep, which means the cert's PublicKey must be RSA or
|
||||
// ECDSA. Catches pre-shared Ed25519 dev keys that micromdm/scep clients
|
||||
// reject.
|
||||
switch leaf.PublicKeyAlgorithm {
|
||||
case x509.RSA, x509.ECDSA:
|
||||
// ok — supported by golang.org/x/crypto/ocsp + every SCEP client
|
||||
default:
|
||||
return fmt.Errorf("RA cert at %s uses unsupported public-key algorithm %s — "+
|
||||
"RFC 8894 §3.5.2 CMS signing requires RSA or ECDSA",
|
||||
raCertPath, leaf.PublicKeyAlgorithm)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
|
||||
// can actually serve a CA certificate. This closes audit finding L-005:
|
||||
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the
|
||||
// registry but did not verify the issuer TYPE could emit a CA cert. An
|
||||
// operator who bound CERTCTL_EST_ISSUER_ID to an ACME issuer (which does
|
||||
// not have a static CA cert — see internal/connector/issuer/acme/acme.go::
|
||||
// GetCACertPEM returning an explicit error) would boot successfully and
|
||||
// only see failures at the first /est/cacerts request, hiding the misconfig
|
||||
// for hours/days behind a degraded enrollment surface.
|
||||
//
|
||||
// Strategy: call issuerConn.GetCACertPEM(ctx) at startup with a short
|
||||
// timeout. If the issuer can serve a CA cert (local, vault, openssl,
|
||||
// stepca, awsacmpca, etc.), the call succeeds and we proceed. If not
|
||||
// (acme, digicert, sectigo, entrust, googlecas, ejbca, globalsign — most
|
||||
// vendor-CA issuers that hand back chains per-issuance), the call fails
|
||||
// loudly with the connector's own error string, and the caller os.Exit(1)s.
|
||||
//
|
||||
// Returns nil on success, non-nil error suitable for structured logging
|
||||
// + os.Exit(1) by the caller. Caller is responsible for the timeout context.
|
||||
func preflightEnrollmentIssuer(ctx context.Context, protocol, issuerID string, issuerConn service.IssuerConnector) error {
|
||||
if issuerConn == nil {
|
||||
return fmt.Errorf("%s issuer %q: connector is nil", protocol, issuerID)
|
||||
}
|
||||
caCertPEM, err := issuerConn.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s issuer %q: cannot serve CA certificate (%w); "+
|
||||
"choose an issuer type that exposes a static CA chain "+
|
||||
"(local / vault / openssl / stepca / awsacmpca) or disable %s",
|
||||
protocol, issuerID, err, protocol)
|
||||
}
|
||||
if caCertPEM == "" {
|
||||
return fmt.Errorf("%s issuer %q: GetCACertPEM returned empty PEM with no error; "+
|
||||
"choose an issuer type that exposes a static CA chain", protocol, issuerID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildFinalHandler builds the outer HTTP dispatch handler that routes incoming
|
||||
// requests to either the authenticated apiHandler chain or the unauthenticated
|
||||
// noAuthHandler chain based on URL path prefix. Extracted from main() so the
|
||||
// dispatch logic can be unit tested without booting the full server stack
|
||||
// (see cmd/server/finalhandler_test.go).
|
||||
//
|
||||
// Dispatch rules (M-001, audit 2026-04-19, option D):
|
||||
//
|
||||
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
|
||||
// - /api/v1/version → no-auth (U-3 ride-along: build identity for rollout/probes)
|
||||
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
|
||||
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
|
||||
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
|
||||
// - /api/v1/* → auth (Bearer token required)
|
||||
// - /assets/* → static file server (dashboard only)
|
||||
// - anything else → SPA index.html fallback (dashboard only)
|
||||
// OR apiHandler (no dashboard)
|
||||
//
|
||||
// EST/SCEP clients (IoT devices, 802.1X supplicants, MDM endpoints, network
|
||||
// appliances) cannot present certctl Bearer tokens, so those endpoints must be
|
||||
// reachable without the Auth middleware. Authentication is instead enforced by
|
||||
// CSR signature verification, profile policy gates, and for SCEP the
|
||||
// challengePassword shared secret (fail-loud gated by preflightSCEPChallengePassword
|
||||
// above).
|
||||
//
|
||||
// webDir must point to a directory containing index.html + assets/ when
|
||||
// dashboardEnabled is true; it is ignored otherwise.
|
||||
func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, dashboardEnabled bool) http.Handler {
|
||||
var fileServer http.Handler
|
||||
if dashboardEnabled {
|
||||
fileServer = http.FileServer(http.Dir(webDir))
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// Health/ready, auth/info, and version bypass auth middleware.
|
||||
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
||||
// auth/info: React app calls this before login to detect auth mode.
|
||||
// version: U-3 ride-along (cat-u-no_version_endpoint) — rollout
|
||||
// systems and blackbox probes need build identity without a key.
|
||||
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" || path == "/api/v1/version" {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 5280 CRL and RFC 6960 OCSP live under /.well-known/pki/ and MUST
|
||||
// be served unauthenticated — relying parties (browsers, OpenSSL, OCSP
|
||||
// stapling sidecars, mTLS clients) cannot present certctl Bearer tokens.
|
||||
if strings.HasPrefix(path, "/.well-known/pki") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 7030 EST endpoints ride the no-auth middleware chain (M-001,
|
||||
// option D, audit 2026-04-19). Trust boundary is CSR signature +
|
||||
// (per EST hardening Phase 2) optional client cert at the handler
|
||||
// layer, not HTTP Bearer. /.well-known/est/cacerts is explicitly
|
||||
// anonymous per RFC 7030 §4.1.1; /.well-known/est-mtls/<PathID>/
|
||||
// (EST hardening Phase 2 sibling route) requires a client cert
|
||||
// gate at the handler layer — both share this prefix gate because
|
||||
// "/.well-known/est-mtls" is itself prefixed by "/.well-known/est".
|
||||
// EST hardening Phase 3's HTTP Basic enrollment-password is a
|
||||
// per-profile handler-layer auth that runs INSIDE the no-auth
|
||||
// middleware chain (since the chain skips the Bearer middleware,
|
||||
// the handler gets to define its own auth contract).
|
||||
if strings.HasPrefix(path, "/.well-known/est") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 8894 SCEP rides the no-auth chain (M-001, option D). SCEP clients
|
||||
// authenticate via the challengePassword attribute in the PKCS#10 CSR,
|
||||
// not via HTTP Bearer tokens. preflightSCEPChallengePassword refuses to
|
||||
// start the server if SCEP is enabled without a non-empty shared secret.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: the sibling
|
||||
// /scep-mtls[/<pathID>] route also rides the no-auth chain. Its
|
||||
// auth boundary is (a) client cert verified at the TLS layer +
|
||||
// re-verified per-profile at the handler layer, plus (b) the
|
||||
// challenge password — neither is a Bearer token. The /scepxyz
|
||||
// vs /scep-mtls disambiguation: 'xyz' starts with a letter so the
|
||||
// HasPrefix(path, "/scep/") gate doesn't match it; 'mtls' is its
|
||||
// own dedicated prefix gated below to avoid the same overlap.
|
||||
if path == "/scep" || strings.HasPrefix(path, "/scep/") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if path == "/scep-mtls" || strings.HasPrefix(path, "/scep-mtls/") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticated API routes — full middleware stack including Auth.
|
||||
if strings.HasPrefix(path, "/api/v1/") {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !dashboardEnabled {
|
||||
// No dashboard: everything non-special falls through to the
|
||||
// authenticated handler (preserves pre-M-001 behavior for API-only
|
||||
// deployments).
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Dashboard-present: serve static assets directly, SPA fallback for
|
||||
// everything else.
|
||||
if strings.HasPrefix(path, "/assets/") {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, webDir+"/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
// authPermissionCheckerAdapter bridges the typed-string Authorizer
|
||||
// signature (authsvc.Authorizer.CheckPermission takes
|
||||
// authdomain.ActorTypeValue + authdomain.ScopeType) to the plain-string
|
||||
// auth.PermissionChecker interface used by the auth.RequirePermission
|
||||
// middleware factory. Lives in cmd/server so internal/auth doesn't have
|
||||
// to import internal/service/auth + internal/domain/auth (would create
|
||||
// a cycle).
|
||||
type authPermissionCheckerAdapter struct {
|
||||
a *authsvc.Authorizer
|
||||
}
|
||||
|
||||
func (ad authPermissionCheckerAdapter) CheckPermission(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType string,
|
||||
tenantID string,
|
||||
permission string,
|
||||
scopeType string,
|
||||
scopeID *string,
|
||||
) (bool, error) {
|
||||
return ad.a.CheckPermission(
|
||||
ctx,
|
||||
actorID,
|
||||
authdomainAlias.ActorTypeValue(actorType),
|
||||
tenantID,
|
||||
permission,
|
||||
authdomainAlias.ScopeType(scopeType),
|
||||
scopeID,
|
||||
)
|
||||
}
|
||||
|
||||
// authCheckResolverAdapter bridges the postgres ActorRoleRepository
|
||||
// (authdomain.ActorTypeValue) to handler.AuthCheckResolver
|
||||
// (domain.ActorType). Lives in cmd/server so the handler layer keeps its
|
||||
// existing import set; the GUI's /v1/auth/check probe round-trips
|
||||
// through this on every page load. Read-only — no caller / no audit row.
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): the equivalent surface area on
|
||||
// /v1/auth/me runs through the service layer's auth.role.list permission
|
||||
// gate, which the GUI may not yet hold during initial render. AuthCheck
|
||||
// has no permission gate (its only requirement is "the request
|
||||
// authenticated"), so the bypass is by design.
|
||||
type authCheckResolverAdapter struct {
|
||||
repo *postgres.ActorRoleRepository
|
||||
}
|
||||
|
||||
func (ad authCheckResolverAdapter) ListRoles(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType domain.ActorType,
|
||||
tenantID string,
|
||||
) ([]*authdomainAlias.ActorRole, error) {
|
||||
return ad.repo.ListByActor(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
||||
}
|
||||
|
||||
func (ad authCheckResolverAdapter) EffectivePermissions(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType domain.ActorType,
|
||||
tenantID string,
|
||||
) ([]repository.EffectivePermission, error) {
|
||||
return ad.repo.EffectivePermissions(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// sessionMinterAdapter — bridge from *session.Service to oidcsvc.SessionMinter.
|
||||
//
|
||||
// The OIDC service's SessionMinter port (Phase 3) takes a *userdomain.User
|
||||
// + role IDs and returns (cookie, csrf, err). The session.Service's
|
||||
// Create method takes (actorID, actorType, ip, ua) -> *CreateResult.
|
||||
// This adapter unwraps the User into actorID/actorType + reshapes the
|
||||
// return tuple. Lives in cmd/server so the session package doesn't have
|
||||
// to know about user.User and the user package doesn't have to know
|
||||
// about session.CreateResult.
|
||||
// =============================================================================
|
||||
|
||||
type sessionMinterAdapter struct {
|
||||
svc *session.Service
|
||||
}
|
||||
|
||||
func (a *sessionMinterAdapter) MintForUser(
|
||||
ctx context.Context,
|
||||
user *userdomain.User,
|
||||
_ []string, // roleIDs unused at the session-mint layer; the rbac middleware looks them up at request time
|
||||
ip, userAgent string,
|
||||
) (cookieValue, csrfToken string, err error) {
|
||||
if user == nil {
|
||||
return "", "", fmt.Errorf("session mint: user is nil")
|
||||
}
|
||||
res, err := a.svc.Create(ctx, user.ID, string(domain.ActorTypeUser), ip, userAgent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return res.CookieValue, res.CSRFToken, nil
|
||||
}
|
||||
|
||||
// silenceUnusedImports keeps the new oidcsvc + oidcdomain imports load-
|
||||
// bearing in case any file shuffles. Linker dead-code elimination handles
|
||||
// the runtime cost.
|
||||
var (
|
||||
_ = oidcdomain.OIDCProvider{}
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// breakglassSessionMinterAdapter — bridge from *session.Service to
|
||||
// breakglass.SessionMinter.
|
||||
//
|
||||
// The break-glass service's SessionMinter port (Phase 7.5) returns
|
||||
// (cookie, csrf, err); the underlying *session.Service.Create returns
|
||||
// *CreateResult. This adapter unwraps the result. Lives in cmd/server
|
||||
// so the breakglass package doesn't have to know about session.Service.
|
||||
// =============================================================================
|
||||
|
||||
type breakglassSessionMinterAdapter struct {
|
||||
svc *session.Service
|
||||
}
|
||||
|
||||
func (a breakglassSessionMinterAdapter) Create(ctx context.Context, actorID, actorType, ip, userAgent string) (string, string, error) {
|
||||
res, err := a.svc.Create(ctx, actorID, actorType, ip, userAgent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return res.CookieValue, res.CSRFToken, nil
|
||||
}
|
||||
|
||||
// RevokeAllForActor — Audit 2026-05-10 HIGH-1 wire. After a break-glass
|
||||
// password rotation or credential removal, every active session for the
|
||||
// target actor must be revoked so a phished-then-rotated credential
|
||||
// doesn't leave the attacker's session live.
|
||||
func (a breakglassSessionMinterAdapter) RevokeAllForActor(ctx context.Context, actorID, actorType string) error {
|
||||
return a.svc.RevokeAllForActor(ctx, actorID, actorType)
|
||||
}
|
||||
|
||||
// oidcProvidersListAdapter bridges the postgres OIDCProviderRepository
|
||||
// to handler.OIDCProvidersListResolver. The handler returns
|
||||
// []*OIDCProviderInfo (id + display_name + login_url) for the public-
|
||||
// safe GUI Login-page payload; the repo returns the full OIDCProvider
|
||||
// row. The adapter projects + maps the login_url shape that
|
||||
// /auth/oidc/login?provider=<id> expects. Auth Bundle 2 Phase 6 /
|
||||
// Category E.
|
||||
type oidcProvidersListAdapter struct {
|
||||
repo repository.OIDCProviderRepository
|
||||
}
|
||||
|
||||
func (a oidcProvidersListAdapter) List(ctx context.Context, tenantID string) ([]*handler.OIDCProviderInfo, error) {
|
||||
provs, err := a.repo.List(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*handler.OIDCProviderInfo, 0, len(provs))
|
||||
for _, p := range provs {
|
||||
// Audit 2026-05-10 MED-9 closure — filter disabled providers
|
||||
// at the adapter so the LoginPage's "Sign in with X" buttons
|
||||
// don't render for offline IdPs. The HandleAuthRequest
|
||||
// service-layer ErrProviderDisabled check is the
|
||||
// defense-in-depth guard for direct API / MCP / CLI callers.
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, &handler.OIDCProviderInfo{
|
||||
ID: p.ID,
|
||||
DisplayName: p.Name,
|
||||
LoginURL: "/auth/oidc/login?provider=" + p.ID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+6
-37
@@ -1,39 +1,8 @@
|
||||
# certctl Docker Compose environment variables (Bundle 2 — 2026-05-12)
|
||||
#
|
||||
# Copy this file to deploy/.env and customize. The production-shaped base
|
||||
# compose (docker-compose.yml) requires every variable below to be set;
|
||||
# the Bundle 2 fail-closed startup guards REFUSE TO BOOT if any value
|
||||
# remains at a "change-me-..." or "replace-with-..." placeholder outside
|
||||
# demo mode (CERTCTL_DEMO_MODE_ACK=true).
|
||||
#
|
||||
# DEMO PATH (zero-config, populated dashboard, demo-mode auth):
|
||||
# docker compose -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml up -d --build
|
||||
# The demo overlay supplies its own placeholder values plus DEMO_MODE_ACK
|
||||
# so this .env is NOT needed.
|
||||
#
|
||||
# PRODUCTION PATH (this .env is required):
|
||||
# docker compose -f deploy/docker-compose.yml up -d
|
||||
# certctl Docker Compose environment variables
|
||||
# Copy this file to .env and customize for your deployment
|
||||
|
||||
# PostgreSQL password — openssl rand -hex 32
|
||||
POSTGRES_PASSWORD=replace-with-openssl-rand-hex-32
|
||||
# PostgreSQL password (change in production!)
|
||||
POSTGRES_PASSWORD=certctl
|
||||
|
||||
# Server API-key secret — openssl rand -base64 32
|
||||
CERTCTL_AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||
|
||||
# Bundled-agent API key (matches one of the server's AUTH_SECRET rotation
|
||||
# values). Generate with: openssl rand -base64 32
|
||||
CERTCTL_API_KEY=replace-with-openssl-rand-base64-32
|
||||
|
||||
# AES-256-GCM key for encrypting issuer/target config secrets at rest.
|
||||
# Minimum 32 bytes. Generate with: openssl rand -base64 32
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY=replace-with-openssl-rand-base64-32
|
||||
|
||||
# Agent ID returned from `POST /api/v1/agents` during agent enrollment.
|
||||
# Without this the bundled certctl-agent service fail-fasts at startup.
|
||||
# CERTCTL_AGENT_ID=agent-from-registration-response
|
||||
|
||||
# Day-0 admin bootstrap token (optional — generate with: openssl rand -hex 32).
|
||||
# When set, POST /api/v1/auth/bootstrap mints the first admin actor + API
|
||||
# key. When unset (default), that endpoint returns 410 Gone.
|
||||
# CERTCTL_BOOTSTRAP_TOKEN=
|
||||
# Agent API key (change in production! Generate with: openssl rand -hex 32)
|
||||
CERTCTL_API_KEY=change-me-in-production
|
||||
|
||||
+15
-52
@@ -62,9 +62,7 @@ A compose file defines **services** (containers), **networks** (how they talk to
|
||||
## Base Environment
|
||||
|
||||
**File:** `docker-compose.yml`
|
||||
**When to use:** Production deployments and any time you want a clean, production-shaped stack with real authentication enforced.
|
||||
|
||||
**Bundle 2 closure (2026-05-12):** the base compose was split from the demo overlay. Pre-Bundle-2 this file IS the demo path (auth=none, keygen=server, demo-seed=true, change-me placeholder credentials baked in). Operators reading "drop the demo overlay for a clean install" were not getting a clean install — they were getting a demo stack with the overlay's data layer stripped off. Post-Bundle-2 the base ships production-shaped: `CERTCTL_AUTH_TYPE` defaults to `api-key`, `CERTCTL_KEYGEN_MODE` defaults to `agent`, demo-mode + demo-seed default to false, and every credential placeholder is rejected at startup. The demo path is now a single overlay flag away (`-f deploy/docker-compose.demo.yml`).
|
||||
**When to use:** Production deployments, first-time setup, or any time you want a clean dashboard with the onboarding wizard.
|
||||
|
||||
### What it runs
|
||||
|
||||
@@ -79,22 +77,11 @@ Three services on a private bridge network:
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/certctl-io/certctl.git
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
|
||||
# Required: provide real credentials. Without this step the server fail-fasts
|
||||
# at startup on the Bundle 2 placeholder-credential guards.
|
||||
cp .env.example deploy/.env
|
||||
$EDITOR deploy/.env
|
||||
# Set: POSTGRES_PASSWORD, CERTCTL_AUTH_SECRET, CERTCTL_API_KEY,
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY (all via `openssl rand -base64 32`),
|
||||
# CERTCTL_AGENT_ID (returned from `POST /api/v1/agents`).
|
||||
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
If you just want to kick the tires without writing a `.env`, use the demo overlay instead — see [Demo Overlay](#demo-overlay) below.
|
||||
|
||||
`--build` compiles the Go server and agent from source, including the React frontend. Without it, Docker may reuse a stale image from a previous build.
|
||||
|
||||
`-d` runs in detached mode (background). Omit it to see logs in your terminal.
|
||||
@@ -135,8 +122,6 @@ The `volumes` section mounts 10 migration files into PostgreSQL's init directory
|
||||
|
||||
**Expert note:** The numbered prefix pattern (`001_`, `002_`, ..., `020_`) ensures deterministic execution order. All migrations use `IF NOT EXISTS` and `ON CONFLICT DO NOTHING` for idempotency, so re-running them against an existing database is safe.
|
||||
|
||||
**Stateful volume — first-boot password binding (U-1).** The same "first boot only" semantics that govern migration scripts also govern `POSTGRES_PASSWORD`. The official `postgres` image runs `initdb` exactly once — when `/var/lib/postgresql/data` is empty — and that pass is the only time `POSTGRES_PASSWORD` is written into `pg_authid`. On every subsequent boot, the postgres container ignores the env var and authenticates against whatever password was baked into the data directory on the original `up`. Editing `POSTGRES_PASSWORD` in `.env` after a successful first boot therefore only updates the **certctl-server** container's `CERTCTL_DATABASE_URL` — postgres still expects the previous password, and the server fails to ping with `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01). The certctl-server container surfaces this case explicitly: when SQLSTATE 28P01 fires at startup, the wrap text in `internal/repository/postgres/db.go::wrapPingError` points operators at the two remediation paths — destructive volume teardown via `docker compose -f deploy/docker-compose.yml down -v && up -d --build`, or non-destructive in-place rotation via `docker compose -f deploy/docker-compose.yml exec postgres psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"` followed by a server restart with the matching `POSTGRES_PASSWORD`. Use the destructive path on the demo / first-time setup; use the non-destructive path on any environment that holds data you want to keep.
|
||||
|
||||
#### certctl Server
|
||||
|
||||
```yaml
|
||||
@@ -145,16 +130,14 @@ certctl-server:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
# Bundle 2 (2026-05-12): no auth-type / keygen-mode override here.
|
||||
# Code defaults (api-key + agent) take effect; the demo overlay flips
|
||||
# both to demo-mode (none + server).
|
||||
CERTCTL_AUTH_SECRET: ${CERTCTL_AUTH_SECRET}
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY}
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key}
|
||||
```
|
||||
|
||||
The server is the control plane. It serves the REST API, the React dashboard, runs 7 background scheduler loops (renewal, job processing, health checks, notifications, short-lived cert expiry, network scanning, digest emails), and manages the issuer/target registry.
|
||||
@@ -162,10 +145,9 @@ The server is the control plane. It serves the REST API, the React dashboard, ru
|
||||
Key environment variables explained:
|
||||
|
||||
- `CERTCTL_DATABASE_URL` references the `postgres` service by hostname. Docker's internal DNS resolves `postgres` to the container's IP on the bridge network. `sslmode=disable` is appropriate because traffic stays on the private Docker network.
|
||||
- `CERTCTL_AUTH_TYPE` defaults to `api-key` in the code (`internal/config/config.go`); the base compose does NOT override it. To run demo-mode auth (every request served as the synthetic admin actor), layer the demo overlay on top.
|
||||
- `CERTCTL_AUTH_SECRET` is the API-key value the server accepts. The Bundle 2 fail-closed guard rejects the literal placeholder `change-me-in-production` outside demo mode. Generate with `openssl rand -base64 32`.
|
||||
- `CERTCTL_KEYGEN_MODE` defaults to `agent` in the code (the base compose does NOT override it). Production deploys leave it there so private keys stay on agent infrastructure; the demo overlay flips it to `server` so the demo can issue + hold the key on the server box without an agent dance.
|
||||
- `CERTCTL_CONFIG_ENCRYPTION_KEY` enables AES-256-GCM encryption for issuer and target configurations stored in the database (credentials, API keys). Required for any deploy that adds issuers via the GUI. The Bundle 2 fail-closed guard rejects the literal placeholder `change-me-32-char-encryption-key` outside demo mode. Generate with `openssl rand -base64 32` (≥ 32 bytes).
|
||||
- `CERTCTL_AUTH_TYPE: none` disables API key authentication so you can explore immediately. For production, set `api-key` and configure `CERTCTL_AUTH_SECRET`.
|
||||
- `CERTCTL_KEYGEN_MODE: server` means the server generates private keys. This is convenient for demos but insecure for production. In production, set `agent` so keys are generated on agent machines and never transmitted.
|
||||
- `CERTCTL_CONFIG_ENCRYPTION_KEY` enables AES-256-GCM encryption for issuer and target configurations stored in the database (credentials, API keys). Without this, the dynamic configuration GUI (adding issuers/targets from the dashboard) won't encrypt sensitive fields. For production, generate a strong random key.
|
||||
- `CERTCTL_NETWORK_SCAN_ENABLED` activates the scheduler loop that probes TLS endpoints on your network to discover certificates you might not be managing.
|
||||
|
||||
**Expert note:** The healthcheck hits `GET /health` every 10 seconds with 5 retries. The `depends_on: condition: service_healthy` on the agent means Docker holds agent startup until this check passes. Resource limits (`cpus: '1.0'`, `memory: 512M`) prevent the server from consuming unbounded resources in shared environments.
|
||||
@@ -178,12 +160,8 @@ certctl-agent:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
# Bundle 2 (2026-05-12): no placeholder fallbacks. Operators MUST
|
||||
# set CERTCTL_API_KEY + CERTCTL_AGENT_ID in deploy/.env. The agent
|
||||
# binary fail-fasts at startup when CERTCTL_AGENT_ID is unset.
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY}
|
||||
CERTCTL_AGENT_ID: ${CERTCTL_AGENT_ID}
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
|
||||
CERTCTL_AGENT_NAME: docker-agent
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys
|
||||
@@ -214,18 +192,11 @@ docker compose -f deploy/docker-compose.yml down -v
|
||||
## Demo Overlay
|
||||
|
||||
**File:** `docker-compose.demo.yml`
|
||||
**When to use:** Demos, screenshots, stakeholder presentations, or any time you want a one-command zero-config evaluation stack with a populated dashboard.
|
||||
**When to use:** Demos, screenshots, stakeholder presentations, or any time you want a populated dashboard on first boot.
|
||||
|
||||
### What it adds
|
||||
|
||||
Bundle 2 closure (2026-05-12) moved every demo-mode env var out of the base compose into this overlay. The overlay now carries:
|
||||
|
||||
- `CERTCTL_AUTH_TYPE=none` + `CERTCTL_DEMO_MODE_ACK=true` — demo-mode synthetic admin actor (`actor-demo-anon`). The server emits a prominent ⚠ DEMO MODE WARN banner at boot with a production-promotion checklist (`cmd/server/main.go`).
|
||||
- `CERTCTL_KEYGEN_MODE=server` — demo-only server-side keygen.
|
||||
- `CERTCTL_DEMO_SEED=true` — the server applies `migrations/seed_demo.sql` at boot via `postgres.RunDemoSeed`, inserting 180 days of simulated operational history (teams, owners, certificates, agents, jobs, discovery results, audit events, policies, profiles).
|
||||
- Fixed weak `POSTGRES_PASSWORD=certctl`, `CERTCTL_AUTH_SECRET=change-me-in-production`, `CERTCTL_CONFIG_ENCRYPTION_KEY=change-me-32-char-encryption-key`, `CERTCTL_API_KEY=change-me-in-production`, `CERTCTL_AGENT_ID=agent-demo-1` — placeholder credentials the Bundle 2 fail-closed `Validate()` rejects outside demo mode, but the demo overlay's `DEMO_MODE_ACK=true` unlocks them.
|
||||
|
||||
Pre-U-3 the overlay used to mount `seed_demo.sql` into PostgreSQL's `/docker-entrypoint-initdb.d/` and rely on initdb-time application. That worked only because the production stack also mounted the migrations there, so the schema existed when initdb ran. Once U-3 dropped the production initdb mounts (single source of truth: server runs `RunMigrations` + `RunSeed` at boot), the demo seed could no longer be applied at initdb time — the tables it references wouldn't exist yet. Post-U-3 the overlay is an override file with no `image:` / `build:` of its own; it MUST be passed alongside the base, or compose errors with `service "certctl-server" has neither an image nor a build context specified`.
|
||||
One line: mounts `seed_demo.sql` into PostgreSQL's init directory. This 667-line SQL file inserts 180 days of simulated operational history: teams, owners, certificates across multiple issuers, agents on different platforms, jobs with realistic timestamps, discovery scan results, audit events, policies, and profiles.
|
||||
|
||||
### Starting it
|
||||
|
||||
@@ -407,7 +378,7 @@ Every `CERTCTL_*` environment variable is read by the server's `internal/config/
|
||||
| `CERTCTL_SERVER_HOST` | `0.0.0.0` | Listen address |
|
||||
| `CERTCTL_SERVER_PORT` | `8443` | Listen port |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `none`, or `oidc` (Auth Bundle 2). |
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key` or `none` |
|
||||
| `CERTCTL_AUTH_SECRET` | (none) | API key(s), comma-separated for rotation |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo) |
|
||||
| `CERTCTL_CONFIG_ENCRYPTION_KEY` | (none) | AES-256-GCM key for encrypting issuer/target configs in DB |
|
||||
@@ -417,13 +388,6 @@ Every `CERTCTL_*` environment variable is read by the server's `internal/config/
|
||||
| `CERTCTL_CORS_ORIGINS` | (empty) | Allowed CORS origins, comma-separated. Empty = deny all cross-origin |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `10` | Requests per second per client |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `20` | Burst allowance above RPS |
|
||||
| `CERTCTL_AGENT_BOOTSTRAP_TOKEN` | (empty) | Agent-registration bootstrap secret. Empty = v2.1.x warn-mode pass-through. Set to a real value (`openssl rand -base64 32`); the deny-empty flag's default flip in v2.2.0 will require it. |
|
||||
| `CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY` | `false` | Phase 2 SEC-H1 staged flag. When `true`, the server refuses to start unless `CERTCTL_AGENT_BOOTSTRAP_TOKEN` is non-empty. Default flip to `true` scheduled for v2.2.0. |
|
||||
| `CERTCTL_DEMO_MODE_ACK` | `false` | Acknowledges demo-mode synthetic admin posture (required when `CERTCTL_AUTH_TYPE=none` binds to a non-loopback host). Must be paired with `CERTCTL_DEMO_MODE_ACK_TS` per Phase 2 SEC-H3. |
|
||||
| `CERTCTL_DEMO_MODE_ACK_TS` | (empty) | Phase 2 SEC-H3: unix-epoch timestamp at which DemoModeAck was last acknowledged. When `CERTCTL_DEMO_MODE_ACK=true`, this must parse as a unix epoch within the last 24h. Set via `CERTCTL_DEMO_MODE_ACK_TS=$(date +%s)` at every `docker compose up`. |
|
||||
| `CERTCTL_ACME_INSECURE_ACK` | `false` | Phase 2 SEC-M4: explicit ACK required to boot with `CERTCTL_ACME_INSECURE=true`. Production deploys MUST never set either flag. |
|
||||
| `CERTCTL_DATABASE_MAX_CONNS` | `50` | Phase 6 SCALE-M1: max open DB connections in the server's pool. Default was `25` pre-Phase-6. Idle connections = max/5. Operator-tune ladder for larger fleets: ≤500 certs → 50; 5K certs → 100; 50K certs → 200 (also raise Postgres `max_connections`). See `docs/operator/scale.md`. |
|
||||
| `CERTCTL_ASYNC_POLL_MAX_WAIT_SECONDS` | (unset → 600) | Phase 6 SCALE-M3: process-wide override for the asyncpoll package's `DefaultMaxWait` (10 minutes). Caps total wall-clock time the certctl-server spends polling an async CA (DigiCert / Entrust / GlobalSign / Sectigo) before returning `StillPending` to the scheduler for re-enqueue. Per-connector overrides (`CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS`, etc.) take precedence when set. |
|
||||
|
||||
### Agent
|
||||
|
||||
@@ -432,7 +396,7 @@ Every `CERTCTL_*` environment variable is read by the server's `internal/config/
|
||||
| `CERTCTL_SERVER_URL` | (required) | Server API URL |
|
||||
| `CERTCTL_API_KEY` | (none) | API key for authenticating with server |
|
||||
| `CERTCTL_AGENT_NAME` | (hostname) | Display name in dashboard |
|
||||
| `CERTCTL_AGENT_ID` | (none — required) | Stable agent identifier returned from `POST /api/v1/agents`. The agent binary fail-fasts at startup if unset. |
|
||||
| `CERTCTL_AGENT_ID` | (auto-generated) | Stable agent identifier |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Must match server setting |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for private key storage (0600 perms) |
|
||||
@@ -447,7 +411,6 @@ Every `CERTCTL_*` environment variable is read by the server's `internal/config/
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | `http-01`, `dns-01`, or `dns-persist-01` |
|
||||
| `CERTCTL_ACME_INSECURE` | Skip TLS verification for ACME CA (test only) |
|
||||
| `CERTCTL_ACME_EAB_KID` / `CERTCTL_ACME_EAB_HMAC` | External Account Binding for ZeroSSL, Google Trust Services |
|
||||
| `CERTCTL_ZEROSSL_EAB_URL` | Override the ZeroSSL EAB-credentials endpoint (defaults to the public ZeroSSL URL; only set for ZeroSSL staging or a private mirror) |
|
||||
| `CERTCTL_ACME_ARI_ENABLED` | Enable RFC 9773 Renewal Information |
|
||||
| `CERTCTL_ACME_PROFILE` | ACME profile (`tlsserver`, `shortlived`) |
|
||||
| `CERTCTL_STEPCA_URL` | step-ca server URL |
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/demo-up.sh — boot the certctl demo stack with the fresh
|
||||
# CERTCTL_DEMO_MODE_ACK_TS the Phase 2 SEC-H3 guard requires.
|
||||
#
|
||||
# The demo overlay sets CERTCTL_DEMO_MODE_ACK=true. Phase 2 SEC-H3
|
||||
# (2026-05-13) pairs that with a fail-closed requirement: the server
|
||||
# refuses to start unless CERTCTL_DEMO_MODE_ACK_TS=<unix-epoch> is set
|
||||
# and is within the last 24h (with 1-minute future clock-skew tolerance).
|
||||
#
|
||||
# A static value in docker-compose.demo.yml would rot the next day, so
|
||||
# the overlay passthroughs the value from the shell environment. This
|
||||
# helper mints a fresh TS at run time and forwards any extra args to
|
||||
# `docker compose up`, so operators can use it as a drop-in replacement
|
||||
# for the bare command. Example:
|
||||
#
|
||||
# ./demo-up.sh -d # cold boot in detached mode
|
||||
# ./demo-up.sh -d --pull always # forward any flags through
|
||||
#
|
||||
# The cold-DB compose smoke in .github/workflows/ci.yml does the same
|
||||
# thing inline; this script exists so local operators don't have to
|
||||
# remember the export.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# cd to the deploy/ dir so the relative `-f` paths resolve regardless
|
||||
# of where the operator invokes this from. The script lives next to
|
||||
# the compose files it references.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
export CERTCTL_DEMO_MODE_ACK_TS="$(date +%s)"
|
||||
|
||||
echo "[demo-up] minting CERTCTL_DEMO_MODE_ACK_TS=$CERTCTL_DEMO_MODE_ACK_TS"
|
||||
echo "[demo-up] running: docker compose -f docker-compose.yml -f docker-compose.demo.yml up $*"
|
||||
|
||||
exec docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.demo.yml \
|
||||
up "$@"
|
||||
@@ -1,125 +1,14 @@
|
||||
# =============================================================================
|
||||
# certctl DEMO overlay — Bundle 2 (2026-05-12)
|
||||
# =============================================================================
|
||||
# Demo mode: pre-populated dashboard with 32 certificates, 8 agents, 10 issuers, etc.
|
||||
# Use this to showcase certctl's dashboard with realistic data.
|
||||
#
|
||||
# Layered on top of the production-shaped base (docker-compose.yml) to give
|
||||
# operators a one-command, zero-config demo path:
|
||||
#
|
||||
# deploy/demo-up.sh -d --build
|
||||
#
|
||||
# (which forwards args to `docker compose up` after exporting the fresh
|
||||
# CERTCTL_DEMO_MODE_ACK_TS that Phase 2 SEC-H3 requires). Equivalent
|
||||
# manual invocation:
|
||||
#
|
||||
# CERTCTL_DEMO_MODE_ACK_TS=$(date +%s) docker compose \
|
||||
# -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml up -d --build
|
||||
#
|
||||
# What this overlay does:
|
||||
#
|
||||
# 1. Flips CERTCTL_AUTH_TYPE=none + CERTCTL_DEMO_MODE_ACK=true. Every
|
||||
# request is served as the synthetic admin actor `actor-demo-anon`;
|
||||
# the server emits a prominent ⚠ DEMO MODE WARN banner at boot with
|
||||
# a production-promotion checklist (cmd/server/main.go::emitDemoBanner).
|
||||
# Phase 2 SEC-H3 (2026-05-13) pairs DEMO_MODE_ACK with a required
|
||||
# DEMO_MODE_ACK_TS within the last 24h. The overlay reads
|
||||
# ${CERTCTL_DEMO_MODE_ACK_TS:-} from the shell — use deploy/demo-up.sh
|
||||
# (which exports a fresh TS) instead of bare `docker compose up`.
|
||||
#
|
||||
# 2. Flips CERTCTL_KEYGEN_MODE=server (the demo issues + holds the key on
|
||||
# the server to keep the dashboard populated; production deploys must
|
||||
# use the default `agent` mode where keys never leave the agent box).
|
||||
#
|
||||
# 3. Flips CERTCTL_DEMO_SEED=true. The server applies migrations/seed_demo.sql
|
||||
# at boot via postgres.RunDemoSeed AFTER baseline migrations + seed.sql,
|
||||
# pre-seeding 180 days of simulated history across 13 issuers + 8 agents.
|
||||
#
|
||||
# 4. Supplies the change-me-... placeholder values for POSTGRES_PASSWORD,
|
||||
# CERTCTL_API_KEY, CERTCTL_CONFIG_ENCRYPTION_KEY, and CERTCTL_AGENT_ID
|
||||
# so the demo runs without a deploy/.env file. The Bundle 2 fail-closed
|
||||
# Validate() rejects these placeholders outside demo mode, so this only
|
||||
# works alongside DEMO_MODE_ACK=true.
|
||||
#
|
||||
# U-3 history: pre-U-3 this overlay mounted seed_demo.sql into postgres
|
||||
# `/docker-entrypoint-initdb.d/`. That worked only because the production
|
||||
# stack also mounted the migrations there. Once U-3 dropped the production
|
||||
# initdb mounts (single source of truth: server runs RunMigrations + RunSeed
|
||||
# at boot), the demo seed could no longer be applied at initdb time — the
|
||||
# tables it references wouldn't exist yet. Post-U-3 the overlay just sets
|
||||
# CERTCTL_DEMO_SEED=true; the server applies seed_demo.sql at boot via
|
||||
# postgres.RunDemoSeed AFTER baseline migrations + seed.sql.
|
||||
#
|
||||
# Bundle 2 history: pre-Bundle-2 the base compose IS this demo path; this
|
||||
# overlay was a single-flag thin shim. Bundle 2 split the demo env vars
|
||||
# out of the base so `docker compose -f deploy/docker-compose.yml up`
|
||||
# (no overlay) boots production-shaped — which is what every operator
|
||||
# reading the README quickstart line "drop the demo overlay for a clean
|
||||
# install" expected. The overlay carries the full demo posture now.
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||
#
|
||||
# To start fresh (wipe previous data):
|
||||
# docker compose -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml down -v
|
||||
# deploy/demo-up.sh -d --build
|
||||
# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v
|
||||
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||
|
||||
services:
|
||||
postgres:
|
||||
# Fixed weak password is intentional for the no-setup demo path.
|
||||
# See docker-compose.yml for the production override pattern.
|
||||
environment:
|
||||
POSTGRES_PASSWORD: certctl
|
||||
|
||||
certctl-server:
|
||||
environment:
|
||||
# Demo-mode auth: every request served as the synthetic
|
||||
# `actor-demo-anon` admin. The server's HIGH-12 startup guard
|
||||
# requires DEMO_MODE_ACK=true to allow this combination on a
|
||||
# non-loopback bind; the boot-time WARN banner (cmd/server/main.go)
|
||||
# reminds the operator on every start.
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_DEMO_MODE_ACK: "true"
|
||||
# Phase 2 SEC-H3 (2026-05-13): DEMO_MODE_ACK=true requires a fresh
|
||||
# DEMO_MODE_ACK_TS within the last 24h. The overlay can't hardcode
|
||||
# a timestamp (it would rot the next day), so we passthrough from
|
||||
# the shell. Operators set this via:
|
||||
# CERTCTL_DEMO_MODE_ACK_TS=$(date +%s) docker compose \
|
||||
# -f docker-compose.yml -f docker-compose.demo.yml up -d
|
||||
# The cold-DB smoke + any helper script (deploy/demo-up.sh, when
|
||||
# it lands) export this before invoking compose. Empty value
|
||||
# fails the SEC-H3 guard with a clear operator-facing error
|
||||
# message pointing at this line.
|
||||
CERTCTL_DEMO_MODE_ACK_TS: "${CERTCTL_DEMO_MODE_ACK_TS:-}"
|
||||
# Server-side keygen so the demo can populate the dashboard with
|
||||
# full lifecycle history. Production deploys leave this at the
|
||||
# code default `agent` (CertctlAgent generates ECDSA P-256 keys
|
||||
# locally and submits CSRs only).
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
# Demo creds — the Bundle 2 fail-closed Validate() rejects these
|
||||
# sentinels outside demo mode, but DEMO_MODE_ACK=true unlocks them.
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: change-me-32-char-encryption-key
|
||||
CERTCTL_AUTH_SECRET: change-me-in-production
|
||||
# Cold-DB smoke fix (2026-05-13): the base compose builds the
|
||||
# database URL via compose-level `${POSTGRES_PASSWORD}` interpolation
|
||||
# (deploy/docker-compose.yml line ~177), which reads the SHELL env —
|
||||
# NOT the postgres service's `environment:` block above (that one
|
||||
# feeds the postgres container's initdb only). In a zero-env-var
|
||||
# CI run the shell var is blank, producing
|
||||
# `postgres://certctl:@postgres:5432/...` and a SCRAM rejection
|
||||
# against a database that initdb seeded with password `certctl`.
|
||||
# Pinning the full URL here closes the gap: the demo overlay is
|
||||
# now fully self-sufficient (matches the file's docstring claim)
|
||||
# and the cold-DB smoke passes against a fresh GitHub-runner clone
|
||||
# with no .env file or exported shell vars. Production deploys
|
||||
# override CERTCTL_DATABASE_URL via the base compose's
|
||||
# `${CERTCTL_DATABASE_URL:-...}` default, so this literal is
|
||||
# overlay-scoped and never leaks into a production posture.
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
|
||||
# 180-day simulated history seed applied at boot.
|
||||
CERTCTL_DEMO_SEED: "true"
|
||||
|
||||
certctl-agent:
|
||||
environment:
|
||||
# Pre-seeded by migrations/seed_demo.sql; the bundled agent
|
||||
# connects with these creds and the demo-mode synthetic admin
|
||||
# accepts every request regardless of API key.
|
||||
CERTCTL_API_KEY: change-me-in-production
|
||||
CERTCTL_AGENT_ID: agent-demo-1
|
||||
volumes:
|
||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql
|
||||
|
||||
+15
-337
@@ -93,17 +93,6 @@ services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10): the test stack used
|
||||
# to mount a hand-curated subset of migrations + seed.sql + a never-checked-in
|
||||
# seed_test.sql into postgres `/docker-entrypoint-initdb.d/`. Same hazard as
|
||||
# the production compose — initdb crashed any time a new migration shipped
|
||||
# that the seed depended on without the mount list being updated. Post-U-3
|
||||
# the schema is built EXCLUSIVELY by the server at startup via
|
||||
# internal/repository/postgres.RunMigrations + RunSeed. Postgres comes up
|
||||
# empty and the server lands the full ladder + baseline seed in one shot.
|
||||
# `start_period: 30s` matches the production compose and shields slow CI
|
||||
# runners from healthcheck flap during initdb.
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-test-postgres
|
||||
@@ -113,6 +102,19 @@ services:
|
||||
POSTGRES_PASSWORD: testpass
|
||||
volumes:
|
||||
- test_postgres_data:/var/lib/postgresql/data
|
||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
||||
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
||||
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql
|
||||
# No seed_demo.sql — start with a clean database for real testing
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.2
|
||||
@@ -123,7 +125,6 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -272,14 +273,6 @@ services:
|
||||
CERTCTL_ACME_EMAIL: test@certctl.dev
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||
CERTCTL_ACME_INSECURE: "true"
|
||||
# Phase 2 SEC-M4 (2026-05-13): CERTCTL_ACME_INSECURE=true requires
|
||||
# the paired CERTCTL_ACME_INSECURE_ACK=true; without the ACK the
|
||||
# server's Config.Validate() refuses to start. This integration
|
||||
# stack uses Pebble's self-signed ACME directory, so disabling
|
||||
# TLS verification is correct — but the ACK env var has to be
|
||||
# set explicitly so the test posture matches what production
|
||||
# operators are blocked from doing accidentally.
|
||||
CERTCTL_ACME_INSECURE_ACK: "true"
|
||||
|
||||
# step-ca issuer (iss-stepca)
|
||||
CERTCTL_STEPCA_URL: https://step-ca:9000
|
||||
@@ -292,57 +285,8 @@ services:
|
||||
CERTCTL_EST_ENABLED: "true"
|
||||
CERTCTL_EST_ISSUER_ID: iss-local
|
||||
|
||||
# SCEP intentionally NOT configured in this stack.
|
||||
#
|
||||
# The 2026-04-29 master bundle Phase I added an `e2eintune` SCEP
|
||||
# profile to this compose file with the intent that
|
||||
# deploy/test/scep_intune_e2e_test.go would exercise it. That
|
||||
# integration test exists (//go:build integration) but no CI job
|
||||
# actually selects it — ci.yml's deploy-vendor-e2e job runs only
|
||||
# `-run 'VendorEdge_'` (line 379), and no other job ever invokes
|
||||
# `go test -tags integration` with a SCEP selector.
|
||||
#
|
||||
# The result was dead config: SCEP_ENABLED=true triggered the
|
||||
# per-profile validator chain at server boot, but the supporting
|
||||
# fixtures (ra.crt + ra.key + intune_trust_anchor.pem) were never
|
||||
# committed to deploy/test/fixtures/ — only the README documenting
|
||||
# how to regenerate them. Pre-Phase-5 (ci-pipeline-cleanup matrix
|
||||
# collapse) the test stack didn't fully boot the certctl-server in
|
||||
# CI, so the gap was hidden. Once the matrix collapsed and the
|
||||
# collapsed deploy-vendor-e2e job started actually booting the
|
||||
# server, the fail-loud gate at config.go:2069 (CWE-306, empty
|
||||
# CHALLENGE_PASSWORD) fired and blocked CI.
|
||||
#
|
||||
# CERTCTL_SCEP_ENABLED is unset → default false → the validator
|
||||
# skips the entire SCEP block. Coherence guard at
|
||||
# scripts/ci-guards/test-compose-scep-coherence.sh refuses any
|
||||
# future edit that re-enables SCEP without ALSO (a) adding a CI
|
||||
# job that runs the SCEP integration test and (b) committing the
|
||||
# required fixtures. The README at deploy/test/fixtures/README.md
|
||||
# keeps the regen recipe so the eventual SCEP CI job lands cleanly.
|
||||
|
||||
# Dynamic issuer/target config encryption (M34/M35).
|
||||
#
|
||||
# MUST be ≥ 32 bytes. The H-1 closure (commit 6cb4414, "feat(security):
|
||||
# encryption-key validation") added internal/config/config.go's
|
||||
# minEncryptionKeyLength = 32 byte floor; values shorter than that are
|
||||
# rejected at server boot with `Failed to load configuration:
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY too short`. The previous test value
|
||||
# `test-encryption-key-32chars!!` was 29 bytes (the name claimed 32 but
|
||||
# the author miscounted — 4+1+10+1+3+1+2+5+2 = 29). Pre-H-1 the
|
||||
# validator accepted any non-empty string, so the gap was silent. Once
|
||||
# the test stack actually boots the certctl-server (which the
|
||||
# ci-pipeline-cleanup Phase 5 matrix collapse forced for the first
|
||||
# time), the server now hard-fails at startup and the deploy-vendor-e2e
|
||||
# job's `dependency failed to start: container certctl-test-server
|
||||
# is unhealthy` error fires.
|
||||
#
|
||||
# The replacement below is 49 bytes — 17 bytes of safety margin over
|
||||
# the floor so a future tightening (32 → 33+) does not break this
|
||||
# fixture. It is clearly test-only / deterministic; do NOT copy this
|
||||
# to production. Operators set CERTCTL_CONFIG_ENCRYPTION_KEY from
|
||||
# `openssl rand -base64 32` per the README.
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-deterministic-32-byte-fixture
|
||||
# Dynamic issuer/target config encryption (M34/M35)
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!!
|
||||
|
||||
# Network scanning
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
@@ -362,11 +306,6 @@ services:
|
||||
# agent mounts the same host path at the same container path (see below)
|
||||
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
|
||||
- ./test/certs:/etc/certctl/tls:ro
|
||||
# SCEP fixtures volume mount removed alongside the SCEP env vars
|
||||
# above. When a CI job that runs scep_intune_e2e_test.go is added,
|
||||
# restore both this mount AND the env vars together — the coherence
|
||||
# guard at scripts/ci-guards/test-compose-scep-coherence.sh
|
||||
# enforces that they move as a unit.
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.6
|
||||
@@ -463,250 +402,6 @@ services:
|
||||
ipv4_address: 10.30.50.8
|
||||
restart: unless-stopped
|
||||
|
||||
# EST RFC 7030 hardening master bundle Phase 10.1 — libest sidecar.
|
||||
#
|
||||
# Cisco's libest reference RFC 7030 client. The integration test
|
||||
# (deploy/test/est_e2e_test.go, build tag `integration`) docker-exec's
|
||||
# into this container to drive estclient against the live certctl
|
||||
# server. The container stays alive via `sleep infinity` so the test
|
||||
# can do many serial exec calls without paying container-startup cost.
|
||||
#
|
||||
# Profile-gated (`profiles: [est-e2e]`) so the routine `docker compose
|
||||
# up` for non-EST integration runs doesn't pay the libest build cost.
|
||||
# Operator opts in via `docker compose --profile est-e2e up`. CI's
|
||||
# est-e2e job runs:
|
||||
# docker compose --profile est-e2e build libest-client
|
||||
# docker compose --profile est-e2e up -d
|
||||
# INTEGRATION=1 go test -tags integration -run 'TestEST_LibESTClient' ./deploy/test/...
|
||||
libest-client:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/test/libest/Dockerfile
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-test-libest
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# /config/est is the libest working directory — the integration
|
||||
# test writes CSRs / reads issued certs through this mount so the
|
||||
# test-side Go code can inspect estclient's outputs.
|
||||
- ./test/est:/config/est:rw
|
||||
# certctl's CA bundle for TLS pinning. estclient uses this to
|
||||
# verify the certctl-server cert (the same self-signed bundle
|
||||
# the certctl-agent verifies against).
|
||||
- ./test/certs:/config/certs:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
# Was 10.30.50.9 — collided with certctl-tls-init (line 91). Pre-Phase-5
|
||||
# per-vendor matrix structurally hid this: tls-init is profile-less so
|
||||
# it always ran, but libest is profiles=[est-e2e] so it only ran when
|
||||
# the (separate) est-e2e job brought it up. Different jobs ⇒ different
|
||||
# docker networks ⇒ no collision. Surfaced when a future job runs both
|
||||
# profiles together; pre-emptive fix here.
|
||||
ipv4_address: 10.30.50.10
|
||||
restart: unless-stopped
|
||||
profiles: [est-e2e]
|
||||
|
||||
# =============================================================================
|
||||
# Deploy-Hardening II Phase 1 — per-vendor sidecar matrix
|
||||
# =============================================================================
|
||||
# Each sidecar is a real-software target the deploy-vendor-e2e tests
|
||||
# (deploy/test/<vendor>_vendor_e2e_test.go, build tag `integration`)
|
||||
# exercise the connector's atomic + verify + rollback contract against.
|
||||
# All gated behind `profiles: [deploy-e2e]` so routine integration runs
|
||||
# don't pay the per-vendor pull cost.
|
||||
#
|
||||
# Image digests pinned per H-001 guard. Re-pin quarterly per
|
||||
# docs/deployment-vendor-matrix.md.
|
||||
|
||||
apache-test:
|
||||
image: httpd:2.4-alpine@sha256:f9061a65c6e8f50d5636e10806da3d5a238877c11d6bc0149dc5131be0a1a19f
|
||||
container_name: certctl-test-apache
|
||||
ports:
|
||||
- "20443:443"
|
||||
volumes:
|
||||
- ./test/apache/httpd-ssl.conf:/usr/local/apache2/conf/extra/httpd-ssl.conf:ro
|
||||
- ./test/apache/init-cert.sh:/docker-entrypoint-init.sh:ro
|
||||
- apache_certs:/usr/local/apache2/conf/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.20
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
haproxy-test:
|
||||
image: haproxy:3.0-alpine@sha256:5b645ad4f3294cf5bc50ab8b201fdeb73732eca2928185df335735c698e8c3e2
|
||||
container_name: certctl-test-haproxy
|
||||
ports:
|
||||
- "20444:443"
|
||||
volumes:
|
||||
- ./test/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
|
||||
- haproxy_certs:/etc/haproxy/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.21
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
traefik-test:
|
||||
image: traefik:v3.1@sha256:8516638b18e67e999d293e4ff0e5baf7807674cd4bdd3d36d448497bcbf0a174
|
||||
container_name: certctl-test-traefik
|
||||
command:
|
||||
- --providers.file.directory=/etc/traefik/dynamic
|
||||
- --providers.file.watch=true
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --log.level=ERROR
|
||||
ports:
|
||||
- "20445:443"
|
||||
volumes:
|
||||
- ./test/traefik/traefik-dynamic.yml:/etc/traefik/dynamic/traefik-dynamic.yml:ro
|
||||
- traefik_certs:/etc/traefik/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.22
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
caddy-test:
|
||||
image: caddy:2.8-alpine@sha256:b95ed06fbc6d74d24a40902090c8cc6086ce7d08ba60a3a7e8e62bf164a9d7bb
|
||||
container_name: certctl-test-caddy
|
||||
command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
ports:
|
||||
- "20446:443"
|
||||
- "22019:2019" # admin API for ValidateOnly probe
|
||||
volumes:
|
||||
- ./test/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_certs:/etc/caddy/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.23
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
envoy-test:
|
||||
image: envoyproxy/envoy:v1.32-latest@sha256:6ed0d4f28b8122df896062c425b34f18b8287e8c71c6badb3b84ca2e2f47c519
|
||||
container_name: certctl-test-envoy
|
||||
command: envoy -c /etc/envoy/envoy.yaml --log-level error
|
||||
ports:
|
||||
- "20447:443"
|
||||
volumes:
|
||||
- ./test/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro
|
||||
- envoy_certs:/etc/envoy/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.24
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
postfix-test:
|
||||
image: boky/postfix:latest@sha256:cd7e192900bfc49a67291a572b5f645f9e7d1b8d7f2b79b0364b4b4176964e21
|
||||
container_name: certctl-test-postfix
|
||||
environment:
|
||||
ALLOWED_SENDER_DOMAINS: "test.local"
|
||||
ports:
|
||||
- "20025:25"
|
||||
- "20465:465"
|
||||
volumes:
|
||||
- postfix_certs:/etc/postfix/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.25
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
dovecot-test:
|
||||
image: dovecot/dovecot:latest@sha256:4046993478e8c8bcb841fdbff2d8de1b233484cc0196b3723f6c588e7eaf7301
|
||||
container_name: certctl-test-dovecot
|
||||
ports:
|
||||
- "20993:993"
|
||||
- "20995:995"
|
||||
volumes:
|
||||
- ./test/dovecot/dovecot.conf:/etc/dovecot/dovecot.conf:ro
|
||||
- dovecot_certs:/etc/dovecot/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.26
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
openssh-test:
|
||||
image: lscr.io/linuxserver/openssh-server:latest@sha256:742f577d4100f5ad3b38f270d722931bbe98b997444c13b1a2a838df12a9971e
|
||||
container_name: certctl-test-openssh
|
||||
environment:
|
||||
USER_NAME: "certctl"
|
||||
PASSWORD_ACCESS: "true"
|
||||
USER_PASSWORD: "test-only-do-not-use-in-prod"
|
||||
SUDO_ACCESS: "true"
|
||||
ports:
|
||||
- "20022:2222"
|
||||
volumes:
|
||||
- openssh_certs:/config/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.27
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
# f5-mock-icontrol: in-tree Go server implementing the iControl REST
|
||||
# surface this bundle exercises (Authenticate, UploadFile, transactions,
|
||||
# SSL profile CRUD). Built from deploy/test/f5-mock-icontrol/Dockerfile;
|
||||
# the operator-supplied real F5 vagrant box is documented in
|
||||
# docs/connector-f5.md as the validation tier above the mock.
|
||||
f5-mock-icontrol:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/test/f5-mock-icontrol/Dockerfile
|
||||
container_name: certctl-test-f5-mock
|
||||
ports:
|
||||
# Host port 20449 (NOT 20443 — apache-test owns 20443). The
|
||||
# ci-pipeline-cleanup Phase 5 vendor-matrix collapse brings up
|
||||
# all sidecars simultaneously; the original Phase 1 design
|
||||
# accidentally double-bound 20443 because the per-vendor matrix
|
||||
# only ever ran one sidecar at a time, hiding the collision.
|
||||
- "20449:443"
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.28
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
# k8s-kind-test: a kind (Kubernetes-in-Docker) cluster used by the
|
||||
# k8ssecret connector e2e tests. Per frozen decision 0.5, each K8s
|
||||
# version test spins up a fresh kind cluster of the matching version.
|
||||
# Tests are slow (~30-60s startup); marked t.Parallel() where independent.
|
||||
# The kind binary lives in the test image; the Docker socket is mounted
|
||||
# so kind can manage child containers.
|
||||
k8s-kind-test:
|
||||
image: kindest/node:v1.31.0@sha256:7fbc5644a803286a69ff9c5695f03bb01b512896835e15df7df17f756f7245ac
|
||||
container_name: certctl-test-kind
|
||||
privileged: true
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.29
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
# windows-iis-test: Windows containers run only on Windows hosts.
|
||||
# CI no longer runs an IIS matrix (per ci-pipeline-cleanup bundle
|
||||
# Phase 6 / frozen decision 0.5 — revises Bundle II decision 0.4).
|
||||
# Two reasons the Windows matrix was deleted: (a) it couldn't
|
||||
# physically work on `windows-latest` GitHub runners (Docker not
|
||||
# started in Windows-containers mode by default; `bridge` network
|
||||
# driver doesn't exist on Windows Docker); (b) all IIS + WinCertStore
|
||||
# vendor-edge tests are t.Log placeholder stubs that exercise no
|
||||
# IIS-specific behavior.
|
||||
#
|
||||
# Operators validate IIS + WinCertStore manually on a Windows host
|
||||
# per the playbook at docs/connector-iis.md::Operator validation playbook.
|
||||
#
|
||||
# The sidecar definition stays here under profiles: [deploy-e2e-windows]
|
||||
# so a Windows operator can opt in via:
|
||||
# docker compose --profile deploy-e2e-windows up -d windows-iis-test
|
||||
# Linux CI never activates this profile.
|
||||
windows-iis-test:
|
||||
image: mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2022@sha256:8d0b0e651ad514e3fb05978db66f38036118812e1b9314a48f10419cad8a3462
|
||||
container_name: certctl-test-iis
|
||||
ports:
|
||||
- "20448:443"
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.30
|
||||
profiles: [deploy-e2e-windows]
|
||||
|
||||
# =============================================================================
|
||||
# Network
|
||||
# =============================================================================
|
||||
@@ -733,20 +428,3 @@ volumes:
|
||||
driver: local
|
||||
nginx_certs:
|
||||
driver: local
|
||||
# Deploy-Hardening II Phase 1 — per-vendor sidecar cert volumes.
|
||||
apache_certs:
|
||||
driver: local
|
||||
haproxy_certs:
|
||||
driver: local
|
||||
traefik_certs:
|
||||
driver: local
|
||||
caddy_certs:
|
||||
driver: local
|
||||
envoy_certs:
|
||||
driver: local
|
||||
postfix_certs:
|
||||
driver: local
|
||||
dovecot_certs:
|
||||
driver: local
|
||||
openssh_certs:
|
||||
driver: local
|
||||
|
||||
+18
-131
@@ -1,49 +1,3 @@
|
||||
# =============================================================================
|
||||
# certctl base compose — PRODUCTION-SHAPED (Bundle 2, 2026-05-12)
|
||||
# =============================================================================
|
||||
#
|
||||
# This base file ships a SAFE-BY-DEFAULT control plane:
|
||||
#
|
||||
# - CERTCTL_AUTH_TYPE defaults to api-key (the code default; not overridden
|
||||
# here). The server REFUSES to start with auth=none on a non-loopback
|
||||
# bind unless CERTCTL_DEMO_MODE_ACK=true (Audit 2026-05-10 HIGH-12 +
|
||||
# Bundle 2 closure: see internal/config/config.go::Validate).
|
||||
# - CERTCTL_KEYGEN_MODE defaults to agent (the code default).
|
||||
# - CERTCTL_DEMO_SEED defaults to false (the code default; the 180-day
|
||||
# simulated history seed only runs under the demo overlay).
|
||||
# - Default placeholder credentials (`change-me-...` sentinels) are NOT
|
||||
# interpolated by this compose. The server REFUSES to start when those
|
||||
# placeholder strings reach config (Bundle 2 fail-closed guards) unless
|
||||
# DEMO_MODE_ACK=true. Operators MUST set:
|
||||
# POSTGRES_PASSWORD (openssl rand -hex 32)
|
||||
# CERTCTL_AUTH_SECRET (openssl rand -hex 32)
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY (openssl rand -base64 32)
|
||||
# CERTCTL_API_KEY (matches CERTCTL_AUTH_SECRET or one
|
||||
# of its rotation siblings)
|
||||
# CERTCTL_AGENT_ID (returned from POST /api/v1/agents)
|
||||
# in deploy/.env or the shell environment. See deploy/.env.example.
|
||||
#
|
||||
# USAGE
|
||||
# -----
|
||||
#
|
||||
# Production-shaped (this base alone):
|
||||
# docker compose -f deploy/docker-compose.yml up -d
|
||||
#
|
||||
# Bundled demo (zero-config, populated dashboard, demo-mode auth):
|
||||
# docker compose -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml up -d
|
||||
#
|
||||
# The demo overlay (docker-compose.demo.yml) layers in the demo-mode env
|
||||
# vars (AUTH_TYPE=none + DEMO_MODE_ACK=true + KEYGEN_MODE=server +
|
||||
# DEMO_SEED=true + the change-me placeholder creds). It exists so the
|
||||
# `docker compose up` smoke + screenshot path stays one command — but it
|
||||
# ALSO carries the operator-visible warning banner the server emits at
|
||||
# boot when DEMO_MODE_ACK=true.
|
||||
#
|
||||
# Pre-Bundle-2 this base file WAS the demo path. The split happened in
|
||||
# 2026-05-12; the README quickstart, deploy/ENVIRONMENTS.md, and the
|
||||
# cold-DB compose smoke in .github/workflows/ci.yml were updated in the
|
||||
# same commit to point at the new layout.
|
||||
services:
|
||||
# HTTPS-Everywhere Phase 3 — self-signed TLS bootstrap (init container).
|
||||
# Generates a CN=certctl-server ECDSA-P256 (SHA-256 signature) cert with
|
||||
@@ -99,45 +53,28 @@ services:
|
||||
- certctl-network
|
||||
|
||||
# PostgreSQL database
|
||||
#
|
||||
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10):
|
||||
# Pre-U-3 this stack mounted a hand-curated subset of `migrations/*.up.sql`
|
||||
# plus `seed.sql` into `/docker-entrypoint-initdb.d/`, and postgres
|
||||
# initdb-applied them on first boot. The mount list rotted every time a
|
||||
# new migration shipped that the seed depended on (000013 added
|
||||
# policy_rules.severity, 000017 renames retry_interval_minutes, etc.) —
|
||||
# initdb crashed, the container reported `unhealthy` indefinitely, and
|
||||
# `docker compose -f deploy/docker-compose.yml up -d --build` from a
|
||||
# fresh clone of v2.0.50 hit it on the first try.
|
||||
#
|
||||
# Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
|
||||
# internal/repository/postgres.RunMigrations + RunSeed. Single source of
|
||||
# truth, no list to keep in sync. Postgres comes up empty; the server
|
||||
# waits for it healthy, then applies the full migration ladder + seed in
|
||||
# one shot. Helm + the dev examples were already runtime-only (Path B)
|
||||
# and worked through the same window.
|
||||
#
|
||||
# `start_period: 30s` gives postgres room to bootstrap on slow runners
|
||||
# (CI macOS, low-spec laptops) before the healthcheck failure counter
|
||||
# starts ticking. Pre-U-3 a slow first-init combined with the
|
||||
# `unhealthy` flap to cascade into certctl-server's `service_healthy`
|
||||
# depends_on, blocking the whole stack.
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-postgres
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
# Bundle 2 closure: no `:-certctl` fallback. Operators MUST set
|
||||
# POSTGRES_PASSWORD in deploy/.env or the shell environment. The
|
||||
# demo overlay (docker-compose.demo.yml) supplies a fixed weak
|
||||
# default for screenshot/demo use; production deploys never
|
||||
# depend on that fallback.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-certctl}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
||||
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
@@ -145,7 +82,6 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Certctl Server (API + scheduler)
|
||||
@@ -170,48 +106,16 @@ services:
|
||||
certctl-tls-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
# Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319): in-cluster Postgres
|
||||
# on the docker bridge network keeps sslmode=disable acceptable; for
|
||||
# external/managed Postgres operators MUST override CERTCTL_DATABASE_URL
|
||||
# with sslmode=verify-full and provide the CA bundle. See docs/database-tls.md.
|
||||
CERTCTL_DATABASE_URL: ${CERTCTL_DATABASE_URL:-postgres://certctl:${POSTGRES_PASSWORD}@postgres:5432/certctl?sslmode=disable}
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
|
||||
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
# Bundle 2 closure (compose split). The base compose no longer
|
||||
# sets CERTCTL_AUTH_TYPE / CERTCTL_KEYGEN_MODE / DEMO_MODE_ACK /
|
||||
# DEMO_SEED — the code defaults take over (auth-type api-key,
|
||||
# keygen agent, demo-mode false, demo-seed false). The demo
|
||||
# overlay (docker-compose.demo.yml) is what flips this baseline
|
||||
# into the populated-dashboard demo path; without that overlay
|
||||
# the server boots production-shaped and refuses to start unless
|
||||
# the operator has supplied CERTCTL_AUTH_SECRET +
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY.
|
||||
#
|
||||
# Audit 2026-05-10 HIGH-12: when DEMO_MODE_ACK=true (set by the
|
||||
# demo overlay) AND the listener binds to a non-loopback address,
|
||||
# every request is served as the synthetic admin actor
|
||||
# `actor-demo-anon`. The server emits a prominent boot-time WARN
|
||||
# banner with a production-promotion checklist in that case.
|
||||
CERTCTL_AUTH_SECRET: ${CERTCTL_AUTH_SECRET}
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY} # AES-256-GCM for dynamic issuer/target config
|
||||
# Bootstrap token interpolation surface (Auditable Codebase Bundle
|
||||
# cold-DB smoke closure, 2026-05-12). Pre-fix, the `env-file +
|
||||
# --force-recreate certctl-server` pattern documented in
|
||||
# cowork/manual-testing-bundle-2.html (and used by the cold-DB
|
||||
# smoke job in .github/workflows/ci.yml::cold-db-compose-smoke)
|
||||
# set CERTCTL_BOOTSTRAP_TOKEN in compose's own interpolation
|
||||
# environment but the container never received it because this
|
||||
# block didn't reference the variable. Wiring it as an explicit
|
||||
# interpolation (default empty) makes the documented manual flow
|
||||
# actually work end-to-end. Empty value = bootstrap strategy
|
||||
# disabled (server returns 410 Gone on POST /api/v1/auth/bootstrap),
|
||||
# which is the safe default — only set the var when you intend to
|
||||
# mint a day-0 admin via the bootstrap path.
|
||||
CERTCTL_BOOTSTRAP_TOKEN: ${CERTCTL_BOOTSTRAP_TOKEN:-}
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key} # AES-256-GCM for dynamic issuer/target config
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
@@ -223,11 +127,6 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# U-3: server boot now does RunMigrations + RunSeed before listening on
|
||||
# 8443. On a fresh clone the full migration ladder + seed application
|
||||
# can take ~10s on a small VM; start_period prevents the first few
|
||||
# healthcheck attempts from counting as failures while that work runs.
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -261,19 +160,7 @@ services:
|
||||
environment:
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
|
||||
# Bundle 2 closure (compose split). No placeholder fallbacks.
|
||||
# Operators MUST set CERTCTL_API_KEY (matching one of the server's
|
||||
# CERTCTL_AUTH_SECRET rotation values) and CERTCTL_AGENT_ID
|
||||
# (returned from `POST /api/v1/agents` during agent enrollment).
|
||||
# Without an agent ID, cmd/agent/main.go fails fast at startup
|
||||
# with "agent-id flag or CERTCTL_AGENT_ID env var is required" —
|
||||
# the cold-DB compose smoke in .github/workflows/ci.yml tolerates
|
||||
# the agent restart loop because the smoke targets server boot
|
||||
# only. The demo overlay (docker-compose.demo.yml) supplies a
|
||||
# pre-seeded agent-demo-1 row + matching env vars so the demo
|
||||
# path stays one-command.
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY}
|
||||
CERTCTL_AGENT_ID: ${CERTCTL_AGENT_ID}
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
|
||||
CERTCTL_AGENT_NAME: docker-agent
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
|
||||
|
||||
@@ -17,7 +17,7 @@ A production-ready Helm chart for deploying certctl (self-hosted certificate lif
|
||||
- **Chart Version**: 0.1.0
|
||||
- **App Version**: 2.1.0
|
||||
- **Type**: application
|
||||
- **License**: BSL-1.1
|
||||
- **License**: BSL-1.1 (converts to Apache 2.0 in 2033)
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -246,8 +246,8 @@ helm install certctl certctl/ \
|
||||
|--------|---------|-------------|
|
||||
| `server.replicas` | 1 | Number of server replicas |
|
||||
| `server.port` | 8443 | Server port |
|
||||
| `server.auth.type` | api-key | Authentication type — `api-key` or `none` (G-1: `jwt` removed; for JWT/OIDC use a fronting authenticating gateway, see `docs/architecture.md` and `docs/upgrade-to-v2-jwt-removal.md`) |
|
||||
| `server.auth.apiKey` | "" | API key (REQUIRED when `auth.type=api-key`) |
|
||||
| `server.auth.type` | api-key | Authentication type |
|
||||
| `server.auth.apiKey` | "" | API key (REQUIRED) |
|
||||
| `server.logging.level` | info | Log level |
|
||||
| `server.logging.format` | json | Log format |
|
||||
|
||||
@@ -452,9 +452,10 @@ monitoring:
|
||||
## Support
|
||||
|
||||
For issues, questions, or contributions:
|
||||
- GitHub: https://github.com/certctl-io/certctl
|
||||
- Documentation: https://github.com/certctl-io/certctl/tree/main/docs
|
||||
- GitHub: https://github.com/shankar0123/certctl
|
||||
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs
|
||||
|
||||
## License
|
||||
|
||||
BSL-1.1 (Business Source License)
|
||||
Converts to Apache 2.0 on March 14, 2033
|
||||
|
||||
@@ -216,7 +216,7 @@ kubectl logs -l app.kubernetes.io/component=server -f
|
||||
|
||||
## Support
|
||||
|
||||
- **GitHub**: https://github.com/certctl-io/certctl
|
||||
- **GitHub**: https://github.com/shankar0123/certctl
|
||||
- **Issues**: Report on GitHub issues
|
||||
- **Documentation**: All docs are in `deploy/helm/`
|
||||
|
||||
@@ -231,4 +231,4 @@ kubectl logs -l app.kubernetes.io/component=server -f
|
||||
|
||||
## License
|
||||
|
||||
All files are covered under the BSL-1.1 license.
|
||||
All files are covered under the BSL-1.1 license (converts to Apache 2.0 in 2033).
|
||||
|
||||
@@ -94,4 +94,4 @@ helm install certctl certctl/ --dry-run --debug
|
||||
|
||||
- Full documentation in `README.md`
|
||||
- Troubleshooting in `DEPLOYMENT_GUIDE.md`
|
||||
- Issues: https://github.com/certctl-io/certctl
|
||||
- Issues: https://github.com/shankar0123/certctl
|
||||
|
||||
@@ -508,9 +508,9 @@ kubectl exec -it <pod> -- \
|
||||
## Support and Contributing
|
||||
|
||||
For issues, questions, or contributions, visit:
|
||||
- GitHub: https://github.com/certctl-io/certctl
|
||||
- Documentation: https://github.com/certctl-io/certctl/tree/main/docs
|
||||
- GitHub: https://github.com/shankar0123/certctl
|
||||
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs
|
||||
|
||||
## License
|
||||
|
||||
BSL-1.1
|
||||
BSL-1.1 (converts to Apache 2.0 in 2033)
|
||||
|
||||
@@ -2,15 +2,7 @@ apiVersion: v2
|
||||
name: certctl
|
||||
description: Self-hosted certificate lifecycle management platform
|
||||
type: application
|
||||
# Bundle 3 closure (OPS-L1): bumped from 0.1.0 → 1.0.0. The pre-1.0
|
||||
# version implied "unstable chart, breaking changes on every minor"
|
||||
# which prospective enterprise operators read as "not ready for
|
||||
# production". The chart has been deployed against real clusters since
|
||||
# 2026-02 and shipped through 8 audit closures (M-018, U-1, U-2, U-3,
|
||||
# H-1, G-1, B1 connector validation, B2 first-run guards); 1.0.0
|
||||
# matches that maturity. The chart still adheres to semver going
|
||||
# forward — any breaking value-schema change bumps to 2.0.0.
|
||||
version: 1.0.0
|
||||
version: 0.1.0
|
||||
appVersion: "2.1.0"
|
||||
keywords:
|
||||
- certificate
|
||||
@@ -22,7 +14,7 @@ keywords:
|
||||
- kubernetes
|
||||
maintainers:
|
||||
- name: certctl
|
||||
home: https://github.com/certctl-io/certctl
|
||||
home: https://github.com/shankar0123/certctl
|
||||
sources:
|
||||
- https://github.com/certctl-io/certctl
|
||||
- https://github.com/shankar0123/certctl
|
||||
license: BSL-1.1
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# certctl Helm Chart
|
||||
|
||||
Production-ready Helm chart for deploying [certctl](https://github.com/certctl-io/certctl) on Kubernetes. Wires up the certctl server (Deployment), PostgreSQL (StatefulSet with PVC), and the agent (DaemonSet — one per node) on a private cluster, with health probes, security contexts, and optional Ingress.
|
||||
|
||||
## Quick install
|
||||
|
||||
```bash
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--create-namespace --namespace certctl \
|
||||
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 24)"
|
||||
```
|
||||
|
||||
This brings up:
|
||||
|
||||
- `<release>-server` Deployment (HTTPS-only on port 8443; TLS 1.3)
|
||||
- `<release>-postgres` StatefulSet (PostgreSQL 16-alpine, 1 replica, 10Gi PVC by default)
|
||||
- `<release>-agent` DaemonSet (polls server, generates ECDSA P-256 keys locally)
|
||||
- Service objects, optional Ingress, and ServiceAccount with RBAC
|
||||
|
||||
See [`values.yaml`](values.yaml) for the full configuration surface — issuer settings, target connectors, scheduler intervals, notifier credentials, and resource requests/limits all live there.
|
||||
|
||||
## Operational notes
|
||||
|
||||
### Postgres password rotation — read this before changing `postgresql.auth.password`
|
||||
|
||||
**The trap.** `postgresql.auth.password` is bound to `pg_authid` exactly once — when the StatefulSet's PVC is provisioned and `initdb` runs. The official `postgres:16-alpine` image only runs `initdb` when `/var/lib/postgresql/data` is empty, so on every subsequent rollout the `POSTGRES_PASSWORD` env var is read into the container but **ignored** by postgres itself. The certctl-server container also picks up the new value (via the database URL helper template), so the two halves diverge: server presents the new password, postgres still expects the old one.
|
||||
|
||||
**Symptom.** The certctl-server pod's startup log shows:
|
||||
|
||||
```
|
||||
failed to ping database: postgres rejected the configured credentials
|
||||
(SQLSTATE 28P01 — invalid_password). If you recently rotated POSTGRES_PASSWORD ...
|
||||
```
|
||||
|
||||
That diagnostic is emitted by `internal/repository/postgres/db.go::wrapPingError` — it points operators at the two remediation paths below.
|
||||
|
||||
**Remediation, non-destructive (preferred for any environment with real data):**
|
||||
|
||||
```bash
|
||||
# 1. Rotate the password in postgres directly
|
||||
kubectl -n certctl exec -it <release>-postgres-0 -- \
|
||||
psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new-password>';"
|
||||
|
||||
# 2. Update the secret / Helm values to the same value
|
||||
helm upgrade <release> deploy/helm/certctl/ \
|
||||
--reuse-values \
|
||||
--set postgresql.auth.password='<new-password>'
|
||||
|
||||
# 3. Bounce the certctl-server pod so it re-reads the secret
|
||||
kubectl -n certctl rollout restart deployment/<release>-server
|
||||
```
|
||||
|
||||
**Remediation, destructive (DESTROYS ALL CERTCTL DATA — only acceptable on dev/demo clusters):**
|
||||
|
||||
```bash
|
||||
helm uninstall <release> -n certctl
|
||||
kubectl -n certctl delete pvc -l \
|
||||
app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres
|
||||
helm install <release> deploy/helm/certctl/ \
|
||||
--namespace certctl \
|
||||
--set postgresql.auth.password='<new-password>'
|
||||
```
|
||||
|
||||
The PVC re-creates empty, `initdb` runs on first boot of the new postgres pod, and `pg_authid` is seeded with the new password.
|
||||
|
||||
**Why we don't fix this in the chart.** The env-vs-`pg_authid` divergence is intrinsic to how the upstream `postgres` image bootstraps — `initdb` is run-once-per-empty-data-dir, and there is no upstream-supported way to make subsequent boots re-seed `pg_authid` from `POSTGRES_PASSWORD`. The ergonomic answer is the runtime diagnostic plus this operational note.
|
||||
|
||||
**Cross-references.** Same root cause is documented for the docker-compose path in [`docs/quickstart.md`](../../../docs/quickstart.md) (Warning callout after the `cp .env.example .env` block) and in [`deploy/ENVIRONMENTS.md`](../../ENVIRONMENTS.md) (Stateful volume — first-boot password binding section). The runtime diagnostic itself lives in `internal/repository/postgres/db.go::wrapPingError` with regression coverage in `internal/repository/postgres/db_test.go`.
|
||||
|
||||
### Server API key rotation
|
||||
|
||||
Unlike the postgres password, `server.auth.apiKey` accepts a comma-separated list, so zero-downtime rotation is straightforward:
|
||||
|
||||
```bash
|
||||
# 1. Add the new key alongside the old
|
||||
helm upgrade <release> deploy/helm/certctl/ \
|
||||
--reuse-values \
|
||||
--set server.auth.apiKey='new-key,old-key'
|
||||
|
||||
# 2. Roll your agents / clients over to the new key
|
||||
|
||||
# 3. Remove the old key
|
||||
helm upgrade <release> deploy/helm/certctl/ \
|
||||
--reuse-values \
|
||||
--set server.auth.apiKey='new-key'
|
||||
```
|
||||
|
||||
### JWT / OIDC via authenticating gateway
|
||||
|
||||
certctl's in-process auth surface is intentionally narrow: `server.auth.type=api-key` for production deployments and `server.auth.type=none` for development. There is no in-process JWT, OIDC, mTLS, or SAML middleware. (`server.auth.type=jwt` was accepted pre-G-1 but silently routed every request through the api-key bearer middleware — silent auth downgrade. The chart now fails at `helm install`/`helm upgrade` template time via the `certctl.validateAuthType` helper if you set it. See [`../../../docs/upgrade-to-v2-jwt-removal.md`](../../../docs/upgrade-to-v2-jwt-removal.md) if you previously had this in your values.)
|
||||
|
||||
For deployments that need JWT/OIDC, the canonical Kubernetes-flavored shape is to put oauth2-proxy in front of the certctl Service, attach an authenticating Ingress middleware, and run certctl with `server.auth.type=none`:
|
||||
|
||||
```bash
|
||||
# 1. Install oauth2-proxy (or any OIDC-terminating sidecar) in the same namespace
|
||||
helm install oauth2-proxy oauth2-proxy/oauth2-proxy \
|
||||
--namespace certctl \
|
||||
--set config.clientID="$OIDC_CLIENT_ID" \
|
||||
--set config.clientSecret="$OIDC_CLIENT_SECRET" \
|
||||
--set config.cookieSecret="$(openssl rand -base64 32)" \
|
||||
--set config.configFile='|
|
||||
provider = "oidc"
|
||||
oidc_issuer_url = "https://your-issuer/"
|
||||
upstreams = ["http://<release>-server.certctl.svc.cluster.local:8443"]
|
||||
pass_authorization_header = true
|
||||
set_authorization_header = true
|
||||
email_domains = ["*"]
|
||||
'
|
||||
|
||||
# 2. Install certctl with type=none (gateway terminates auth)
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--namespace certctl \
|
||||
--set server.auth.type=none \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 24)"
|
||||
|
||||
# 3. Attach an Ingress that routes through oauth2-proxy
|
||||
# (Traefik ForwardAuth, nginx auth_request, Envoy ext_authz, etc.)
|
||||
```
|
||||
|
||||
Same root pattern works with Pomerium, Authelia, Caddy `forward_auth`, Apache `mod_auth_openidc`, or any service-mesh `ext_authz`. See [`../../../docs/architecture.md`](../../../docs/architecture.md) "Authenticating-gateway pattern" for the full design rationale and [`../../../docs/upgrade-to-v2-jwt-removal.md`](../../../docs/upgrade-to-v2-jwt-removal.md) for the migration walkthrough.
|
||||
|
||||
### TLS certificate sourcing
|
||||
|
||||
By default the chart provisions a self-signed cert via the same init-container pattern as the docker-compose deploy. For production, supply an operator-managed Secret (cert-manager, internal CA, etc.) — see [`docs/tls.md`](../../../docs/tls.md) for the full provisioning matrix and [`docs/upgrade-to-tls.md`](../../../docs/upgrade-to-tls.md) for upgrade-from-HTTP procedures.
|
||||
|
||||
## Disabling embedded postgres
|
||||
|
||||
If you have an existing PostgreSQL cluster, disable the embedded one and point at it directly:
|
||||
|
||||
```bash
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--set postgresql.enabled=false \
|
||||
--set server.databaseUrl='postgres://certctl:<pw>@my-pg-host:5432/certctl?sslmode=require'
|
||||
```
|
||||
|
||||
The volume-trap section above does **not** apply to this configuration — your postgres operator (or cloud DB) handles password rotation, and you control `pg_authid` directly.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
helm uninstall <release> -n certctl
|
||||
# Optional — also delete the postgres PVC (DESTROYS DATA):
|
||||
kubectl -n certctl delete pvc -l \
|
||||
app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres
|
||||
```
|
||||
|
||||
By default `helm uninstall` retains the StatefulSet's PVCs, so reinstalling with the same release name preserves the database. If you've changed `postgresql.auth.password` in your values between uninstall and reinstall, you'll hit the trap on the reinstall — apply the non-destructive remediation above, or also delete the PVC.
|
||||
@@ -112,43 +112,9 @@ PostgreSQL image
|
||||
|
||||
{{/*
|
||||
Database connection string
|
||||
|
||||
Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319):
|
||||
- postgresql.tls.mode is the operator-facing knob.
|
||||
Default: "disable" (preserves the in-cluster Helm-bundled-Postgres
|
||||
behavior; pod-to-pod traffic stays on the K8s pod network and is
|
||||
encrypted by the CNI when the cluster is configured with a TLS-aware
|
||||
CNI such as Cilium WireGuard).
|
||||
- Operators on PCI-DSS-scoped clusters or operators using an external
|
||||
managed Postgres (RDS, Cloud SQL, Azure DB) MUST set
|
||||
postgresql.tls.mode to "require", "verify-ca", or "verify-full" and
|
||||
point postgresql.tls.caSecretRef at a Secret containing the
|
||||
server-ca.crt under key "ca.crt".
|
||||
- The connection string sslmode parameter is wired from
|
||||
postgresql.tls.mode without further translation.
|
||||
*/}}
|
||||
{{- define "certctl.databaseURL" -}}
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
{{- $sslMode := default "disable" .Values.postgresql.tls.mode -}}
|
||||
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode={{ $sslMode }}
|
||||
{{- else -}}
|
||||
{{- /*
|
||||
Bundle 3 closure (D2 + OPS-L2): external-Postgres first-class path.
|
||||
When postgresql.enabled=false, the chart NEVER renders the
|
||||
bundled StatefulSet, postgres-secret, or postgres-service —
|
||||
templates/postgres-*.yaml gate themselves on .Values.postgresql.enabled.
|
||||
The connection string comes from externalDatabase.url (the canonical
|
||||
form) or, for backward-compat with pre-Bundle-3 deploys, from
|
||||
server.env.CERTCTL_DATABASE_URL (which overrides this helper at the
|
||||
pod-spec level — see server-deployment.yaml).
|
||||
|
||||
externalDatabase.url is consumed VERBATIM by the server's
|
||||
CERTCTL_DATABASE_URL env var. Operators are responsible for choosing
|
||||
the right sslmode (`verify-full` recommended for managed Postgres
|
||||
per PCI-DSS Req 4 §2.2.5; see docs/database-tls.md).
|
||||
*/ -}}
|
||||
{{- required "externalDatabase.url is required when postgresql.enabled=false" .Values.externalDatabase.url -}}
|
||||
{{- end -}}
|
||||
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
@@ -199,129 +165,7 @@ per affected resource. No-op when configured correctly.
|
||||
{{- if and (not .Values.server.tls.existingSecret) (not .Values.server.tls.certManager.enabled) -}}
|
||||
{{- fail "\n\ncertctl refuses to start without TLS.\n\nSet EXACTLY ONE of:\n --set server.tls.existingSecret=<your-kubernetes.io/tls-secret-name>\nOR\n --set server.tls.certManager.enabled=true \\\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md for the full setup walkthrough, including bootstrap\nguidance for air-gapped clusters without cert-manager.\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.server.tls.existingSecret .Values.server.tls.certManager.enabled -}}
|
||||
{{- /*
|
||||
Bundle 3 closure (D7): pre-Bundle-3 the helper only rejected the
|
||||
NEITHER-set case. Setting BOTH (`existingSecret` AND `certManager.enabled=true`)
|
||||
produced two TLS sources of truth — the existing Secret got mounted but
|
||||
cert-manager simultaneously provisioned a Certificate CR pointing at a
|
||||
conflicting Secret. Operators ended up with a dangling cert-manager
|
||||
Certificate or a wrong-source TLS bundle. The chart now refuses at
|
||||
render-time so the misconfiguration cannot ship.
|
||||
*/ -}}
|
||||
{{- fail "\n\nserver.tls.existingSecret AND server.tls.certManager.enabled are BOTH set.\n\nThe chart requires EXACTLY ONE TLS ownership path (Bundle 3 closure / audit D7):\n - existingSecret: operator owns the TLS Secret; cert-manager must NOT provision one.\n - certManager.enabled: cert-manager owns the TLS Secret; existingSecret must be empty.\n\nUnset one of:\n --set server.tls.existingSecret=\"\" (let cert-manager own it)\nOR\n --set server.tls.certManager.enabled=false (let the existing Secret stand)\n\nSee docs/tls.md.\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.server.tls.certManager.enabled (not .Values.server.tls.certManager.issuerRef.name) -}}
|
||||
{{- fail "\n\nserver.tls.certManager.enabled=true but server.tls.certManager.issuerRef.name is empty.\n\nSet:\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md.\n" -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Pod- vs container-scope security context split (Bundle 3 closure / audit D3).
|
||||
|
||||
The Kubernetes API splits SecurityContext into two non-overlapping
|
||||
field sets, and silently DROPS fields that land at the wrong scope —
|
||||
which is exactly the audit D3 finding pre-Bundle-3.
|
||||
|
||||
Pod-scope fields (applied via spec.securityContext):
|
||||
runAsNonRoot, runAsUser, runAsGroup, fsGroup, fsGroupChangePolicy,
|
||||
supplementalGroups, seLinuxOptions, seccompProfile, sysctls.
|
||||
|
||||
Container-scope fields (applied via spec.containers[].securityContext):
|
||||
readOnlyRootFilesystem, allowPrivilegeEscalation, capabilities,
|
||||
privileged, procMount, runAsNonRoot/runAsUser/runAsGroup (override),
|
||||
seLinuxOptions/seccompProfile (override).
|
||||
|
||||
These helpers split a single operator-facing `securityContext` map
|
||||
into the two sub-maps so the chart renders each field at the scope
|
||||
where Kubernetes actually honors it. The split is conservative — a
|
||||
field that COULD live at either scope is rendered at pod scope only
|
||||
(no override at container scope) so behavior matches the pre-Bundle-3
|
||||
operator intent: pod-level setting is the source of truth.
|
||||
|
||||
Operators don't need to change values.yaml; the existing
|
||||
`server.securityContext` and `agent.securityContext` blocks keep
|
||||
working byte-for-byte. The Helm template just routes each field to
|
||||
the correct YAML node now.
|
||||
*/}}
|
||||
{{- define "certctl.podSecurityContext" -}}
|
||||
{{- $sc := . -}}
|
||||
{{- $podKeys := list "runAsNonRoot" "runAsUser" "runAsGroup" "fsGroup" "fsGroupChangePolicy" "supplementalGroups" "seLinuxOptions" "seccompProfile" "sysctls" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := $podKeys -}}
|
||||
{{- if hasKey $sc $k -}}
|
||||
{{- $_ := set $out $k (index $sc $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "certctl.containerSecurityContext" -}}
|
||||
{{- $sc := . -}}
|
||||
{{- $containerKeys := list "readOnlyRootFilesystem" "allowPrivilegeEscalation" "capabilities" "privileged" "procMount" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := $containerKeys -}}
|
||||
{{- if hasKey $sc $k -}}
|
||||
{{- $_ := set $out $k (index $sc $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Required-secret gate (Bundle 3 closure / audit D1).
|
||||
|
||||
Pre-Bundle-3 the chart accepted empty `server.auth.apiKey` and empty
|
||||
`postgresql.auth.password` and rendered Secrets with empty values; the
|
||||
certctl-server container then crash-looped at startup with the auth
|
||||
configuration error or with `pq: password authentication failed for
|
||||
user "certctl"`. Worse, an operator who forgot to set the api-key
|
||||
ended up with auth.type=api-key + empty CERTCTL_AUTH_SECRET in the
|
||||
Secret, which Validate() rejects at startup — but the diagnostic
|
||||
surfaces inside a CrashLoopBackOff, not at `helm install` time where
|
||||
it would be caught immediately.
|
||||
|
||||
Post-Bundle-3 the chart fails at template time with operator-actionable
|
||||
guidance. The bundled-Postgres path (`postgresql.enabled=true`)
|
||||
requires `postgresql.auth.password`; the external-Postgres path
|
||||
(`postgresql.enabled=false`) skips that check because credentials are
|
||||
embedded in `externalDatabase.url` instead.
|
||||
|
||||
Any template that depends on either secret value should call
|
||||
`{{ include "certctl.requiredSecrets" . }}` at the top so this guard
|
||||
runs once per affected resource. No-op when configured correctly.
|
||||
*/}}
|
||||
{{- define "certctl.requiredSecrets" -}}
|
||||
{{- if and (eq .Values.server.auth.type "api-key") (not .Values.server.auth.apiKey) -}}
|
||||
{{- fail "\n\nserver.auth.type=\"api-key\" but server.auth.apiKey is empty.\n\nSet:\n --set server.auth.apiKey=$(openssl rand -base64 32)\n\nor put the value in a values override. The certctl-server container\nrefuses to start without an API key when auth.type=api-key.\n\nFor demo deploys without authentication, use:\n --set server.auth.type=none\n(only safe behind an authenticating gateway — see docs/operator/security.md).\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.postgresql.enabled (not .Values.postgresql.auth.password) -}}
|
||||
{{- fail "\n\npostgresql.enabled=true but postgresql.auth.password is empty.\n\nSet:\n --set postgresql.auth.password=$(openssl rand -base64 32)\n\nor put the value in a values override. The bundled Postgres\nStatefulSet refuses to bootstrap initdb without POSTGRES_PASSWORD.\n\nFor external Postgres deployments, set:\n --set postgresql.enabled=false\n --set externalDatabase.url=postgres://user:pass@host:5432/db?sslmode=require\nSee deploy/helm/examples/values-external-db.yaml.\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and (not .Values.postgresql.enabled) (not .Values.externalDatabase.url) (not .Values.server.env.CERTCTL_DATABASE_URL) -}}
|
||||
{{- fail "\n\npostgresql.enabled=false but no external database URL is configured.\n\nSet ONE of:\n --set externalDatabase.url=postgres://user:pass@host:5432/db?sslmode=require\nOR (legacy)\n --set server.env.CERTCTL_DATABASE_URL=postgres://user:pass@host:5432/db?sslmode=require\n\nSee deploy/helm/examples/values-external-db.yaml.\n" -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Auth-type validation gate.
|
||||
|
||||
G-1 (P1): pre-G-1 the chart accepted server.auth.type=jwt and the
|
||||
certctl-server container silently routed every request through the
|
||||
api-key bearer middleware (no JWT impl ships with certctl). Post-G-1
|
||||
the chart fails at template-time with a pointer at the authenticating-
|
||||
gateway pattern. The valid set must stay in sync with
|
||||
internal/config.ValidAuthTypes() in the Go binary; if you add a value
|
||||
there you must add it here too (and update the property test in
|
||||
internal/config/config_test.go that pins both surfaces).
|
||||
|
||||
Any template that consumes .Values.server.auth.type should call
|
||||
`{{ include "certctl.validateAuthType" . }}` at the top so this guard
|
||||
runs once per affected resource. No-op when configured correctly.
|
||||
*/}}
|
||||
{{- define "certctl.validateAuthType" -}}
|
||||
{{- $valid := list "api-key" "none" "oidc" -}}
|
||||
{{- if not (has .Values.server.auth.type $valid) -}}
|
||||
{{- fail (printf "\n\nserver.auth.type=%q is not supported (valid: %v).\n\nFor JWT/SAML/LDAP, run an authenticating gateway in front of certctl\n(oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium) and\nset server.auth.type=none here so the gateway terminates federated\nidentity. See docs/architecture.md \"Authenticating-gateway pattern\"\nand docs/upgrade-to-v2-jwt-removal.md for the migration walkthrough.\n\nG-1 audit closure: pre-G-1 the chart accepted type=jwt and the binary\nsilently downgraded to api-key middleware. The chart now fails at\ntemplate time so misconfigured deployments cannot ship.\n\nAuth Bundle 2 Phase 0: server.auth.type=oidc is in the valid set but\nthe OIDC handler chain ships in later Bundle 2 phases. Pre-Bundle-2\noperators who set type=oidc see the certctl-server container exit at\nstartup with an actionable error — chart-time validation no longer\nblocks deploy because the binary's runtime guard takes over. Once\nBundle 2 lands, the runtime guard relaxes and OIDC works end-to-end.\n" .Values.server.auth.type $valid) -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
@@ -19,7 +19,7 @@ spec:
|
||||
spec:
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.agent.securityContext | nindent 8 }}
|
||||
{{- toYaml .Values.agent.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
@@ -40,8 +40,6 @@ spec:
|
||||
- name: agent
|
||||
image: {{ include "certctl.agentImage" . }}
|
||||
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.agent.securityContext | nindent 12 }}
|
||||
env:
|
||||
- name: CERTCTL_SERVER_URL
|
||||
value: {{ include "certctl.serverURL" . }}
|
||||
@@ -108,7 +106,7 @@ spec:
|
||||
spec:
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.agent.securityContext | nindent 8 }}
|
||||
{{- toYaml .Values.agent.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
@@ -129,8 +127,6 @@ spec:
|
||||
- name: agent
|
||||
image: {{ include "certctl.agentImage" . }}
|
||||
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.agent.securityContext | nindent 12 }}
|
||||
env:
|
||||
- name: CERTCTL_SERVER_URL
|
||||
value: {{ include "certctl.serverURL" . }}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
{{- /*
|
||||
Phase 4 DEPL-H2 closure (2026-05-14): opt-in Helm CronJob for
|
||||
PostgreSQL backups.
|
||||
|
||||
OPERATOR OPT-IN. Default `backup.enabled: false`. Turning it on
|
||||
requires:
|
||||
- In-cluster Postgres (this CronJob does NOT cover managed DB
|
||||
services — for AWS RDS / GCP CloudSQL / Azure DB rely on the
|
||||
provider's PITR).
|
||||
- A sink choice (PVC or S3) configured in values.yaml.
|
||||
- For S3: a Secret holding AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
|
||||
(or use a service account with IRSA on EKS).
|
||||
|
||||
The pg_dump invocation matches the canonical shape documented in
|
||||
docs/operator/runbooks/postgres-backup.md so a manual run and a
|
||||
CronJob run produce byte-identical dumps:
|
||||
|
||||
pg_dump --format=custom --no-owner --no-acl --dbname=certctl
|
||||
|
||||
For sink choices beyond PVC + S3 (GCS, Azure Blob, NFS, restic, etc.),
|
||||
extend the `aws s3 cp` line below. The Job is intentionally minimal —
|
||||
it does ONE thing (capture + ship), not orchestrate retention or
|
||||
rotation. Off-host retention is the sink's responsibility (S3 lifecycle
|
||||
rules, PVC snapshot retention on the storage class, etc.).
|
||||
*/ -}}
|
||||
{{- if .Values.backup.enabled }}
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-postgres-backup
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres-backup
|
||||
spec:
|
||||
schedule: {{ .Values.backup.schedule | quote }}
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit | default 3 }}
|
||||
failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit | default 1 }}
|
||||
startingDeadlineSeconds: {{ .Values.backup.startingDeadlineSeconds | default 300 }}
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: {{ .Values.backup.backoffLimit | default 1 }}
|
||||
activeDeadlineSeconds: {{ .Values.backup.activeDeadlineSeconds | default 3600 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 12 }}
|
||||
app.kubernetes.io/component: postgres-backup
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: backup
|
||||
image: {{ .Values.backup.image | default "postgres:16-alpine" | quote }}
|
||||
imagePullPolicy: {{ .Values.backup.imagePullPolicy | default "IfNotPresent" | quote }}
|
||||
env:
|
||||
- name: PGHOST
|
||||
value: {{ include "certctl.fullname" . }}-postgres
|
||||
- name: PGPORT
|
||||
value: {{ .Values.postgresql.service.port | default 5432 | quote }}
|
||||
- name: PGUSER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: username
|
||||
- name: PGPASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: password
|
||||
- name: PGDATABASE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: database
|
||||
{{- if eq (.Values.backup.sink | default "pvc") "s3" }}
|
||||
# S3 sink — operator provides AWS credentials via the
|
||||
# Secret referenced in backup.s3.credentialsSecret. The
|
||||
# credentials need s3:PutObject + s3:ListBucket on the
|
||||
# target bucket only; least-privilege per industry
|
||||
# standard.
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.backup.s3.credentialsSecret.name | quote }}
|
||||
key: {{ .Values.backup.s3.credentialsSecret.accessKeyIdKey | default "AWS_ACCESS_KEY_ID" }}
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.backup.s3.credentialsSecret.name | quote }}
|
||||
key: {{ .Values.backup.s3.credentialsSecret.secretAccessKeyKey | default "AWS_SECRET_ACCESS_KEY" }}
|
||||
{{- with .Values.backup.s3.region }}
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ceu
|
||||
- |
|
||||
# Phase 4 DEPL-H2: canonical pg_dump shape per
|
||||
# docs/operator/runbooks/postgres-backup.md.
|
||||
# Custom-format compressed dump, no ownership /
|
||||
# ACL embedded — produces a portable artifact
|
||||
# restorable into any Postgres ≥ source major
|
||||
# via `pg_restore -d certctl <dump>`.
|
||||
set -euo pipefail
|
||||
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
DUMP_FILE="/tmp/certctl-${TIMESTAMP}.dump"
|
||||
|
||||
echo "[backup-cronjob] capturing dump at ${TIMESTAMP}"
|
||||
pg_dump --format=custom --no-owner --no-acl --dbname="${PGDATABASE}" \
|
||||
> "${DUMP_FILE}"
|
||||
|
||||
# Integrity check — pg_restore --list parses the
|
||||
# dump's table-of-contents; a corrupt dump fails
|
||||
# here without shipping garbage off-host. Same
|
||||
# check the manual runbook performs.
|
||||
echo "[backup-cronjob] verifying dump integrity"
|
||||
pg_restore --list "${DUMP_FILE}" > /dev/null
|
||||
|
||||
{{- if eq (.Values.backup.sink | default "pvc") "s3" }}
|
||||
# S3 sink — requires aws-cli. The default
|
||||
# postgres:16-alpine image does NOT include
|
||||
# aws-cli; operators MUST set
|
||||
# backup.image to an image that bundles both
|
||||
# (e.g. ghcr.io/your-org/postgres-aws:16) OR
|
||||
# override backup.command to install aws-cli at
|
||||
# runtime. The line below assumes the image has
|
||||
# `aws` on PATH.
|
||||
S3_PATH="{{ .Values.backup.s3.bucket }}/{{ .Values.backup.s3.prefix | default "certctl" }}/certctl-${TIMESTAMP}.dump"
|
||||
echo "[backup-cronjob] uploading to s3://${S3_PATH}"
|
||||
aws s3 cp "${DUMP_FILE}" "s3://${S3_PATH}"
|
||||
rm -f "${DUMP_FILE}"
|
||||
{{- else }}
|
||||
# PVC sink — dump lands at /backups/certctl-${TIMESTAMP}.dump
|
||||
# mounted from backup.pvc.claimName. Retention is the
|
||||
# PVC's responsibility (storage-class snapshot lifecycle
|
||||
# or a separate cleanup CronJob). The Job moves the
|
||||
# file from /tmp to /backups atomically; never
|
||||
# writes partial dumps into the durable mount.
|
||||
FINAL_PATH="/backups/certctl-${TIMESTAMP}.dump"
|
||||
echo "[backup-cronjob] persisting to ${FINAL_PATH}"
|
||||
mv "${DUMP_FILE}" "${FINAL_PATH}"
|
||||
{{- end }}
|
||||
echo "[backup-cronjob] done"
|
||||
{{- if ne (.Values.backup.sink | default "pvc") "s3" }}
|
||||
volumeMounts:
|
||||
- name: backups
|
||||
mountPath: /backups
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml (.Values.backup.resources | default dict) | nindent 16 }}
|
||||
{{- if ne (.Values.backup.sink | default "pvc") "s3" }}
|
||||
volumes:
|
||||
- name: backups
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.backup.pvc.claimName | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeAffinity }}
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
{{- toYaml . | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backup.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,89 +0,0 @@
|
||||
{{- /*
|
||||
Phase 4 DEPL-M1 closure (2026-05-14): Helm pre-install / pre-upgrade
|
||||
hook that runs Postgres migrations before the server Deployment rolls.
|
||||
|
||||
Pre-DEPL-M1, postgres.RunMigrations was invoked at server boot
|
||||
(cmd/server/main.go:151) as the only migration path. That works for
|
||||
Compose deployments but conflicts with Kubernetes rolling deploys:
|
||||
when a new server image lands with a schema change, multiple replicas
|
||||
race the migration during the rollout. The hook resolves the race by
|
||||
running migrations OUT OF BAND, exactly once, before any new server
|
||||
pod starts.
|
||||
|
||||
How it works:
|
||||
- The Job ships the same certctl-server image as the Deployment, so
|
||||
the migration code path is binary-identical to the boot-time path.
|
||||
- It runs `certctl-server --migrate-only` (a flag the cmd/server
|
||||
main process must support — see cmd/server/main.go for the flag
|
||||
parse + early-exit path).
|
||||
- The CERTCTL_MIGRATIONS_VIA_HOOK=true env var is ALSO set on the
|
||||
server Deployment (via values.yaml). When the server boots, it
|
||||
sees this env var and skips its own RunMigrations call — the
|
||||
hook already did the work. Compose deploys don't set the env
|
||||
var, so they keep the boot-time path unchanged.
|
||||
- hook-delete-policy hook-succeeded means the Job is cleaned up
|
||||
automatically on success but retained on failure for operator
|
||||
diagnosis.
|
||||
- The hook-weight ensures the migration Job runs before any other
|
||||
pre-install/pre-upgrade resources (the StatefulSet's PVC has to
|
||||
exist first; in practice the StatefulSet has no hook so it lands
|
||||
naturally in the install phase after the Job completes).
|
||||
|
||||
Operators on Compose: this hook is a no-op for you. The server still
|
||||
runs migrations at boot per the existing path.
|
||||
*/ -}}
|
||||
{{- if .Values.migrations.viaHook }}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-migrate
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: migration
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-5"
|
||||
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation
|
||||
spec:
|
||||
backoffLimit: {{ .Values.migrations.backoffLimit | default 1 }}
|
||||
activeDeadlineSeconds: {{ .Values.migrations.activeDeadlineSeconds | default 600 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: migration
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.server.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migrate
|
||||
image: {{ include "certctl.serverImage" . }}
|
||||
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||
# Migration-only entrypoint. The server binary supports a
|
||||
# --migrate-only flag that runs postgres.RunMigrations +
|
||||
# postgres.RunSeed and exits cleanly (zero on success,
|
||||
# non-zero on migration failure). See cmd/server/main.go
|
||||
# for the implementation. The flag is hermetic — no HTTP
|
||||
# listener starts, no scheduler ticks, no signing
|
||||
# operations occur. Pure schema-mutation pass.
|
||||
command:
|
||||
- /app/server
|
||||
- --migrate-only
|
||||
env:
|
||||
- name: CERTCTL_DATABASE_URL
|
||||
value: {{ include "certctl.databaseURL" . | quote }}
|
||||
- name: CERTCTL_LOG_LEVEL
|
||||
value: {{ .Values.server.logging.level | default "info" | quote }}
|
||||
- name: CERTCTL_LOG_FORMAT
|
||||
value: {{ .Values.server.logging.format | default "json" | quote }}
|
||||
resources:
|
||||
{{- toYaml (.Values.migrations.resources | default .Values.server.resources) | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.server.securityContext | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -1,75 +0,0 @@
|
||||
{{- /*
|
||||
Bundle 3 closure (D11): NetworkPolicy for the server Deployment.
|
||||
|
||||
Pre-Bundle-3 the chart had no NetworkPolicy template at all — the
|
||||
audit-D11 "documented placeholder" finding referred to docs claiming
|
||||
deny-by-default network isolation that the rendered chart did not
|
||||
provide. Closed.
|
||||
|
||||
This template emits a single NetworkPolicy that, when enabled,
|
||||
restricts the certctl-server Pod to:
|
||||
- Ingress : from any agent Pod in the same namespace (selector
|
||||
match on app.kubernetes.io/component=agent) on the
|
||||
server port, plus optional operator-supplied
|
||||
additional from clauses (.networkPolicy.extraIngress).
|
||||
- Egress : to the postgres Pod (when postgresql.enabled=true),
|
||||
53/UDP+TCP for kube-dns, and operator-supplied
|
||||
additional to clauses for outbound CA / OIDC / SMTP
|
||||
(.networkPolicy.extraEgress).
|
||||
|
||||
Default off so existing deploys don't suddenly lose network reach.
|
||||
Operators opt in once they've mapped their actual egress surface.
|
||||
*/ -}}
|
||||
{{- if .Values.networkPolicy.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
# Allow in-cluster agent Pods to reach the server's HTTPS port.
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
||||
app.kubernetes.io/component: agent
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: {{ .Values.server.port }}
|
||||
{{- with .Values.networkPolicy.extraIngress }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
egress:
|
||||
# Kube-DNS (53/UDP + 53/TCP). Required for any in-cluster name
|
||||
# resolution (postgres-service, OIDC issuer hostnames, ACME).
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
# Bundled-Postgres egress.
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
||||
app.kubernetes.io/component: postgres
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 5432
|
||||
{{- end }}
|
||||
{{- with .Values.networkPolicy.extraEgress }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,31 +0,0 @@
|
||||
{{- /*
|
||||
Bundle 3 closure (D11): PodDisruptionBudget for the server Deployment.
|
||||
|
||||
Pre-Bundle-3 values.yaml carried `podDisruptionBudget.enabled` +
|
||||
`minAvailable` + `maxUnavailable` knobs but no template consumed
|
||||
them. Audit D11 closed.
|
||||
|
||||
The PDB only renders when server.replicas > 1 — a single-replica
|
||||
deployment can't satisfy minAvailable=1 during voluntary disruption
|
||||
anyway (the K8s scheduler would refuse to drain the node). Operators
|
||||
running 2+ replicas get the PDB; operators running a single replica
|
||||
get a templated-out NOTES line reminding them to bump replicas first.
|
||||
*/ -}}
|
||||
{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.server.replicas) 1) }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||
{{- if .Values.podDisruptionBudget.minAvailable }}
|
||||
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
|
||||
{{- else if .Values.podDisruptionBudget.maxUnavailable }}
|
||||
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,14 +1,3 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
{{- /*
|
||||
Bundle 3 closure (D1 + D2): the bundled-Postgres Secret only renders
|
||||
when postgresql.enabled=true. Pre-Bundle-3 this template rendered
|
||||
unconditionally with `password: "changeme"` as the fallback default —
|
||||
which is exactly what the change-me-... cluster of audit findings
|
||||
was about (a deployment that uses the rendered chart with default
|
||||
values ships a known weak password). The Bundle-3 helper at
|
||||
certctl.requiredSecrets fail-closes empty password at template time
|
||||
before this template ever runs.
|
||||
*/ -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@@ -18,7 +7,6 @@ metadata:
|
||||
app.kubernetes.io/component: postgres
|
||||
type: Opaque
|
||||
stringData:
|
||||
password: {{ required "postgresql.auth.password is required when postgresql.enabled=true (Bundle 3: no fallback default)" .Values.postgresql.auth.password | quote }}
|
||||
password: {{ .Values.postgresql.auth.password | default "changeme" | quote }}
|
||||
username: {{ .Values.postgresql.auth.username | quote }}
|
||||
database: {{ .Values.postgresql.auth.database | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -9,21 +9,6 @@ metadata:
|
||||
spec:
|
||||
serviceName: {{ include "certctl.fullname" . }}-postgres
|
||||
replicas: 1
|
||||
# Phase 4 DEPL-M4 closure (2026-05-14): explicit StatefulSet update +
|
||||
# pod-management strategies. Defaults make Postgres upgrades
|
||||
# operator-controlled rather than automatic:
|
||||
# updateStrategy.type: OnDelete — Postgres pods do NOT roll
|
||||
# automatically when the StatefulSet spec changes. Operator
|
||||
# deletes the pod explicitly after taking a backup + reviewing
|
||||
# the change. Prevents an accidental Helm-template tweak from
|
||||
# triggering a database restart at an awkward time.
|
||||
# podManagementPolicy: OrderedReady — when scaling Postgres to
|
||||
# a replica >1 (future HA work), pods come up one at a time
|
||||
# and must reach Ready before the next pod is created. Aligns
|
||||
# with the standard Postgres-on-Kubernetes pattern.
|
||||
updateStrategy:
|
||||
type: OnDelete
|
||||
podManagementPolicy: OrderedReady
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.postgresSelectorLabels" . | nindent 6 }}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
{{- /*
|
||||
Phase 4 DEPL-L2 closure (2026-05-14): opt-in Prometheus AlertManager
|
||||
rules covering the four operationally-actionable alerts every certctl
|
||||
deployment wants out of the box.
|
||||
|
||||
OPERATOR OPT-IN. Default `monitoring.prometheusRules.enabled: false`.
|
||||
Turning it on requires Prometheus Operator CRDs (PrometheusRule kind)
|
||||
to be installed in-cluster. Without them this template renders an
|
||||
object Kubernetes will reject — keep the toggle off if you're scraping
|
||||
with vanilla Prometheus + a Helm-installed AlertManager rules
|
||||
ConfigMap instead.
|
||||
|
||||
Metric names + thresholds verified against the actual
|
||||
internal/api/handler/metrics.go exposition path:
|
||||
- certctl_certificate_expiring_soon: server-side count of certs with
|
||||
ExpiresAt in (now, now + 30d]. The 30-day window is computed in
|
||||
internal/service/stats.go::GetDashboardSummary.
|
||||
- certctl_agent_online: agents with heartbeat in the last 5 minutes.
|
||||
A drop below certctl_agent_total signals offline agents.
|
||||
- certctl_job_failed_total + certctl_job_completed_total: cumulative
|
||||
counters; ratio gives the failure rate over the rate() window.
|
||||
- certctl_issuance_failures_total: cumulative counter of failed
|
||||
issuance attempts (renewal failures are issuance failures with a
|
||||
specific error_class label).
|
||||
|
||||
Adjust thresholds per fleet — the defaults below are tuned for the
|
||||
demo dataset (15 certs / 1 agent) and may need raising for production
|
||||
fleets with thousands of certs where a steady rate of expiring certs
|
||||
is the normal operating state.
|
||||
*/ -}}
|
||||
{{- if and .Values.monitoring.enabled .Values.monitoring.prometheusRules.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-rules
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: monitoring
|
||||
{{- with .Values.monitoring.prometheusRules.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
groups:
|
||||
- name: certctl.alerts
|
||||
interval: {{ .Values.monitoring.prometheusRules.interval | default "60s" }}
|
||||
rules:
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlCertificateExpiringSoon
|
||||
# Series: certctl_certificate_expiring_soon
|
||||
# The certctl-server counts certs with ExpiresAt in
|
||||
# (now, now + 30d] every metrics scrape. Fires whenever any cert
|
||||
# crosses into that window — operator must triage or extend
|
||||
# automation coverage. Rapid renewal infrastructure should keep
|
||||
# this number small in steady state.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlCertificateExpiringSoon
|
||||
expr: certctl_certificate_expiring_soon > {{ .Values.monitoring.prometheusRules.thresholds.expiringCertificateCount | default 0 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.expiringCertificateFor | default "5m" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl
|
||||
annotations:
|
||||
summary: "certctl: {{`{{ $value }}`}} certificate(s) expiring within 30 days"
|
||||
description: >-
|
||||
certctl_certificate_expiring_soon has been > {{ .Values.monitoring.prometheusRules.thresholds.expiringCertificateCount | default 0 }}
|
||||
for 5+ minutes. Investigate via
|
||||
/api/v1/certificates?status=expiring or the dashboard's
|
||||
Expiring tab. If renewal automation should have covered
|
||||
these, check the renewal scheduler logs for the cert IDs
|
||||
+ the per-issuer failure rate.
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlAgentOffline
|
||||
# Series: certctl_agent_total - certctl_agent_online
|
||||
# Agents flip from online → offline after 5 minutes without a
|
||||
# heartbeat (internal/service/stats.go::GetDashboardSummary).
|
||||
# The 1h `for:` window prevents a flapping agent from paging the
|
||||
# operator on every transient network blip.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlAgentOffline
|
||||
expr: (certctl_agent_total - certctl_agent_online) > {{ .Values.monitoring.prometheusRules.thresholds.offlineAgentCount | default 0 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.offlineAgentFor | default "1h" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl-agent
|
||||
annotations:
|
||||
summary: "certctl: {{`{{ $value }}`}} agent(s) offline for >1h"
|
||||
description: >-
|
||||
One or more certctl-agent instances have been without a
|
||||
heartbeat for over an hour. Check the agent logs on the
|
||||
affected hosts. If the agent host is intentionally
|
||||
decommissioned, retire the agent via the dashboard or
|
||||
POST /api/v1/agents/{id}/retire to suppress this alert.
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlJobFailureRateHigh
|
||||
# Series: certctl_job_failed_total / (certctl_job_failed_total + certctl_job_completed_total)
|
||||
# Computes the failure rate over a 15-minute rate() window so
|
||||
# short bursts don't fire but a sustained issue does. The 5%
|
||||
# threshold is a conservative starter — adjust per fleet's
|
||||
# baseline.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlJobFailureRateHigh
|
||||
expr: >-
|
||||
(
|
||||
rate(certctl_job_failed_total[15m])
|
||||
/
|
||||
clamp_min(rate(certctl_job_failed_total[15m]) + rate(certctl_job_completed_total[15m]), 1)
|
||||
) > {{ .Values.monitoring.prometheusRules.thresholds.jobFailureRate | default 0.05 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.jobFailureRateFor | default "15m" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl
|
||||
annotations:
|
||||
summary: "certctl: job failure rate above 5% over 15m"
|
||||
description: >-
|
||||
The 15m rate of certctl_job_failed_total / total jobs
|
||||
has been above 5% for 15+ minutes. Open
|
||||
/api/v1/jobs?status=failed to see the failing job IDs
|
||||
and root-cause the recurring error class.
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlIssuanceFailures
|
||||
# Series: certctl_issuance_failures_total
|
||||
# Any non-zero rate of issuance failures over a 15m window is
|
||||
# operationally significant — a single CA outage or expired
|
||||
# ACME account can cascade across the fleet.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlIssuanceFailures
|
||||
expr: rate(certctl_issuance_failures_total[15m]) > {{ .Values.monitoring.prometheusRules.thresholds.issuanceFailureRate | default 0 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.issuanceFailureFor | default "15m" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl
|
||||
annotations:
|
||||
summary: "certctl: certificate issuance / renewal failures over 15m"
|
||||
description: >-
|
||||
certctl_issuance_failures_total has been incrementing
|
||||
over the last 15 minutes. Check the per-issuer breakdown
|
||||
via /api/v1/issuers + the failed-job log in
|
||||
/api/v1/jobs?status=failed. Common causes: CA
|
||||
outage, ACME account rate-limit, EAB credential
|
||||
expiration, stepca provisioner key rotation without
|
||||
certctl-side update.
|
||||
{{- end }}
|
||||
@@ -1,4 +1,3 @@
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{{- include "certctl.tls.required" . }}
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
{{- include "certctl.requiredSecrets" . }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -24,13 +22,8 @@ spec:
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/server-secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
# Bundle 3 closure (D3): pod-level fields only. The container-only
|
||||
# fields (readOnlyRootFilesystem, allowPrivilegeEscalation,
|
||||
# capabilities, privileged) render at container scope below —
|
||||
# pre-Bundle-3 they all sat here at pod scope and the K8s API
|
||||
# silently dropped them.
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.server.securityContext | nindent 8 }}
|
||||
{{- toYaml .Values.server.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
@@ -39,13 +32,6 @@ spec:
|
||||
- name: server
|
||||
image: {{ include "certctl.serverImage" . }}
|
||||
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||
# Bundle 3 closure (D3): container-scope security hardening.
|
||||
# readOnlyRootFilesystem + allowPrivilegeEscalation +
|
||||
# capabilities are container-only fields per the K8s API; the
|
||||
# helper splits them out of the operator-facing
|
||||
# server.securityContext map so existing values keep working.
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.server.securityContext | nindent 12 }}
|
||||
ports:
|
||||
- name: https
|
||||
containerPort: {{ .Values.server.port }}
|
||||
@@ -64,16 +50,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: database-url
|
||||
# Bundle 3 closure (D2): POSTGRES_PASSWORD is only needed
|
||||
# for the bundled-Postgres mode. External Postgres mode
|
||||
# embeds the password directly in externalDatabase.url.
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: password
|
||||
{{- end }}
|
||||
- name: CERTCTL_LOG_LEVEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@@ -8,11 +7,7 @@ metadata:
|
||||
app.kubernetes.io/component: server
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Bundle B / Audit M-018 (PCI-DSS Req 4): sslmode wired from
|
||||
# postgresql.tls.mode. Default "disable" preserves the in-cluster
|
||||
# Helm-bundled-Postgres path; operators on PCI-scoped clusters set
|
||||
# postgresql.tls.mode to require / verify-ca / verify-full.
|
||||
database-url: {{ include "certctl.databaseURL" . | quote }}
|
||||
database-url: postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
||||
{{- if and (eq .Values.server.auth.type "api-key") .Values.server.auth.apiKey }}
|
||||
api-key: {{ .Values.server.auth.apiKey | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
{{- /*
|
||||
Bundle 3 closure (D5 + OPS-M1 docs): Prometheus Operator ServiceMonitor.
|
||||
|
||||
Pre-Bundle-3 the chart had `monitoring.serviceMonitor.enabled` in
|
||||
values.yaml but no template consumed it — toggling it on rendered
|
||||
nothing. Audit D5 closed.
|
||||
|
||||
The endpoint scrapes /api/v1/metrics/prometheus which the certctl
|
||||
server already exposes in Prometheus exposition format (see
|
||||
internal/api/handler/metrics.go::GetPrometheusMetrics). Note: the
|
||||
endpoint is rbac-gated on `metrics.read`, so the ServiceMonitor needs
|
||||
a bearer token. Operators with Prometheus Operator MUST set
|
||||
`monitoring.serviceMonitor.bearerTokenSecret` pointing at a Secret
|
||||
that holds an API key with the `metrics.read` permission. Without
|
||||
that, scrapes return 401.
|
||||
|
||||
OPS-M1 caveat: the current /metrics/prometheus handler is a hand-rolled
|
||||
exposition-format emitter, not prometheus/client_golang-instrumented
|
||||
code. Histograms, exemplars, and target labels are limited to what the
|
||||
handler computes statically. Migration to client_golang tracked in
|
||||
WORKSPACE-ROADMAP.md.
|
||||
*/ -}}
|
||||
{{- if and .Values.monitoring.enabled .Values.monitoring.serviceMonitor.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
{{- with .Values.monitoring.serviceMonitor.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||
endpoints:
|
||||
- port: https
|
||||
scheme: https
|
||||
path: /api/v1/metrics/prometheus
|
||||
interval: {{ .Values.monitoring.serviceMonitor.interval | default "30s" }}
|
||||
scrapeTimeout: {{ .Values.monitoring.serviceMonitor.scrapeTimeout | default "10s" }}
|
||||
tlsConfig:
|
||||
# The certctl server uses self-signed bootstrap TLS or operator-
|
||||
# provided cert-manager TLS — the ServiceMonitor consumes the
|
||||
# same CA bundle the server presents. When server.tls.existingSecret
|
||||
# is set, operators usually want to pull the matching ca.crt key
|
||||
# out of that Secret. Adjust if your CA chain lives elsewhere.
|
||||
{{- if .Values.monitoring.serviceMonitor.tlsConfig }}
|
||||
{{- toYaml .Values.monitoring.serviceMonitor.tlsConfig | nindent 8 }}
|
||||
{{- else }}
|
||||
insecureSkipVerify: true
|
||||
{{- end }}
|
||||
{{- with .Values.monitoring.serviceMonitor.bearerTokenSecret }}
|
||||
bearerTokenSecret:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.monitoring.serviceMonitor.relabelings }}
|
||||
relabelings:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
+13
-390
@@ -15,15 +15,12 @@ fullnameOverride: ""
|
||||
# Certctl Server Configuration
|
||||
# ==============================================================================
|
||||
server:
|
||||
# Number of replicas (for HA deployments).
|
||||
# Phase 2 DEPL-H1: production HA is operator-opt-in across this field
|
||||
# + podDisruptionBudget.enabled + server.service.sessionAffinity.
|
||||
# See docs/operator/runbooks/ha.md for the smallest-possible HA overlay.
|
||||
# Number of replicas (for HA deployments)
|
||||
replicas: 1
|
||||
|
||||
# Image configuration
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl
|
||||
repository: ghcr.io/shankar0123/certctl
|
||||
tag: "" # defaults to Chart.appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
@@ -31,36 +28,6 @@ server:
|
||||
port: 8443
|
||||
|
||||
# Resource requests and limits
|
||||
#
|
||||
# Phase 4 DEPL-M5 (2026-05-14): per-fleet-size tuning ladder. The
|
||||
# default values below are validated against the demo dataset
|
||||
# (15 certs / 1 agent) and the baselines in
|
||||
# docs/operator/performance-baselines.md (single endpoint < 5s for
|
||||
# 100 sequential requests = ~50ms p50; cursor-paginated 1000-cert
|
||||
# inventory walk < 3s; renewal scan for 15 certs < 100ms).
|
||||
#
|
||||
# Larger fleet recommendations (TBD pending Phase 8 load-test runs;
|
||||
# operators tune empirically until then — capture readings in your
|
||||
# own loadtest-baselines log):
|
||||
#
|
||||
# ≤ 500 certs / 100 agents: defaults below (100m / 128Mi req, 500m / 512Mi lim)
|
||||
# 5K certs / 1K agents: tune up — TBD Phase 8 (suggested starter: 500m / 512Mi req, 2000m / 2Gi lim)
|
||||
# 50K certs / 10K agents: tune up — TBD Phase 8 (suggested starter: 2000m / 2Gi req, 4000m / 4Gi lim)
|
||||
#
|
||||
# The "suggested starter" values above are operator-tuning starting
|
||||
# points, NOT validated. Phase 8 (load test coverage expansion) will
|
||||
# measure them against synthetic fleets and replace the suggestions
|
||||
# with measured ceilings. Until then, treat them as a "raise CPU
|
||||
# before raising memory; raise both before scaling out" mental
|
||||
# model. Per docs/operator/performance-baselines.md, certctl-server
|
||||
# is CPU-bound on issuance / renewal scan work and memory-bound on
|
||||
# the inventory query path.
|
||||
#
|
||||
# Database scale (postgresql.* below) tracks server scale roughly
|
||||
# 1:1 — at 50K certs the Postgres instance needs 4 CPU / 4Gi RAM
|
||||
# and shared_buffers ≥ 1Gi. Postgres tuning is out of scope for
|
||||
# this comment; see docs/operator/runbooks/postgres-backup.md
|
||||
# for the production-tuning entry-point.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
@@ -81,14 +48,7 @@ server:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
# Liveness and readiness probes (HTTPS-only as of v2.2).
|
||||
#
|
||||
# The two paths exposed for probes are `/health` and `/ready` —
|
||||
# registered in internal/api/router/router.go:76-85 and bypassing the
|
||||
# auth middleware via the no-auth list at cmd/server/main.go:920.
|
||||
# Both serve the same JSON shape today (`{"status":"healthy"}` /
|
||||
# `{"status":"ready"}`) but exist as separate routes so liveness and
|
||||
# readiness can diverge in the future without renaming.
|
||||
# Liveness and readiness probes (HTTPS-only as of v2.2)
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
@@ -99,18 +59,9 @@ server:
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# U-2 (P1, cat-u-healthcheck_protocol_mismatch — adjacent fix): pre-U-2
|
||||
# the readiness probe pointed at `/readyz`, the conventional kube-flavor
|
||||
# name. The certctl server doesn't register `/readyz` (only `/health`
|
||||
# and `/ready`) — see cmd/server/main.go:920 and
|
||||
# internal/api/router/router.go:81. K8s readiness probes therefore
|
||||
# received a 404 (or, with auth enabled, a 401 from the api-key middleware
|
||||
# because `/readyz` was NOT in the no-auth bypass set), pods stayed
|
||||
# `NotReady` indefinitely, and Helm rollouts stalled. Post-U-2 the path
|
||||
# matches a registered route.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
path: /readyz
|
||||
port: https
|
||||
scheme: HTTPS
|
||||
initialDelaySeconds: 5
|
||||
@@ -161,23 +112,10 @@ server:
|
||||
port: 8443
|
||||
annotations: {}
|
||||
|
||||
# Authentication configuration.
|
||||
# Valid types: "api-key" (production) or "none" (demo only — disables
|
||||
# authentication on the API and logs a loud Warn at server startup).
|
||||
# For JWT/OIDC, run an authenticating gateway in front of certctl
|
||||
# (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium)
|
||||
# and set type=none here so the gateway terminates federated identity.
|
||||
# See docs/architecture.md "Authenticating-gateway pattern".
|
||||
#
|
||||
# G-1 (P1): pre-G-1 the chart accepted server.auth.type=jwt and the
|
||||
# certctl-server container silently routed every request through the
|
||||
# api-key bearer middleware — silent auth downgrade. Post-G-1 the
|
||||
# chart's `certctl.validateAuthType` template helper rejects any value
|
||||
# outside {api-key, none} at template time. See
|
||||
# docs/upgrade-to-v2-jwt-removal.md if you previously set type=jwt.
|
||||
# Authentication configuration
|
||||
auth:
|
||||
type: api-key
|
||||
apiKey: "" # REQUIRED when type=api-key (set via --set or values override).
|
||||
type: api-key # Options: api-key, none (for demo only)
|
||||
apiKey: "" # REQUIRED in production - set via --set or values override
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
@@ -305,34 +243,6 @@ server:
|
||||
# secret:
|
||||
# secretName: ca-cert
|
||||
|
||||
# ==============================================================================
|
||||
# External Database Configuration (Bundle 3 closure / D2 + OPS-L2)
|
||||
# ==============================================================================
|
||||
# When postgresql.enabled=false, the chart skips the bundled StatefulSet +
|
||||
# Secret + Service and instead consumes the URL below verbatim as the
|
||||
# server's CERTCTL_DATABASE_URL. The URL embeds username, password,
|
||||
# host, port, database, and sslmode — operators are responsible for
|
||||
# rotating credentials in this string out-of-band (Kubernetes Secret +
|
||||
# helm upgrade is the supported pattern).
|
||||
#
|
||||
# Recommended sslmode for managed Postgres (RDS, Cloud SQL, Azure DB):
|
||||
# verify-full — PCI-DSS Req 4 v4.0 §2.2.5 compliant; requires CA bundle.
|
||||
# Mount the CA via server.volumes / server.volumeMounts and
|
||||
# set sslrootcert=/path/in/pod/ca.crt in the URL.
|
||||
#
|
||||
# Example values overrides:
|
||||
# postgresql.enabled: false
|
||||
# externalDatabase.url: "postgres://certctl:HUNTER2@db.example.com:5432/certctl?sslmode=verify-full"
|
||||
#
|
||||
# Migration from the legacy `server.env.CERTCTL_DATABASE_URL` workaround:
|
||||
# both still work (env block overrides the helper-emitted Secret value at
|
||||
# pod-spec level), but the new path renders cleaner manifests with no
|
||||
# stranded postgres-* templates.
|
||||
externalDatabase:
|
||||
# Connection string used when postgresql.enabled=false.
|
||||
# Required in that mode — see certctl.requiredSecrets helper.
|
||||
url: ""
|
||||
|
||||
# ==============================================================================
|
||||
# PostgreSQL Configuration
|
||||
# ==============================================================================
|
||||
@@ -350,58 +260,7 @@ postgresql:
|
||||
auth:
|
||||
database: certctl
|
||||
username: certctl
|
||||
# REQUIRED — set via `--set postgresql.auth.password=<value>` or values override.
|
||||
#
|
||||
# WARNING (U-1): rotating this value after first deploy does NOT change the
|
||||
# database password. The `postgres:16-alpine` image runs `initdb` only when
|
||||
# /var/lib/postgresql/data is empty, so POSTGRES_PASSWORD is written into
|
||||
# pg_authid exactly once — on the first boot of the StatefulSet's PVC.
|
||||
# Subsequent rollouts pick up the new env value in the postgres container
|
||||
# but the certctl-server container's CERTCTL_DATABASE_URL also picks up
|
||||
# the new value, while pg_authid still expects the old one — leading to
|
||||
# `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01).
|
||||
#
|
||||
# The certctl-server emits guidance via internal/repository/postgres/db.go::
|
||||
# wrapPingError when it sees SQLSTATE 28P01 at startup. To resolve in a
|
||||
# Helm deployment:
|
||||
# - Non-destructive (preferred for environments with data):
|
||||
# kubectl exec -it <release>-postgres-0 -- \
|
||||
# psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"
|
||||
# then update the secret/values to match and let the certctl-server
|
||||
# pod restart against the matching credential.
|
||||
# - Destructive (DESTROYS DATA — only acceptable on dev/demo PVCs):
|
||||
# helm uninstall <release> && \
|
||||
# kubectl delete pvc -l app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres && \
|
||||
# helm install <release> ... # PVC re-creates empty, initdb seeds new password
|
||||
password: ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319): TLS to Postgres
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# postgresql.tls.mode is wired into the database-url sslmode parameter
|
||||
# (see templates/_helpers.tpl::certctl.databaseURL).
|
||||
#
|
||||
# Acceptable values (lib/pq):
|
||||
# disable — no TLS (default, preserves in-cluster pod-to-pod
|
||||
# traffic on the K8s pod network).
|
||||
# require — TLS required, no certificate verification.
|
||||
# verify-ca — TLS required + verify CA chain.
|
||||
# verify-full — TLS required + verify CA chain + verify hostname.
|
||||
#
|
||||
# PCI-DSS Req 4 v4.0 §2.2.5 requires verify-ca or verify-full when the
|
||||
# database carries sensitive data crossing untrusted networks (RDS,
|
||||
# Cloud SQL, cross-VPC, etc). The bundled Helm Postgres runs in the
|
||||
# same pod network as certctl-server; sslmode=disable is acceptable
|
||||
# there only when the cluster CNI provides L2/L3 encryption (Cilium
|
||||
# WireGuard, Calico Wireguard, Tailscale operator, etc).
|
||||
#
|
||||
# When mode != disable AND tls.caSecretRef is set, the CA bundle is
|
||||
# mounted at /etc/postgresql-ca/ca.crt and the server's PGSSLROOTCERT
|
||||
# env points there. caSecretRef must reference an existing Secret with
|
||||
# a "ca.crt" key.
|
||||
tls:
|
||||
mode: disable
|
||||
# caSecretRef: "" # Secret with ca.crt key (required for verify-ca/verify-full)
|
||||
password: "" # REQUIRED - set via --set or values override
|
||||
|
||||
# Storage configuration
|
||||
storage:
|
||||
@@ -471,7 +330,7 @@ agent:
|
||||
|
||||
# Image configuration
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl-agent
|
||||
repository: ghcr.io/shankar0123/certctl-agent
|
||||
tag: "" # defaults to Chart.appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
@@ -479,27 +338,6 @@ agent:
|
||||
replicas: 1
|
||||
|
||||
# Resource requests and limits
|
||||
#
|
||||
# Phase 4 DEPL-M5 (2026-05-14): per-fleet-size tuning ladder for the
|
||||
# agent. Defaults are sized for the standard "one cert per host"
|
||||
# operating pattern: the agent polls the server every 30 seconds
|
||||
# (hardcoded in cmd/agent/main.go::pollInterval — not yet
|
||||
# env-configurable), generates ECDSA P-256 keys locally on
|
||||
# issuance/renewal events, and is otherwise idle. CPU is bursty only
|
||||
# during keygen + CSR submission.
|
||||
#
|
||||
# Tuning ladder (TBD pending Phase 8 — measure on your fleet):
|
||||
#
|
||||
# 1 cert / host (typical): defaults below (50m / 64Mi req, 200m / 256Mi lim)
|
||||
# 10 certs / host: stays at defaults — agent is poll-driven, not work-bound by cert count
|
||||
# 100 certs / host (rare): raise lim to 500m / 512Mi if you see throttling on issuance bursts
|
||||
#
|
||||
# The agent does NOT cache certs in memory — issuance is one-shot
|
||||
# generate-then-deploy. So per-host memory scales with whatever
|
||||
# truststore PEM bundles the agent's connectors load (Apache /
|
||||
# Postfix / similar), not with the cert count. Defaults are
|
||||
# appropriate for any "agent terminates ≤ 100 certs on this host"
|
||||
# deployment.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
@@ -592,34 +430,14 @@ rbac:
|
||||
create: true
|
||||
|
||||
# ==============================================================================
|
||||
# Kubernetes Secrets Target Connector (PREVIEW — Bundle 3 closure / C3)
|
||||
# Kubernetes Secrets Target Connector
|
||||
# ==============================================================================
|
||||
# Bundle 3 audit closure (C3): the connector framework at
|
||||
# internal/connector/target/k8ssecret/ ships the Config + interface +
|
||||
# 14 unit tests, but the production K8s client at
|
||||
# k8ssecret.go::realK8sClient is documented as "a stub placeholder for
|
||||
# the real k8s.io/client-go implementation". The repo does not import
|
||||
# k8s.io/client-go (verified via `grep -n "client-go" go.mod`), so the
|
||||
# connector cannot deploy to a real cluster today.
|
||||
#
|
||||
# Setting kubernetesSecrets.enabled=true wires up the RBAC verbs the
|
||||
# real client will need (get/create/update/patch/delete on Secrets)
|
||||
# without making the connector functional — operators trying to use it
|
||||
# get the stub's error and a pointer to this note.
|
||||
#
|
||||
# Status: PREVIEW. Production client lands when the cluster-management
|
||||
# bundle ships (tracked in WORKSPACE-ROADMAP.md). Until then,
|
||||
# in-cluster deploys use the file-based connectors (NGINX, Apache,
|
||||
# HAProxy, etc.) via a Pod-mounted Secret + DaemonSet agent.
|
||||
kubernetesSecrets:
|
||||
# Enable RBAC rules for managing TLS Secrets
|
||||
enabled: false
|
||||
|
||||
# ==============================================================================
|
||||
# Pod Disruption Budget (for HA deployments).
|
||||
# Phase 2 DEPL-H1: defaults to enabled=false because a PDB template
|
||||
# rendered at `replicas: 1` blocks every rolling restart on a
|
||||
# single-node cluster. Production HA flips this to true alongside
|
||||
# server.replicas ≥ 2. See docs/operator/runbooks/ha.md.
|
||||
# Pod Disruption Budget (for HA deployments)
|
||||
# ==============================================================================
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
@@ -629,13 +447,6 @@ podDisruptionBudget:
|
||||
# ==============================================================================
|
||||
# Monitoring Configuration
|
||||
# ==============================================================================
|
||||
# Bundle 3 closure (D5): the ServiceMonitor template at
|
||||
# templates/servicemonitor.yaml renders when both monitoring.enabled=true
|
||||
# AND monitoring.serviceMonitor.enabled=true. The endpoint scrapes
|
||||
# /api/v1/metrics/prometheus, which is rbac-gated on `metrics.read` —
|
||||
# operators MUST provide a bearer token via
|
||||
# monitoring.serviceMonitor.bearerTokenSecret pointing at a Secret with
|
||||
# an API key holding that permission. Without the token, scrapes 401.
|
||||
monitoring:
|
||||
enabled: false
|
||||
# Prometheus ServiceMonitor
|
||||
@@ -643,196 +454,8 @@ monitoring:
|
||||
enabled: false
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
# Additional labels applied to the ServiceMonitor metadata.
|
||||
# labels: {}
|
||||
# Bearer-token Secret reference (required when the certctl server's
|
||||
# /api/v1/metrics/prometheus endpoint is gated by api-key auth).
|
||||
# Example:
|
||||
# bearerTokenSecret:
|
||||
# name: certctl-prometheus-key
|
||||
# key: api-key
|
||||
# bearerTokenSecret: {}
|
||||
# TLS config for the scrape endpoint. The certctl server presents
|
||||
# the same TLS cert the rest of the chart uses; insecureSkipVerify
|
||||
# defaults to true so demos work out of the box. Production deploys
|
||||
# should pin the CA via caFile or ca.secret.
|
||||
# tlsConfig:
|
||||
# caFile: /etc/prometheus/secrets/certctl-ca/ca.crt
|
||||
# serverName: certctl-server
|
||||
# tlsConfig: {}
|
||||
# Optional relabeling for the scrape job.
|
||||
# relabelings: []
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Phase 4 DEPL-L2 closure (2026-05-14): PrometheusRule (alert rules)
|
||||
#
|
||||
# Operator opt-in. Requires Prometheus Operator CRDs (the
|
||||
# `monitoring.coreos.com/v1` PrometheusRule kind) installed in
|
||||
# cluster. Without those CRDs the rendered object is rejected by
|
||||
# `kubectl apply` — keep enabled: false if you scrape with vanilla
|
||||
# Prometheus + AlertManager rules ConfigMap instead.
|
||||
#
|
||||
# Four starter rules ship out of the box (see
|
||||
# templates/prometheusrules.yaml for the full PromQL):
|
||||
#
|
||||
# CertctlCertificateExpiringSoon — certs expiring within 30d
|
||||
# CertctlAgentOffline — agent without heartbeat for >1h
|
||||
# CertctlJobFailureRateHigh — job-failure rate over 5% (15m)
|
||||
# CertctlIssuanceFailures — any issuance failures in last 15m
|
||||
#
|
||||
# All thresholds are operator-tunable via the `thresholds:` block
|
||||
# below. The defaults are tuned for the demo dataset (15 certs / 1
|
||||
# agent); production fleets with sustained renewal volume MAY want
|
||||
# to raise the expiringCertificateCount + jobFailureRate thresholds
|
||||
# to suppress steady-state noise.
|
||||
prometheusRules:
|
||||
enabled: false
|
||||
# Evaluation interval for the rule group.
|
||||
interval: 60s
|
||||
# Additional labels applied to the PrometheusRule metadata.
|
||||
# labels: {}
|
||||
# Per-alert threshold / duration tunables.
|
||||
thresholds:
|
||||
# Fire when more than N certs are in the expiring-soon window.
|
||||
expiringCertificateCount: 0
|
||||
expiringCertificateFor: 5m
|
||||
# Fire when more than N agents are offline (server - online).
|
||||
offlineAgentCount: 0
|
||||
offlineAgentFor: 1h
|
||||
# Fire when job failure rate exceeds this fraction (15m window).
|
||||
jobFailureRate: 0.05
|
||||
jobFailureRateFor: 15m
|
||||
# Fire when issuance failure rate exceeds this value (15m window).
|
||||
issuanceFailureRate: 0
|
||||
issuanceFailureFor: 15m
|
||||
|
||||
# ==============================================================================
|
||||
# Backup CronJob (Phase 4 DEPL-H2 closure, 2026-05-14)
|
||||
# ==============================================================================
|
||||
# Operator opt-in. Default OFF. The CronJob runs `pg_dump --format=custom
|
||||
# --no-owner --no-acl --dbname=certctl` matching the canonical shape
|
||||
# documented in docs/operator/runbooks/postgres-backup.md (so manual
|
||||
# and automated dumps are byte-identical) and ships the result to a
|
||||
# sink chosen below.
|
||||
#
|
||||
# DO NOT enable this for managed Postgres deployments (AWS RDS / GCP
|
||||
# Cloud SQL / Azure DB) — those have built-in PITR backup that this
|
||||
# CronJob cannot match. For in-cluster Postgres only.
|
||||
backup:
|
||||
enabled: false
|
||||
# Cron expression (UTC). Default: 02:30 UTC daily.
|
||||
schedule: "30 2 * * *"
|
||||
# Sink: "pvc" (default — dump lands on a PersistentVolumeClaim) or
|
||||
# "s3" (uploads via aws-cli — requires an image that bundles
|
||||
# aws-cli, see backup.image below).
|
||||
sink: pvc
|
||||
# Container image. The default postgres:16-alpine has pg_dump but
|
||||
# NOT aws-cli; for sink: s3 set this to an image that bundles both
|
||||
# (e.g. ghcr.io/your-org/postgres-aws:16) or override the Job's
|
||||
# command to install aws-cli at runtime.
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
# PVC sink config — used when sink: pvc.
|
||||
pvc:
|
||||
# Name of an existing PersistentVolumeClaim mounted at /backups
|
||||
# in the Job's pod. The PVC's storage class controls durability
|
||||
# and snapshot retention. Operator creates this PVC out of band
|
||||
# via their own storage policy.
|
||||
claimName: certctl-backups
|
||||
# S3 sink config — used when sink: s3.
|
||||
s3:
|
||||
# Target bucket (without s3:// prefix).
|
||||
bucket: ""
|
||||
# Object key prefix inside the bucket. Dumps land at
|
||||
# s3://<bucket>/<prefix>/certctl-<TIMESTAMP>.dump.
|
||||
prefix: certctl
|
||||
# AWS region (sets AWS_DEFAULT_REGION). Optional if the image's
|
||||
# AWS SDK can resolve the region another way (instance profile,
|
||||
# IRSA, etc.).
|
||||
region: ""
|
||||
# Secret holding AWS credentials. The IAM principal needs
|
||||
# s3:PutObject + s3:ListBucket on the target bucket only.
|
||||
credentialsSecret:
|
||||
name: certctl-backup-aws-creds
|
||||
accessKeyIdKey: AWS_ACCESS_KEY_ID
|
||||
secretAccessKeyKey: AWS_SECRET_ACCESS_KEY
|
||||
# Job housekeeping.
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
startingDeadlineSeconds: 300
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 3600
|
||||
# Resource budget for the backup container. pg_dump is generally
|
||||
# memory-light; ~250MB RSS for fleets up to 100K certs is typical.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
# Optional tolerations for the backup Job pod.
|
||||
tolerations: []
|
||||
|
||||
# ==============================================================================
|
||||
# Migrations via Helm hook (Phase 4 DEPL-M1 closure, 2026-05-14)
|
||||
# ==============================================================================
|
||||
# When viaHook: true, the chart deploys templates/migration-job.yaml as
|
||||
# a pre-install + pre-upgrade hook that runs `certctl-server
|
||||
# --migrate-only` (a hermetic schema-mutation pass) before the server
|
||||
# Deployment rolls.
|
||||
#
|
||||
# Set CERTCTL_MIGRATIONS_VIA_HOOK=true in the server Deployment env to
|
||||
# tell the server to skip its boot-time RunMigrations call (the hook
|
||||
# already did the work; running again at boot would race across
|
||||
# replicas during rollouts).
|
||||
#
|
||||
# Default OFF — when off, the server runs migrations at boot exactly
|
||||
# as it always has (Compose deploys keep this path).
|
||||
migrations:
|
||||
viaHook: false
|
||||
# Job housekeeping.
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 600
|
||||
# Resource budget for the migration Job pod. The migration pass is
|
||||
# I/O-bound on Postgres; matches the server's resource budget by
|
||||
# default. Override here if migrations on a large database need
|
||||
# more headroom than the steady-state server.
|
||||
# resources:
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# limits:
|
||||
# cpu: 500m
|
||||
# memory: 512Mi
|
||||
|
||||
# ==============================================================================
|
||||
# Network Policy (Bundle 3 closure / D11)
|
||||
# ==============================================================================
|
||||
# Default off so existing deploys don't suddenly lose network reach.
|
||||
# When enabled, restricts the server pod to:
|
||||
# - Ingress: from in-namespace agent pods only.
|
||||
# - Egress: kube-dns + bundled Postgres (if enabled).
|
||||
# Operators add CA / OIDC / SMTP egress via extraEgress.
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
# Additional Ingress rules merged into the policy. Each entry is a
|
||||
# raw networking.k8s.io/v1 NetworkPolicyIngressRule.
|
||||
extraIngress: []
|
||||
# Additional Egress rules merged into the policy. Common operator
|
||||
# need: 443/TCP to an OIDC issuer, 443/TCP to a public CA endpoint,
|
||||
# 25/TCP to an SMTP relay.
|
||||
# Example:
|
||||
# extraEgress:
|
||||
# - to:
|
||||
# - ipBlock:
|
||||
# cidr: 0.0.0.0/0
|
||||
# except:
|
||||
# - 10.0.0.0/8
|
||||
# ports:
|
||||
# - protocol: TCP
|
||||
# port: 443
|
||||
extraEgress: []
|
||||
# selector: {}
|
||||
|
||||
# ==============================================================================
|
||||
# Advanced Configuration
|
||||
|
||||
@@ -10,7 +10,7 @@ server:
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl
|
||||
repository: ghcr.io/shankar0123/certctl
|
||||
pullPolicy: IfNotPresent # Use latest tag
|
||||
|
||||
port: 8443
|
||||
@@ -72,7 +72,7 @@ agent:
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl-agent
|
||||
repository: ghcr.io/shankar0123/certctl-agent
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
resources:
|
||||
|
||||
@@ -12,7 +12,7 @@ server:
|
||||
replicas: 3
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl
|
||||
repository: ghcr.io/shankar0123/certctl
|
||||
tag: "2.1.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
@@ -84,7 +84,7 @@ agent:
|
||||
kind: DaemonSet
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl-agent
|
||||
repository: ghcr.io/shankar0123/certctl-agent
|
||||
tag: "2.1.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Phase 5 — install cert-manager 1.15.0 into the kind cluster brought
|
||||
# up by kind-config.yaml. Idempotent: re-running waits for the
|
||||
# existing deployment to be Ready instead of reinstalling.
|
||||
#
|
||||
# Called from: deploy/test/acme-integration/certmanager_test.go
|
||||
# Standalone: bash deploy/test/acme-integration/cert-manager-install.sh
|
||||
set -euo pipefail
|
||||
|
||||
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.15.0}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
|
||||
echo "Installing cert-manager ${CERT_MANAGER_VERSION}..."
|
||||
${KUBECTL} apply -f \
|
||||
"https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
|
||||
|
||||
echo "Waiting for cert-manager controller to be Ready (timeout 5m)..."
|
||||
${KUBECTL} -n cert-manager wait --for=condition=Available --timeout=5m \
|
||||
deployment/cert-manager \
|
||||
deployment/cert-manager-cainjector \
|
||||
deployment/cert-manager-webhook
|
||||
|
||||
echo "cert-manager ${CERT_MANAGER_VERSION} ready."
|
||||
@@ -1,20 +0,0 @@
|
||||
# Phase 5 — Certificate resource the integration test applies and
|
||||
# waits for. The certctl-test-trust ClusterIssuer (trust_authenticated
|
||||
# mode) issues the cert without any solver round-trip; the resulting
|
||||
# Secret 'test-com-tls' is asserted to carry tls.crt + tls.key.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: test-com
|
||||
namespace: default
|
||||
spec:
|
||||
secretName: test-com-tls
|
||||
commonName: test.example.com
|
||||
dnsNames:
|
||||
- test.example.com
|
||||
- www.test.example.com
|
||||
issuerRef:
|
||||
name: certctl-test-trust
|
||||
kind: ClusterIssuer
|
||||
duration: 720h # 30d
|
||||
renewBefore: 240h # 10d
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
//go:build integration
|
||||
|
||||
// Phase 5 — kind-driven cert-manager integration test. Verifies the
|
||||
// certctl ACME server end-to-end against a real cert-manager 1.15+
|
||||
// deployment in a kind cluster. The test sequences:
|
||||
//
|
||||
// 1. Bring up the kind cluster (kind-config.yaml).
|
||||
// 2. Install cert-manager 1.15 (cert-manager-install.sh).
|
||||
// 3. Helm-install certctl-server with acmeServer.enabled=true.
|
||||
// 4. Apply the ClusterIssuer + Certificate.
|
||||
// 5. Wait for the Certificate to become Ready.
|
||||
// 6. Assert the Secret has tls.crt + tls.key.
|
||||
//
|
||||
// Gated behind KIND_AVAILABLE — CI doesn't run kind and skips this
|
||||
// cleanly. Operators run locally via `make acme-cert-manager-test`.
|
||||
|
||||
package acmeintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// kindAvailable returns true when the operator opted into the kind-
|
||||
// driven test path. CI default is opt-out (env unset → skip).
|
||||
func kindAvailable() bool {
|
||||
return os.Getenv("KIND_AVAILABLE") != ""
|
||||
}
|
||||
|
||||
// kindClusterName is the name passed to `kind create/delete cluster`.
|
||||
// Kept as a const so the test cleanup uses the exact same name as
|
||||
// setup (avoid orphan-cluster-after-flake).
|
||||
const kindClusterName = "certctl-acme-test"
|
||||
|
||||
// TestCertManagerTrustAuthenticatedIssuance is the happy-path
|
||||
// integration: cert-manager submits a new-order against a profile in
|
||||
// trust_authenticated mode; certctl auto-resolves authzs (no solver
|
||||
// round-trip in this mode); cert-manager finalizes; the Secret lands.
|
||||
//
|
||||
// Runtime: ~6-8 minutes wall-clock on a workstation (most of which is
|
||||
// kind-create + cert-manager-controller-bootstrap, both cached on
|
||||
// re-runs after the first). Skips cleanly when KIND_AVAILABLE is
|
||||
// unset.
|
||||
func TestCertManagerTrustAuthenticatedIssuance(t *testing.T) {
|
||||
if !kindAvailable() {
|
||||
t.Skip("KIND_AVAILABLE unset — kind-driven cert-manager integration test skipped")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Log("creating kind cluster")
|
||||
runCmd(t, ctx, "kind", "create", "cluster",
|
||||
"--name", kindClusterName,
|
||||
"--config", "kind-config.yaml")
|
||||
t.Cleanup(func() {
|
||||
// Best-effort cluster teardown — never fail the test on cleanup
|
||||
// failure (operator can `kind delete cluster` manually).
|
||||
_ = exec.Command("kind", "delete", "cluster", "--name", kindClusterName).Run()
|
||||
})
|
||||
|
||||
t.Log("installing cert-manager")
|
||||
runCmd(t, ctx, "bash", "cert-manager-install.sh")
|
||||
|
||||
// Step 3 — deploy certctl-server. The Helm chart at
|
||||
// deploy/helm/certctl/ takes acmeServer.enabled=true; the operator
|
||||
// is expected to have built + pushed (or kind-loaded) a `:test`
|
||||
// image tag before the test runs. Document this in docs/acme-server.md.
|
||||
t.Log("helm-installing certctl-test")
|
||||
runCmd(t, ctx, "helm", "install", "certctl-test", "../../helm/certctl/",
|
||||
"--set", "acmeServer.enabled=true",
|
||||
"--set", "acmeServer.defaultProfileId=prof-test",
|
||||
"--set", "image.tag=test",
|
||||
)
|
||||
waitForDeploymentReady(t, ctx, "default", "certctl-test", 3*time.Minute)
|
||||
|
||||
t.Log("applying ClusterIssuer + Certificate")
|
||||
runCmd(t, ctx, "kubectl", "apply", "-f", "clusterissuer-trust-authenticated.yaml")
|
||||
runCmd(t, ctx, "kubectl", "apply", "-f", "certificate-test.yaml")
|
||||
|
||||
t.Log("waiting for Certificate to become Ready")
|
||||
waitForCertificateReady(t, ctx, "default", "test-com", 3*time.Minute)
|
||||
|
||||
t.Log("asserting Secret has tls.crt")
|
||||
assertSecretHasCert(t, ctx, "default", "test-com-tls")
|
||||
|
||||
t.Log("happy-path issuance verified end-to-end")
|
||||
}
|
||||
|
||||
// runCmd runs the command; failures fail the test immediately. We
|
||||
// stream combined stdout+stderr to t.Log on completion so the operator
|
||||
// can read the kubectl/kind output in CI logs (when run there with
|
||||
// KIND_AVAILABLE=1).
|
||||
func runCmd(t *testing.T, ctx context.Context, name string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, out)
|
||||
}
|
||||
t.Logf("%s %s: %s", name, strings.Join(args, " "), strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// waitForDeploymentReady polls until the named deployment reports
|
||||
// Available=True. Wraps `kubectl wait` with a Go-level timeout so test
|
||||
// hangs are bounded.
|
||||
func waitForDeploymentReady(t *testing.T, ctx context.Context, namespace, name string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "wait",
|
||||
"--for=condition=Available", fmt.Sprintf("--timeout=%ds", int(timeout.Seconds())),
|
||||
"deployment/"+name) //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("deployment %s/%s did not become Ready in %v: %v\n%s",
|
||||
namespace, name, timeout, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForCertificateReady polls until the cert-manager Certificate
|
||||
// resource transitions to Ready=True. cert-manager's own
|
||||
// reconciliation loop is what advances the state; this just blocks
|
||||
// until the controller is happy.
|
||||
func waitForCertificateReady(t *testing.T, ctx context.Context, namespace, name string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "wait",
|
||||
"--for=condition=Ready", fmt.Sprintf("--timeout=%ds", int(timeout.Seconds())),
|
||||
"certificate/"+name) //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Dump the Certificate's events on failure so the operator
|
||||
// can see exactly which reconciliation step failed.
|
||||
describe := exec.Command("kubectl", "-n", namespace, "describe", "certificate", name)
|
||||
describeOut, _ := describe.CombinedOutput()
|
||||
t.Fatalf("certificate %s/%s did not become Ready in %v: %v\n%s\n--- describe ---\n%s",
|
||||
namespace, name, timeout, err, out, describeOut)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSecretHasCert checks that the named Secret has a non-empty
|
||||
// tls.crt entry. We don't validate the chain itself here — that's the
|
||||
// job of certctl's own integration test layer; this just confirms
|
||||
// cert-manager wrote something into the Secret on the
|
||||
// trust_authenticated happy-path.
|
||||
func assertSecretHasCert(t *testing.T, ctx context.Context, namespace, name string) {
|
||||
t.Helper()
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "get", "secret", name,
|
||||
"-o", "jsonpath={.data.tls\\.crt}") //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("get secret %s/%s: %v\n%s", namespace, name, err, out)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("secret %s/%s has empty tls.crt", namespace, name)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
# Phase 5 — sample ClusterIssuer for the certctl challenge auth mode
|
||||
# (RFC 8555 §8 HTTP-01 / DNS-01 / TLS-ALPN-01). Use this for public-
|
||||
# trust-style deployments where per-identifier ownership proof is
|
||||
# required.
|
||||
#
|
||||
# Same bootstrap-root caBundle requirement as the trust_authenticated
|
||||
# variant — see clusterissuer-trust-authenticated.yaml comments.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: certctl-test-challenge
|
||||
spec:
|
||||
acme:
|
||||
email: test@example.com
|
||||
# Point at a profile whose certificate_profiles.acme_auth_mode is
|
||||
# set to 'challenge'. The certctl operator manages this column
|
||||
# per-profile; see certctl/docs/acme-server.md "Per-profile auth
|
||||
# mode" section.
|
||||
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-challenge/directory
|
||||
caBundle: |
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
|
||||
privateKeySecretRef:
|
||||
name: certctl-test-challenge-account-key
|
||||
solvers:
|
||||
# HTTP-01 via the in-cluster ingress-nginx. The cert-manager
|
||||
# http-solver pod publishes the key authorization at
|
||||
# http://<identifier>/.well-known/acme-challenge/<token>; the
|
||||
# certctl HTTP01Validator (Phase 3) fetches it.
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
@@ -1,42 +0,0 @@
|
||||
# Phase 5 — sample ClusterIssuer for the certctl trust_authenticated
|
||||
# auth mode (RFC 8555 §6 + certctl auth_mode=trust_authenticated, where
|
||||
# the JWS-authenticated ACME account is trusted to issue any identifier
|
||||
# the profile policy permits — no per-identifier ownership challenges).
|
||||
#
|
||||
# Use this as the starting template for any internal-PKI rollout.
|
||||
# Replace the caBundle placeholder with the base64-encoded PEM of the
|
||||
# certctl-server's self-signed bootstrap root, then `kubectl apply`.
|
||||
#
|
||||
# Generate the caBundle via:
|
||||
# cat deploy/test/certs/ca.crt | base64 -w0
|
||||
# (See certctl/docs/acme-server.md "TLS trust bootstrap" section for the
|
||||
# end-to-end walkthrough — this is the single biggest first-time-deploy
|
||||
# footgun on cert-manager, captured as audit fix #9.)
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: certctl-test-trust
|
||||
spec:
|
||||
acme:
|
||||
email: test@example.com
|
||||
# Replace 'certctl-test' with your release name + adjust the
|
||||
# profile path segment. Default profile path:
|
||||
# https://<service>.<namespace>.svc.cluster.local:8443/acme/profile/<profile-id>/directory
|
||||
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-test/directory
|
||||
# caBundle: Audit fix #9. cert-manager validates the ACME server's
|
||||
# TLS chain before submitting any account/order/finalize. With a
|
||||
# self-signed bootstrap root, the ClusterIssuer MUST carry the root
|
||||
# explicitly via this field.
|
||||
caBundle: |
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
|
||||
privateKeySecretRef:
|
||||
name: certctl-test-trust-account-key
|
||||
solvers:
|
||||
# In trust_authenticated mode the solver is unused at the
|
||||
# validation step but cert-manager still requires at least one
|
||||
# solver in the spec. http01-via-ingress-nginx is the cheapest
|
||||
# placeholder shape that round-trips correctly through cert-
|
||||
# manager's validation webhooks.
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Phase 5 — lego-driven RFC 8555 conformance test. Drives a real ACME
|
||||
# client (lego v4) against the certctl ACME server in trust_authenticated
|
||||
# mode and exercises the full happy-path: register → new-order →
|
||||
# finalize → cert download.
|
||||
#
|
||||
# Caller (`make acme-rfc-conformance-test`) brings up the certctl
|
||||
# docker-compose stack first; this script just runs lego against it.
|
||||
#
|
||||
# Skips cleanly when CERTCTL_ACME_DIR is unset (the operator probably
|
||||
# meant to run the make target instead of this script directly).
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${CERTCTL_ACME_DIR:-}" ]]; then
|
||||
echo "CERTCTL_ACME_DIR unset — point at the certctl ACME directory URL"
|
||||
echo " e.g. CERTCTL_ACME_DIR=https://localhost:8443/acme/profile/prof-test/directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKDIR="$(mktemp -d -t certctl-lego-conf-XXXXXX)"
|
||||
trap 'rm -rf "${WORKDIR}"' EXIT
|
||||
|
||||
# Skip TLS verification — the test stack uses certctl's self-signed
|
||||
# bootstrap cert. Operators in production use --insecure-skip-verify=false
|
||||
# and pass --tls-bundle for the real CA.
|
||||
LEGO_INSECURE="--insecure-skip-verify"
|
||||
|
||||
# Step 1: register a fresh account.
|
||||
echo "==> lego: register account"
|
||||
lego --server "${CERTCTL_ACME_DIR}" \
|
||||
--email conformance@example.com \
|
||||
--domains conformance.example.com \
|
||||
--path "${WORKDIR}" \
|
||||
--accept-tos \
|
||||
${LEGO_INSECURE} \
|
||||
register
|
||||
|
||||
# Step 2: issue a cert (trust_authenticated mode auto-resolves authzs).
|
||||
echo "==> lego: run (issue conformance.example.com)"
|
||||
lego --server "${CERTCTL_ACME_DIR}" \
|
||||
--email conformance@example.com \
|
||||
--domains conformance.example.com \
|
||||
--path "${WORKDIR}" \
|
||||
--accept-tos \
|
||||
${LEGO_INSECURE} \
|
||||
run
|
||||
|
||||
# Step 3: assert the cert PEM landed.
|
||||
CERT_FILE="${WORKDIR}/certificates/conformance.example.com.crt"
|
||||
if [[ ! -s "${CERT_FILE}" ]]; then
|
||||
echo "FAIL: ${CERT_FILE} is missing or empty"
|
||||
exit 1
|
||||
fi
|
||||
openssl x509 -in "${CERT_FILE}" -noout -subject -issuer -dates
|
||||
echo "PASS: lego conformance happy-path completed"
|
||||
@@ -1,34 +0,0 @@
|
||||
# Phase 5 — kind-cluster shape for the cert-manager integration test.
|
||||
#
|
||||
# Single control-plane + single worker. Port 8443 (certctl ACME server)
|
||||
# and 80/443 (ingress-nginx for HTTP-01 solver) are extra-mapped onto
|
||||
# the host so the in-test workflow can curl the in-cluster services.
|
||||
#
|
||||
# Used by: deploy/test/acme-integration/certmanager_test.go
|
||||
# Invoked via: kind create cluster --name certctl-acme-test --config <this file>
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: certctl-acme-test
|
||||
nodes:
|
||||
- role: control-plane
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
# ingress-nginx HTTP — needed for the challenge-mode solver.
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
# certctl-server HTTPS (the ACME directory + JWS-authenticated
|
||||
# POST surface). Only required for out-of-cluster smoke tests; the
|
||||
# in-cluster ClusterIssuer talks via Service DNS.
|
||||
- containerPort: 30843
|
||||
hostPort: 8443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
@@ -1,13 +0,0 @@
|
||||
# Deploy-hardening II Phase 1 — minimal Apache SSL config for the
|
||||
# apache-test sidecar. The cert + chain + key are bind-mounted into
|
||||
# /usr/local/apache2/conf/certs and the e2e tests rotate them via
|
||||
# the apache connector's atomic-deploy primitive.
|
||||
LoadModule ssl_module modules/mod_ssl.so
|
||||
Listen 443
|
||||
<VirtualHost *:443>
|
||||
ServerName apache-test.local
|
||||
SSLEngine on
|
||||
SSLCertificateFile /usr/local/apache2/conf/certs/cert.pem
|
||||
SSLCertificateKeyFile /usr/local/apache2/conf/certs/key.pem
|
||||
SSLCertificateChainFile /usr/local/apache2/conf/certs/chain.pem
|
||||
</VirtualHost>
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Generate an initial known-good cert so Apache starts cleanly. The
|
||||
# e2e tests rotate this via the connector.
|
||||
set -e
|
||||
mkdir -p /usr/local/apache2/conf/certs
|
||||
if [ ! -f /usr/local/apache2/conf/certs/cert.pem ]; then
|
||||
openssl req -x509 -newkey rsa:2048 -keyout /usr/local/apache2/conf/certs/key.pem \
|
||||
-out /usr/local/apache2/conf/certs/cert.pem -days 1 -nodes \
|
||||
-subj "/CN=apache-test.local"
|
||||
cp /usr/local/apache2/conf/certs/cert.pem /usr/local/apache2/conf/certs/chain.pem
|
||||
fi
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
admin 0.0.0.0:2019
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:443 {
|
||||
tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem
|
||||
respond "OK"
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
// Package integration_test — CRL/OCSP-Responder Bundle Phase 6 e2e.
|
||||
//
|
||||
// Verifies the full revocation-status flow against a live stack:
|
||||
// 1. Issue a cert via the local issuer.
|
||||
// 2. Fetch the OCSP response for that cert's serial — expect Good.
|
||||
// 3. Revoke the cert via the standard revoke endpoint.
|
||||
// 4. Wait for the scheduler to refresh the CRL cache (or trigger an
|
||||
// immediate cache miss by fetching the CRL directly — the
|
||||
// cache-miss path uses singleflight to coalesce + regenerate).
|
||||
// 5. Fetch the CRL — assert the cert's serial is in the revocation list.
|
||||
// 6. Fetch the OCSP response again — expect Revoked.
|
||||
// 7. Verify the OCSP response was signed by the dedicated responder
|
||||
// cert (NOT the CA key directly), per RFC 6960 §2.6.
|
||||
// 8. Verify the responder cert carries id-pkix-ocsp-nocheck (RFC 6960
|
||||
// §4.2.2.2.1).
|
||||
//
|
||||
// Sandbox note: the certctl development sandbox doesn't have Docker
|
||||
// available, so this test was written but not executed there. CI runs
|
||||
// it via the standard integration-test workflow which spins up the
|
||||
// docker-compose.test.yml stack. Run locally:
|
||||
//
|
||||
// cd deploy && docker compose -f docker-compose.test.yml up --build -d
|
||||
// cd deploy/test && go test -tags integration -v -run TestCRLOCSPLifecycle -timeout 10m ./...
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test-stack-specific identifiers — match deploy/docker-compose.test.yml's
|
||||
// seed data + migrations/seed.sql. The CRL/OCSP suite issues its own certs
|
||||
// (rather than reusing mc-local-test from the main TestIntegrationSuite)
|
||||
// so the suites can run independently and in parallel.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
crlE2EIssuerID = "iss-local"
|
||||
crlE2EOwnerID = "owner-test-admin"
|
||||
crlE2ETeamID = "team-test-ops"
|
||||
crlE2EPolicyID = "rp-default"
|
||||
crlE2EProfileID = "prof-test-tls"
|
||||
crlE2EJobsTimeout = 180 * time.Second
|
||||
)
|
||||
|
||||
// TestCRLOCSPLifecycle exercises the CRL/OCSP-Responder backend
|
||||
// end-to-end against the running test stack. Skipped in -short.
|
||||
func TestCRLOCSPLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration only")
|
||||
}
|
||||
|
||||
// Boot-state preconditions — assumes docker-compose.test.yml is
|
||||
// up; the existing integration_test.go tests rely on the same
|
||||
// invariant. If your run errors out here, run the up command
|
||||
// from the package doc comment first.
|
||||
requireServerReady(t)
|
||||
|
||||
issuerID := "iss-local" // assumes local issuer is seeded in the test stack
|
||||
|
||||
// 1. Issue a cert. Reuses the existing helper from integration_test.go
|
||||
// (issueCertificateAgainstLocal).
|
||||
cert, certPEM, certSerial := issueLocalCert(t, "crl-ocsp-e2e.example.com")
|
||||
t.Logf("issued cert serial=%s", certSerial)
|
||||
|
||||
// 2. Fetch OCSP for the fresh cert — expect Good.
|
||||
resp1, responder1 := fetchOCSP(t, issuerID, certSerial)
|
||||
if resp1.Status != ocsp.Good {
|
||||
t.Fatalf("pre-revoke OCSP status = %d, want Good (0)", resp1.Status)
|
||||
}
|
||||
if !certHasOCSPNoCheck(responder1) {
|
||||
t.Errorf("responder cert missing id-pkix-ocsp-nocheck extension (RFC 6960 §4.2.2.2.1)")
|
||||
}
|
||||
if responder1.Subject.CommonName == cert.Issuer.CommonName {
|
||||
t.Errorf("OCSP response was signed by CA cert directly; expected dedicated responder cert per RFC 6960 §2.6")
|
||||
}
|
||||
|
||||
// 3. Revoke the cert via the standard API.
|
||||
revokeCertViaAPI(t, certSerial, "key_compromise")
|
||||
|
||||
// 4. Trigger the cache-miss path by fetching CRL directly.
|
||||
// The cache service's singleflight gate collapses concurrent
|
||||
// misses; the first fetch after revocation regenerates the CRL
|
||||
// with the new entry. (The scheduler also refreshes on its 1h
|
||||
// tick, but the test doesn't wait that long.)
|
||||
time.Sleep(2 * time.Second) // allow scheduler debounce
|
||||
|
||||
crl := fetchCRL(t, issuerID)
|
||||
if !crlContainsSerial(crl, certSerial) {
|
||||
// If the cache hadn't expired yet, force a regen by hitting
|
||||
// the endpoint a second time after a small delay — the
|
||||
// staleness check in CRLCacheEntry.IsStale flips on
|
||||
// next_update.
|
||||
time.Sleep(3 * time.Second)
|
||||
crl = fetchCRL(t, issuerID)
|
||||
if !crlContainsSerial(crl, certSerial) {
|
||||
t.Fatalf("revoked serial %s not present in CRL after wait", certSerial)
|
||||
}
|
||||
}
|
||||
t.Logf("CRL contains revoked serial %s", certSerial)
|
||||
|
||||
// 5. Fetch OCSP again — expect Revoked.
|
||||
resp2, _ := fetchOCSP(t, issuerID, certSerial)
|
||||
if resp2.Status != ocsp.Revoked {
|
||||
t.Fatalf("post-revoke OCSP status = %d, want Revoked (1)", resp2.Status)
|
||||
}
|
||||
t.Logf("OCSP shows revoked, reason=%d", resp2.RevocationReason)
|
||||
|
||||
// 6. Sanity: silence unused-variable lint for certPEM (kept in
|
||||
// signature for future assertions on cert chain validity).
|
||||
_ = certPEM
|
||||
}
|
||||
|
||||
// TestCRLOCSPPostEndpoint verifies the POST OCSP endpoint
|
||||
// (RFC 6960 §A.1.1) accepts a binary OCSPRequest body. Companion to
|
||||
// TestCRLOCSPLifecycle which exercises the GET form via fetchOCSP.
|
||||
func TestCRLOCSPPostEndpoint(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration only")
|
||||
}
|
||||
requireServerReady(t)
|
||||
|
||||
cert, _, certSerial := issueLocalCert(t, "post-ocsp-e2e.example.com")
|
||||
caCert := fetchCACert(t, "iss-local")
|
||||
|
||||
ocspReq, err := ocsp.CreateRequest(cert, caCert, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRequest: %v", err)
|
||||
}
|
||||
|
||||
url := serverBaseURL(t) + "/.well-known/pki/ocsp/iss-local"
|
||||
httpReq, err := http.NewRequest(http.MethodPost, url, strings.NewReader(string(ocspReq)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/ocsp-request")
|
||||
|
||||
httpResp, err := httpClient(t).Do(httpReq)
|
||||
if err != nil {
|
||||
t.Fatalf("POST OCSP: %v", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(httpResp.Body)
|
||||
t.Fatalf("POST OCSP: status %d, body=%s", httpResp.StatusCode, body)
|
||||
}
|
||||
respBytes, _ := io.ReadAll(httpResp.Body)
|
||||
parsed, err := ocsp.ParseResponse(respBytes, caCert)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse: %v", err)
|
||||
}
|
||||
if parsed.SerialNumber.Cmp(cert.SerialNumber) != 0 {
|
||||
t.Errorf("POST OCSP response serial mismatch: got %v, want %v",
|
||||
parsed.SerialNumber, cert.SerialNumber)
|
||||
}
|
||||
t.Logf("POST OCSP returned status=%d for serial=%s", parsed.Status, certSerial)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — these wrap the existing integration_test.go primitives where
|
||||
// possible; new helpers (fetchCRL, fetchOCSP, certHasOCSPNoCheck) are
|
||||
// added here. The full set lives in this file rather than being scattered
|
||||
// across package_test.go to keep the e2e suite self-contained per the
|
||||
// existing convention.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// crlE2ECert tracks the certctl-side ID + the parsed leaf together. The
|
||||
// revoke endpoint is keyed by the certctl certificate ID (mc-*), not by
|
||||
// the X.509 serial — so the test threads both through the helpers.
|
||||
type crlE2ECert struct {
|
||||
CertctlID string // e.g. "mc-crl-e2e-<n>"
|
||||
Leaf *x509.Certificate // parsed leaf
|
||||
HexSerial string // lowercase hex of Leaf.SerialNumber, no leading zero stripping
|
||||
PEMChain string // raw pem_chain string from versions endpoint
|
||||
IssuerCA *x509.Certificate // parsed issuer CA (chain[1] when present, else chain[0])
|
||||
}
|
||||
|
||||
// crlE2ECerts holds the in-flight cert-ID → cert mapping so revokeCertViaAPI
|
||||
// can resolve the hex serial back to the certctl cert ID. Populated by
|
||||
// issueLocalCert. Map access is safe because the e2e test is single-threaded
|
||||
// (the integration tag suites don't t.Parallel()).
|
||||
var crlE2ECerts = map[string]*crlE2ECert{}
|
||||
|
||||
// issueLocalCert issues a cert against the test-stack's local issuer and
|
||||
// returns the parsed leaf + raw PEM chain + hex serial. Wires through the
|
||||
// existing integration_test.go primitives:
|
||||
// - newTestClient() for the HTTPS Bearer-authenticated client
|
||||
// - waitForJobsDone() for the async issuance job
|
||||
// - parsePEMCert() for the PEM → x509.Certificate parse
|
||||
//
|
||||
// The cert ID is derived from a monotonic counter so successive calls in
|
||||
// the same run get unique IDs (mc-crl-e2e-1, mc-crl-e2e-2, …) — keeps the
|
||||
// test re-runnable against the same DB without ON CONFLICT noise.
|
||||
func issueLocalCert(t *testing.T, commonName string) (cert *x509.Certificate, certPEM string, hexSerial string) {
|
||||
t.Helper()
|
||||
|
||||
c := newTestClient()
|
||||
|
||||
certID := fmt.Sprintf("mc-crl-e2e-%d", len(crlE2ECerts)+1)
|
||||
body := fmt.Sprintf(`{
|
||||
"id": %q,
|
||||
"name": %q,
|
||||
"common_name": %q,
|
||||
"sans": [%q],
|
||||
"issuer_id": %q,
|
||||
"owner_id": %q,
|
||||
"team_id": %q,
|
||||
"renewal_policy_id": %q,
|
||||
"certificate_profile_id": %q,
|
||||
"environment": "test"
|
||||
}`, certID, certID, commonName, commonName,
|
||||
crlE2EIssuerID, crlE2EOwnerID, crlE2ETeamID, crlE2EPolicyID, crlE2EProfileID)
|
||||
|
||||
resp, err := c.Post("/api/v1/certificates", body)
|
||||
if err != nil {
|
||||
t.Fatalf("issueLocalCert: POST /certificates: %v", err)
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
t.Fatalf("issueLocalCert: POST status %d, body=%s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Trigger issuance + wait for the job to finish.
|
||||
resp, err = c.Post("/api/v1/certificates/"+certID+"/renew", "")
|
||||
if err != nil {
|
||||
t.Fatalf("issueLocalCert: POST renew: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
waitForJobsDone(t, c, certID, crlE2EJobsTimeout)
|
||||
|
||||
// Pull the freshly-issued version.
|
||||
resp, err = c.Get("/api/v1/certificates/" + certID + "/versions")
|
||||
if err != nil {
|
||||
t.Fatalf("issueLocalCert: GET versions: %v", err)
|
||||
}
|
||||
rawBody := readBody(resp)
|
||||
var versions []certVersion
|
||||
if err := json.Unmarshal([]byte(rawBody), &versions); err != nil {
|
||||
// Versions endpoint may use the paged envelope.
|
||||
var pr pagedResponse
|
||||
if err := json.Unmarshal([]byte(rawBody), &pr); err != nil {
|
||||
t.Fatalf("issueLocalCert: decode versions: %v (body: %s)", err, rawBody)
|
||||
}
|
||||
if err := json.Unmarshal(pr.Data, &versions); err != nil {
|
||||
t.Fatalf("issueLocalCert: unmarshal paged versions: %v", err)
|
||||
}
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
t.Fatalf("issueLocalCert: no versions returned for %s", certID)
|
||||
}
|
||||
v := versions[0]
|
||||
if v.PEMChain == "" {
|
||||
t.Fatalf("issueLocalCert: empty pem_chain on version %s", v.ID)
|
||||
}
|
||||
|
||||
leaf, issuerCA := parsePEMChain(t, v.PEMChain)
|
||||
hex := strings.ToLower(leaf.SerialNumber.Text(16))
|
||||
|
||||
crlE2ECerts[hex] = &crlE2ECert{
|
||||
CertctlID: certID,
|
||||
Leaf: leaf,
|
||||
HexSerial: hex,
|
||||
PEMChain: v.PEMChain,
|
||||
IssuerCA: issuerCA,
|
||||
}
|
||||
return leaf, v.PEMChain, hex
|
||||
}
|
||||
|
||||
// parsePEMChain decodes a leaf || issuer || ... PEM bundle. Returns the leaf
|
||||
// + the next cert in the chain (the issuing CA, used as the OCSP issuer).
|
||||
// If the chain has only one cert (self-signed test root), returns it twice.
|
||||
func parsePEMChain(t *testing.T, chainPEM string) (leaf, issuer *x509.Certificate) {
|
||||
t.Helper()
|
||||
rest := []byte(chainPEM)
|
||||
var certs []*x509.Certificate
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
c, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePEMChain: %v", err)
|
||||
}
|
||||
certs = append(certs, c)
|
||||
}
|
||||
if len(certs) == 0 {
|
||||
t.Fatalf("parsePEMChain: no certificates decoded from chain")
|
||||
}
|
||||
leaf = certs[0]
|
||||
if len(certs) >= 2 {
|
||||
issuer = certs[1]
|
||||
} else {
|
||||
issuer = certs[0] // self-signed test root
|
||||
}
|
||||
return leaf, issuer
|
||||
}
|
||||
|
||||
// revokeCertViaAPI calls POST /api/v1/certificates/{id}/revoke. The certctl
|
||||
// API keys revocation by certctl cert ID (mc-*), not by X.509 serial — so
|
||||
// this resolver looks up the cert ID via the hex-serial registry populated
|
||||
// by issueLocalCert.
|
||||
func revokeCertViaAPI(t *testing.T, hexSerial string, reason string) {
|
||||
t.Helper()
|
||||
entry, ok := crlE2ECerts[strings.ToLower(hexSerial)]
|
||||
if !ok {
|
||||
t.Fatalf("revokeCertViaAPI: no certctl ID registered for serial %s — call issueLocalCert first", hexSerial)
|
||||
}
|
||||
c := newTestClient()
|
||||
body := fmt.Sprintf(`{"reason": %q}`, reason)
|
||||
resp, err := c.Post("/api/v1/certificates/"+entry.CertctlID+"/revoke", body)
|
||||
if err != nil {
|
||||
t.Fatalf("revokeCertViaAPI: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
t.Fatalf("revokeCertViaAPI: POST status %d, body=%s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// fetchCRL hits GET /.well-known/pki/crl/{issuer_id} and returns the
|
||||
// parsed RevocationList. Asserts 200 + content-type.
|
||||
func fetchCRL(t *testing.T, issuerID string) *x509.RevocationList {
|
||||
t.Helper()
|
||||
url := serverBaseURL(t) + "/.well-known/pki/crl/" + issuerID
|
||||
resp, err := httpClient(t).Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchCRL Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("fetchCRL: status %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
crl, err := x509.ParseRevocationList(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRevocationList: %v", err)
|
||||
}
|
||||
return crl
|
||||
}
|
||||
|
||||
// fetchOCSP hits the GET form of the OCSP endpoint (the POST form is
|
||||
// exercised separately in TestCRLOCSPPostEndpoint). Returns the parsed
|
||||
// response + the responder cert (so the test can assert it's NOT the
|
||||
// CA cert, per RFC 6960 §2.6).
|
||||
func fetchOCSP(t *testing.T, issuerID, hexSerial string) (*ocsp.Response, *x509.Certificate) {
|
||||
t.Helper()
|
||||
url := fmt.Sprintf("%s/.well-known/pki/ocsp/%s/%s", serverBaseURL(t), issuerID, hexSerial)
|
||||
resp, err := httpClient(t).Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchOCSP Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("fetchOCSP: status %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
caCert := fetchCACert(t, issuerID)
|
||||
parsed, err := ocsp.ParseResponse(body, caCert)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse: %v", err)
|
||||
}
|
||||
return parsed, parsed.Certificate
|
||||
}
|
||||
|
||||
// fetchCACert returns the issuing CA certificate for the given issuer.
|
||||
//
|
||||
// Strategy: a cert issued via issueLocalCert against this issuer left its
|
||||
// chain in the crlE2ECerts registry; the second cert in that chain is the
|
||||
// issuing CA (or the leaf itself for a self-signed test root). This
|
||||
// avoids a dependency on a /.well-known/pki/cacert/ endpoint that the
|
||||
// backend doesn't expose today — the bundle is published via the EST
|
||||
// /.well-known/est/cacerts surface (PKCS#7) but the test-harness route
|
||||
// here is simpler and deterministic.
|
||||
//
|
||||
// If no leaf has been issued yet against this issuer, falls back to a
|
||||
// just-in-time issuance so the helper is callable from any phase order.
|
||||
func fetchCACert(t *testing.T, issuerID string) *x509.Certificate {
|
||||
t.Helper()
|
||||
for _, entry := range crlE2ECerts {
|
||||
if entry.IssuerCA != nil && entry.Leaf.Issuer.CommonName != "" {
|
||||
// All issued e2e certs share the same iss-local CA; the first
|
||||
// one we find is correct for issuerID == "iss-local".
|
||||
if issuerID == crlE2EIssuerID || strings.HasPrefix(issuerID, "iss-local") {
|
||||
return entry.IssuerCA
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: no cert in registry for this issuer yet — synthesise one.
|
||||
_, _, _ = issueLocalCert(t, fmt.Sprintf("cacert-bootstrap-%d.example.com", time.Now().UnixNano()))
|
||||
for _, entry := range crlE2ECerts {
|
||||
if entry.IssuerCA != nil {
|
||||
return entry.IssuerCA
|
||||
}
|
||||
}
|
||||
t.Fatalf("fetchCACert: no CA cert resolvable for issuer %s after bootstrap", issuerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// crlContainsSerial returns true if the parsed CRL has an entry for
|
||||
// the given hex-encoded serial.
|
||||
func crlContainsSerial(crl *x509.RevocationList, hexSerial string) bool {
|
||||
target := new(big.Int)
|
||||
target.SetString(hexSerial, 16)
|
||||
for _, entry := range crl.RevokedCertificateEntries {
|
||||
if entry.SerialNumber.Cmp(target) == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// certHasOCSPNoCheck returns true if the cert carries the
|
||||
// id-pkix-ocsp-nocheck extension (OID 1.3.6.1.5.5.7.48.1.5) per
|
||||
// RFC 6960 §4.2.2.2.1.
|
||||
func certHasOCSPNoCheck(cert *x509.Certificate) bool {
|
||||
if cert == nil {
|
||||
return false
|
||||
}
|
||||
oid := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
|
||||
for _, ext := range cert.Extensions {
|
||||
if ext.Id.Equal(oid) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requireServerReady polls /health until it returns 200, or t.Fatals after
|
||||
// 30s. The endpoint is unauthenticated (router.go pins it as a Bearer-free
|
||||
// liveness route for K8s/Docker probes) so it doubles as a "is the test
|
||||
// stack up?" probe before the suite makes its first authenticated call.
|
||||
func requireServerReady(t *testing.T) {
|
||||
t.Helper()
|
||||
client := newUnauthHTTPClient()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
url := serverURL + "/health"
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("requireServerReady: %s never returned 200 within 30s — is the test stack up? (run `docker compose -f deploy/docker-compose.test.yml up -d` first)", url)
|
||||
}
|
||||
|
||||
// serverBaseURL returns the server URL configured by the integration
|
||||
// harness (CERTCTL_TEST_SERVER_URL, defaulting to https://localhost:8443
|
||||
// per deploy/docker-compose.test.yml).
|
||||
func serverBaseURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
return serverURL
|
||||
}
|
||||
|
||||
// httpClient returns the unauthenticated TLS-trust-aware client from the
|
||||
// integration harness. The /.well-known/pki/{crl,ocsp}/ endpoints are
|
||||
// reachable without a Bearer token by design (M-006: relying parties
|
||||
// must validate revocation without API keys), so we deliberately use the
|
||||
// no-Authorization client here — this matches how a real revocation-
|
||||
// validating consumer would hit the endpoints in production.
|
||||
func httpClient(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
return newUnauthHTTPClient()
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
// Package test contains the deploy-hardening I Phase 11 cross-
|
||||
// cutting end-to-end integration tests. These exercise the
|
||||
// internal/deploy package's load-bearing invariants end-to-end:
|
||||
//
|
||||
// - atomicity: kill mid-deploy → file is fully old or fully new;
|
||||
// never torn.
|
||||
// - post-verify: deploy a wrong-fingerprint cert + the connector's
|
||||
// verify hook → the rollback wire restores the previous bytes.
|
||||
// - idempotency: deploy the same bytes twice → the second attempt
|
||||
// is a no-op (no PreCommit/PostCommit calls).
|
||||
// - concurrency: N simultaneous deploys to the same destination
|
||||
// serialize via the deploy package's file-level mutex.
|
||||
//
|
||||
// Run via `INTEGRATION=1 go test -tags integration -race ./deploy/test/... -run Deploy`.
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/deploy"
|
||||
)
|
||||
|
||||
// TestDeploy_Atomicity_FileIsAlwaysOldOrNew pins the load-bearing
|
||||
// POSIX-rename atomicity invariant. A reader hammering the
|
||||
// destination during 30 alternating writes either sees the OLD
|
||||
// bytes or the NEW bytes — never an intermediate state. Closes
|
||||
// the operator-facing question "is my cert deploy interruption-
|
||||
// safe?".
|
||||
func TestDeploy_Atomicity_FileIsAlwaysOldOrNew(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cert.pem")
|
||||
old := []byte(strings.Repeat("OLD-CERT-PEM-", 200))
|
||||
newer := []byte(strings.Repeat("NEW-CERT-PEM-", 200))
|
||||
if err := os.WriteFile(path, old, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var torn atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := string(b)
|
||||
if s != string(old) && s != string(newer) {
|
||||
torn.Store(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
writeBytes := old
|
||||
if i%2 == 0 {
|
||||
writeBytes = newer
|
||||
}
|
||||
if _, err := deploy.AtomicWriteFile(context.Background(), path, writeBytes, deploy.WriteOptions{
|
||||
SkipIdempotent: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("write %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
if torn.Load() {
|
||||
t.Error("torn read observed (rename atomicity broken)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploy_PostVerify_WrongCertTriggersRollback simulates a
|
||||
// mis-deployed cert: the deploy.Apply succeeds at the file-write
|
||||
// + reload level, but the connector's post-deploy verify (run
|
||||
// AFTER Apply returns) detects the SHA-256 mismatch and rolls
|
||||
// back manually using the BackupPaths that Apply returned. The
|
||||
// final on-disk state matches the OLD bytes; the rollback wire
|
||||
// works end-to-end.
|
||||
func TestDeploy_PostVerify_WrongCertTriggersRollback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cert := filepath.Join(dir, "cert.pem")
|
||||
if err := os.WriteFile(cert, []byte("OLD-CERT"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan := deploy.Plan{
|
||||
Files: []deploy.File{{Path: cert, Bytes: []byte("WRONG-CERT")}},
|
||||
PostCommit: func(_ context.Context) error {
|
||||
// Reload would normally verify the cert via the post-deploy
|
||||
// TLS handshake. Here we simulate the verify failure by
|
||||
// returning an error from PostCommit (which triggers the
|
||||
// deploy package's automatic rollback).
|
||||
//
|
||||
// On the first call (the real deploy), return an error so
|
||||
// the rollback fires; on the second call (the rollback's
|
||||
// re-PostCommit against the restored bytes), succeed so
|
||||
// rollback completes cleanly.
|
||||
return errors.New("post-deploy verify: SHA-256 mismatch")
|
||||
},
|
||||
}
|
||||
|
||||
// First call to PostCommit fails; the rollback's second call
|
||||
// would also fail with the same handler — so we use a stateful
|
||||
// counter.
|
||||
var postCalls int32
|
||||
plan.PostCommit = func(_ context.Context) error {
|
||||
if atomic.AddInt32(&postCalls, 1) == 1 {
|
||||
return errors.New("post-deploy verify: SHA-256 mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := deploy.Apply(context.Background(), plan)
|
||||
if !errors.Is(err, deploy.ErrReloadFailed) {
|
||||
t.Fatalf("got %v, want ErrReloadFailed", err)
|
||||
}
|
||||
got, _ := os.ReadFile(cert)
|
||||
if string(got) != "OLD-CERT" {
|
||||
t.Errorf("cert after rollback = %q, want OLD-CERT", got)
|
||||
}
|
||||
if atomic.LoadInt32(&postCalls) != 2 {
|
||||
t.Errorf("PostCommit calls = %d, want 2 (1 deploy + 1 rollback re-call)", postCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploy_Idempotency_SecondDeployIsNoOp pins the SHA-256
|
||||
// short-circuit. Defends against agent-restart retry storms that
|
||||
// otherwise hammer targets with no-op reloads.
|
||||
func TestDeploy_Idempotency_SecondDeployIsNoOp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cert := filepath.Join(dir, "cert.pem")
|
||||
bytes := []byte("STABLE-CERT-PEM")
|
||||
if err := os.WriteFile(cert, bytes, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var preCalls, postCalls int32
|
||||
plan := deploy.Plan{
|
||||
Files: []deploy.File{{Path: cert, Bytes: bytes}},
|
||||
PreCommit: func(_ context.Context, _ map[string]string) error {
|
||||
atomic.AddInt32(&preCalls, 1)
|
||||
return nil
|
||||
},
|
||||
PostCommit: func(_ context.Context) error {
|
||||
atomic.AddInt32(&postCalls, 1)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
res, err := deploy.Apply(context.Background(), plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.SkippedAsIdempotent {
|
||||
t.Error("expected SkippedAsIdempotent=true")
|
||||
}
|
||||
if preCalls != 0 || postCalls != 0 {
|
||||
t.Errorf("expected 0 calls, got %d/%d", preCalls, postCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploy_Concurrent_SamePathsSerialize fires N simultaneous
|
||||
// deploys to the same destination. The deploy package's file-
|
||||
// level mutex must serialize them: max-in-flight = 1.
|
||||
func TestDeploy_Concurrent_SamePathsSerialize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cert := filepath.Join(dir, "cert.pem")
|
||||
|
||||
const N = 8
|
||||
var inFlight, maxInFlight int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
plan := deploy.Plan{
|
||||
Files: []deploy.File{{
|
||||
Path: cert,
|
||||
Bytes: []byte(fmt.Sprintf("WRITER-%d", idx)),
|
||||
}},
|
||||
SkipIdempotent: true,
|
||||
PostCommit: func(_ context.Context) error {
|
||||
n := atomic.AddInt32(&inFlight, 1)
|
||||
for {
|
||||
m := atomic.LoadInt32(&maxInFlight)
|
||||
if n <= m || atomic.CompareAndSwapInt32(&maxInFlight, m, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
atomic.AddInt32(&inFlight, -1)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if _, err := deploy.Apply(context.Background(), plan); err != nil {
|
||||
t.Errorf("Apply %d: %v", idx, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if maxInFlight > 1 {
|
||||
t.Errorf("max in-flight = %d, want 1 (mutex broken)", maxInFlight)
|
||||
}
|
||||
got, _ := os.ReadFile(cert)
|
||||
if !strings.HasPrefix(string(got), "WRITER-") {
|
||||
t.Errorf("file content not from any writer: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
protocols = imap
|
||||
listen = *
|
||||
ssl = required
|
||||
ssl_cert = </etc/dovecot/certs/cert.pem
|
||||
ssl_key = </etc/dovecot/certs/key.pem
|
||||
service imap-login {
|
||||
inet_listener imaps {
|
||||
port = 993
|
||||
ssl = yes
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
admin:
|
||||
address:
|
||||
socket_address:
|
||||
address: 0.0.0.0
|
||||
port_value: 9901
|
||||
static_resources:
|
||||
listeners:
|
||||
- name: https
|
||||
address:
|
||||
socket_address: { address: 0.0.0.0, port_value: 443 }
|
||||
filter_chains:
|
||||
- transport_socket:
|
||||
name: envoy.transport_sockets.tls
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
|
||||
common_tls_context:
|
||||
tls_certificates:
|
||||
- certificate_chain: { filename: /etc/envoy/certs/cert.pem }
|
||||
private_key: { filename: /etc/envoy/certs/key.pem }
|
||||
filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: ingress_http
|
||||
http_filters:
|
||||
- name: envoy.filters.http.router
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||
route_config:
|
||||
virtual_hosts:
|
||||
- name: backend
|
||||
domains: ["*"]
|
||||
routes:
|
||||
- match: { prefix: "/" }
|
||||
direct_response: { status: 200 }
|
||||
@@ -1,6 +0,0 @@
|
||||
# EST RFC 7030 hardening master bundle Phase 10.1.
|
||||
# This directory is the libest sidecar's working dir (bind-mounted as
|
||||
# /config/est). The integration test writes CSRs here + reads issued
|
||||
# certs back; this .gitkeep keeps the directory present in the repo
|
||||
# so a fresh `docker compose --profile est-e2e up` doesn't bind-mount
|
||||
# a missing path.
|
||||
@@ -1,354 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 10.2 — libest sidecar
|
||||
// integration tests. Five named tests exercise the live certctl
|
||||
// server's EST endpoints through Cisco's libest reference client
|
||||
// (estclient binary inside the certctl-test-libest sidecar container).
|
||||
//
|
||||
// Skip conditions:
|
||||
// - INTEGRATION env var not set (matches integration_test.go).
|
||||
// - The libest sidecar isn't running (the test detects this by
|
||||
// `docker inspect certctl-test-libest` and skips if absent).
|
||||
// - The EST endpoint isn't reachable from inside the network (the
|
||||
// test probes /.well-known/est/cacerts via estclient -g and
|
||||
// skips if the route returns 404).
|
||||
//
|
||||
// Operator workflow:
|
||||
//
|
||||
// cd deploy
|
||||
// docker compose -f docker-compose.test.yml --profile est-e2e build libest-client
|
||||
// docker compose -f docker-compose.test.yml --profile est-e2e up -d
|
||||
// cd test
|
||||
// INTEGRATION=1 go test -tags integration -v -run 'TestEST_LibESTClient' ./...
|
||||
//
|
||||
// CI runs this in the same job that already runs integration_test.go;
|
||||
// the docker-compose.test.yml libest-client entry + the Dockerfile
|
||||
// land in the same commit so a fresh `make integration-test-est`
|
||||
// (CI-side wrapper) works without operator intervention.
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// libestContainer is the docker-compose service name + container_name
|
||||
// the sidecar uses (deploy/docker-compose.test.yml::libest-client).
|
||||
const libestContainer = "certctl-test-libest"
|
||||
|
||||
// estServerHostInsideNetwork is the certctl-server hostname libest
|
||||
// resolves inside the certctl-test docker network. The sidecar's
|
||||
// /etc/hosts is auto-populated by docker-compose's bridge network so
|
||||
// `certctl-server` resolves to 10.30.50.6 (the static IP from the
|
||||
// compose file).
|
||||
const estServerHostInsideNetwork = "certctl-server"
|
||||
|
||||
// estPortInsideNetwork is the certctl HTTPS port inside the docker
|
||||
// network. NOT the host-mapped port (8443 → 8443 via compose); the
|
||||
// sidecar talks straight to the container.
|
||||
const estPortInsideNetwork = "8443"
|
||||
|
||||
// estCABundleInContainer is the bind-mounted certctl CA bundle the
|
||||
// libest sidecar pins TLS against. Path matches the volume mount in
|
||||
// docker-compose.test.yml::libest-client.
|
||||
const estCABundleInContainer = "/config/certs/ca.crt"
|
||||
|
||||
// dockerExec runs `docker exec <container> <args>` and returns
|
||||
// stdout + stderr + the run error. Used by every libest test below.
|
||||
// Centralised so a future docker-cli refactor (podman, kubectl exec)
|
||||
// only changes one place.
|
||||
func dockerExec(ctx context.Context, container string, args ...string) (string, string, error) {
|
||||
full := append([]string{"exec", container}, args...)
|
||||
cmd := exec.CommandContext(ctx, "docker", full...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
// libestSidecarReady checks that the libest sidecar container is
|
||||
// running. Returns the docker-inspect status string + a boolean for
|
||||
// "ready"; the boolean is what tests use to skip cleanly when the
|
||||
// operator forgot the --profile est-e2e flag.
|
||||
func libestSidecarReady(ctx context.Context) (string, bool) {
|
||||
cmd := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.Status}}", libestContainer)
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errBuf.String(), false
|
||||
}
|
||||
status := strings.TrimSpace(out.String())
|
||||
return status, status == "running"
|
||||
}
|
||||
|
||||
// runEstclient is the workhorse helper that drives `estclient` inside
|
||||
// the sidecar. Returns the raw stdout (typically the issued cert PEM
|
||||
// or the cacerts PKCS#7 base64 blob) + a useful error including
|
||||
// stderr on failure.
|
||||
//
|
||||
// The args are appended after a baseline {`estclient`, ...common
|
||||
// flags} shape that pins TLS against the certctl CA bundle + sets the
|
||||
// per-test-run output dir.
|
||||
func runEstclient(ctx context.Context, t *testing.T, extraArgs ...string) (string, error) {
|
||||
t.Helper()
|
||||
baseArgs := []string{
|
||||
"estclient",
|
||||
"-s", estServerHostInsideNetwork,
|
||||
"-p", estPortInsideNetwork,
|
||||
"-c", estCABundleInContainer,
|
||||
}
|
||||
args := append(baseArgs, extraArgs...)
|
||||
stdout, stderr, err := dockerExec(ctx, libestContainer, args...)
|
||||
if err != nil {
|
||||
return stdout, fmt.Errorf("estclient %v: %w (stderr=%q)", args, err, stderr)
|
||||
}
|
||||
return stdout, nil
|
||||
}
|
||||
|
||||
// requireESTSidecar is the per-test skip guard. If the libest sidecar
|
||||
// isn't running, every EST integration test skips with a message that
|
||||
// tells the operator the exact command to bring it up.
|
||||
func requireESTSidecar(t *testing.T) {
|
||||
t.Helper()
|
||||
if !integrationOptedIn() {
|
||||
t.Skip("integration tests require INTEGRATION=1; skipping libest e2e suite")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if status, ready := libestSidecarReady(ctx); !ready {
|
||||
t.Skipf("libest sidecar (container %q) not running (status=%q). Run `cd deploy && docker compose -f docker-compose.test.yml --profile est-e2e up -d libest-client` to bring it up.", libestContainer, status)
|
||||
}
|
||||
}
|
||||
|
||||
// integrationOptedIn mirrors integration_test.go's existing INTEGRATION
|
||||
// env-var convention. We can't import the helper from integration_test.go
|
||||
// because they're in the same package + the convention is just one
|
||||
// env-var read.
|
||||
func integrationOptedIn() bool {
|
||||
for _, v := range []string{"INTEGRATION", "RUN_INTEGRATION"} {
|
||||
if val := strings.TrimSpace(getenv(v)); val != "" && val != "0" && !strings.EqualFold(val, "false") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getenv is a tiny wrapper so we don't pull in os twice from this file
|
||||
// (integration_test.go has the canonical envOr that uses os.Getenv).
|
||||
// Kept self-contained so the est_e2e_test.go file is independently
|
||||
// readable.
|
||||
func getenv(k string) string {
|
||||
v := exec.Command("printenv", k)
|
||||
out, _ := v.Output()
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// TestEST_LibESTClient_Enrollment_Integration is the canonical
|
||||
// happy-path test. estclient does:
|
||||
//
|
||||
// 1. GET cacerts to retrieve the CA chain.
|
||||
// 2. POST simpleenroll with a freshly-generated CSR; receive the
|
||||
// issued cert chain back.
|
||||
// 3. Parse the issued cert + assert Subject CN matches what we asked.
|
||||
//
|
||||
// HTTP Basic auth is NOT used here — the test profile (CERTCTL_EST_PROFILE_E2E_*)
|
||||
// is configured without an enrollment password so the smoke test
|
||||
// exercises the simplest happy path.
|
||||
func TestEST_LibESTClient_Enrollment_Integration(t *testing.T) {
|
||||
requireESTSidecar(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Step 1 — get cacerts. estclient writes the PKCS#7 to /config/est/cacerts.p7.
|
||||
if _, err := runEstclient(ctx, t, "-g", "-o", "/config/est"); err != nil {
|
||||
t.Fatalf("get cacerts: %v", err)
|
||||
}
|
||||
|
||||
// Step 2 — generate a CSR + enroll. estclient -e mode generates
|
||||
// the keypair + the CSR + drives simpleenroll in one shot.
|
||||
if _, err := runEstclient(ctx, t, "-e", "--common-name", "device-e2e-001.example.com",
|
||||
"-o", "/config/est"); err != nil {
|
||||
t.Fatalf("simpleenroll: %v", err)
|
||||
}
|
||||
|
||||
// Step 3 — read the issued cert back via docker exec + parse.
|
||||
pemBytes, _, err := dockerExec(ctx, libestContainer, "cat", "/config/est/cert-0-0.pkcs7")
|
||||
if err != nil {
|
||||
t.Fatalf("read issued cert: %v", err)
|
||||
}
|
||||
if !strings.Contains(pemBytes, "BEGIN") && !strings.Contains(pemBytes, "MII") {
|
||||
t.Errorf("issued cert output didn't look like PEM/base64: first 80 bytes = %q", truncateHead(pemBytes, 80))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEST_LibESTClient_MTLSEnrollment_Integration drives the mTLS
|
||||
// sibling route /.well-known/est-mtls/<PathID>/simpleenroll. The
|
||||
// sidecar carries a bootstrap cert under /config/certs/bootstrap.pem
|
||||
// signed by the per-profile mTLS trust anchor; estclient presents
|
||||
// it via the -k/-c flags.
|
||||
//
|
||||
// Skip when the bootstrap cert isn't installed in the sidecar (the
|
||||
// operator has to run a one-time setup script to mint the cert
|
||||
// against the per-profile trust bundle's CA key — the integration
|
||||
// suite can't bootstrap that automatically without exposing the
|
||||
// trust anchor's private key, which we deliberately keep out of git).
|
||||
func TestEST_LibESTClient_MTLSEnrollment_Integration(t *testing.T) {
|
||||
requireESTSidecar(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Probe for the bootstrap cert. Skip if the operator hasn't
|
||||
// pre-provisioned one.
|
||||
if _, _, err := dockerExec(ctx, libestContainer, "test", "-f", "/config/certs/bootstrap.pem"); err != nil {
|
||||
t.Skip("/config/certs/bootstrap.pem not present in libest sidecar — skipping mTLS path. To enable: mint a bootstrap cert against the per-profile mTLS trust anchor and copy into deploy/test/certs/.")
|
||||
}
|
||||
|
||||
if _, err := runEstclient(ctx, t,
|
||||
"-e",
|
||||
"--pem-output",
|
||||
"-k", "/config/certs/bootstrap.key",
|
||||
"-c", "/config/certs/bootstrap.pem",
|
||||
"--common-name", "device-mtls-001.example.com",
|
||||
"-o", "/config/est",
|
||||
); err != nil {
|
||||
t.Fatalf("mTLS simpleenroll: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEST_LibESTClient_ServerKeygen_Integration drives RFC 7030
|
||||
// §4.4 server-keygen. estclient submits a CSR + receives the issued
|
||||
// cert + the encrypted private key (CMS EnvelopedData) in a multipart
|
||||
// response. The test asserts both parts arrive + the key part is
|
||||
// non-empty. Decrypting the key requires the CSR-side private key
|
||||
// (which estclient holds) — left as a smoke check rather than a full
|
||||
// round-trip because libest's --serverkeygen flag does the decrypt
|
||||
// internally before writing the key to disk.
|
||||
func TestEST_LibESTClient_ServerKeygen_Integration(t *testing.T) {
|
||||
requireESTSidecar(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := runEstclient(ctx, t,
|
||||
"-e",
|
||||
"--serverkeygen",
|
||||
"--common-name", "device-keygen-001.example.com",
|
||||
"-o", "/config/est",
|
||||
); err != nil {
|
||||
// Some libest builds report a non-zero exit when the server
|
||||
// returns a profile-disabled 404; map that to a Skip so the
|
||||
// suite stays green when the e2e profile hasn't enabled
|
||||
// SERVER_KEYGEN. The error message contains "404" in either case.
|
||||
if strings.Contains(err.Error(), "404") {
|
||||
t.Skip("server-keygen disabled on the e2e EST profile (HTTP 404). Enable via CERTCTL_EST_PROFILE_E2E_SERVER_KEYGEN_ENABLED=true in docker-compose.test.yml.")
|
||||
}
|
||||
t.Fatalf("serverkeygen: %v", err)
|
||||
}
|
||||
|
||||
// Assert the key part was written. estclient writes the private
|
||||
// key to a deterministic filename when --serverkeygen is set;
|
||||
// exact name depends on libest version, so we glob.
|
||||
stdout, _, err := dockerExec(ctx, libestContainer, "sh", "-c",
|
||||
"ls /config/est/ | grep -E '\\.(key|pkey|p8)$' | head -1")
|
||||
if err != nil || strings.TrimSpace(stdout) == "" {
|
||||
t.Errorf("server-keygen response did not write a key file: stdout=%q err=%v", stdout, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEST_LibESTClient_RateLimited_Integration drives N+1 enrollments
|
||||
// from the same (CN, source-IP) pair to trip the per-principal
|
||||
// sliding-window rate limiter. The 4th enrollment (default cap=3
|
||||
// matches Intune's PerDeviceRateLimiter default) MUST fail with a
|
||||
// 429 response.
|
||||
//
|
||||
// The test relies on the e2e profile being configured with
|
||||
// RATE_LIMIT_PER_PRINCIPAL_24H=3 so the cap is testable in a
|
||||
// reasonable test window.
|
||||
func TestEST_LibESTClient_RateLimited_Integration(t *testing.T) {
|
||||
requireESTSidecar(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
commonName := "device-ratelimit-001.example.com"
|
||||
allowed := 3
|
||||
for i := 1; i <= allowed; i++ {
|
||||
if _, err := runEstclient(ctx, t,
|
||||
"-e",
|
||||
"--common-name", commonName,
|
||||
"-o", "/config/est",
|
||||
); err != nil {
|
||||
t.Fatalf("enroll #%d should have succeeded: %v", i, err)
|
||||
}
|
||||
}
|
||||
// (allowed+1)-th attempt MUST be rate-limited.
|
||||
out, err := runEstclient(ctx, t,
|
||||
"-e",
|
||||
"--common-name", commonName,
|
||||
"-o", "/config/est",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("enroll #%d should have been rate-limited, but succeeded: %q", allowed+1, out)
|
||||
}
|
||||
// estclient surfaces the HTTP status in stderr; the test wrapper
|
||||
// captures both streams in the err message.
|
||||
if !strings.Contains(err.Error(), "429") && !strings.Contains(err.Error(), "Too Many") {
|
||||
t.Errorf("enroll #%d failed but not with a 429-shaped error: %v", allowed+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEST_LibESTClient_ChannelBinding_Integration drives the RFC 9266
|
||||
// tls-exporter binding path. libest's --tls-exporter flag (3.2.0+)
|
||||
// computes the binding client-side + embeds it as the
|
||||
// id-aa-est-tls-exporter CMC unsignedAttribute on the CSR.
|
||||
//
|
||||
// On the server side we expect the channel-binding gate to pass for
|
||||
// the matching binding + reject when we forge a wrong binding (libest
|
||||
// has no explicit "wrong binding" knob — the test exercises only the
|
||||
// passing path, and the rejection path is covered by the unit test
|
||||
// suite at internal/cms/channelbinding_test.go).
|
||||
func TestEST_LibESTClient_ChannelBinding_Integration(t *testing.T) {
|
||||
requireESTSidecar(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := runEstclient(ctx, t,
|
||||
"-e",
|
||||
"--tls-exporter",
|
||||
"--common-name", "device-binding-001.example.com",
|
||||
"-o", "/config/est",
|
||||
); err != nil {
|
||||
// Libest builds without RFC 9266 support exit non-zero with
|
||||
// "unknown option --tls-exporter". Surface as Skip so the
|
||||
// suite stays informative on libest variants that lack it.
|
||||
if strings.Contains(err.Error(), "unknown option") || strings.Contains(err.Error(), "invalid option") {
|
||||
t.Skipf("libest build lacks --tls-exporter support: %v", err)
|
||||
}
|
||||
t.Fatalf("channel-binding enroll: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateHead returns the first n runes of s (or all of s if it's
|
||||
// shorter), used to keep error messages from dumping multi-MB cert
|
||||
// blobs into the test log.
|
||||
func truncateHead(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
|
||||
// silenceUnused keeps imports live across libest builds that may
|
||||
// trigger a different code path. pem + x509 are both referenced by
|
||||
// the cert-parsing branch of the Enrollment_Integration test in
|
||||
// future expansions.
|
||||
var _ = pem.Decode
|
||||
var _ = x509.ParseCertificate
|
||||
@@ -1,21 +0,0 @@
|
||||
# f5-mock-icontrol sidecar: in-tree Go server implementing the
|
||||
# subset of F5 iControl REST that the certctl F5 connector exercises.
|
||||
# Used by the deploy-hardening II Phase 10 vendor-edge tests as a
|
||||
# CI-friendly alternative to a real F5 BIG-IP appliance.
|
||||
#
|
||||
# Per H-001 guard: every FROM is digest-pinned. Operator re-pins
|
||||
# quarterly per docs/deployment-vendor-matrix.md.
|
||||
|
||||
# golang:1.25.10-bookworm digest pinned per H-001.
|
||||
FROM golang:1.25.10-bookworm@sha256:e3a54b77385b4f8a31c1db4d12429ffb3718ea76865731a787c497755d409547 AS builder
|
||||
WORKDIR /src
|
||||
COPY deploy/test/f5-mock-icontrol/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o /out/f5-mock-icontrol .
|
||||
|
||||
# debian:bookworm-slim digest pinned per H-001 (matches libest sidecar).
|
||||
FROM debian:bookworm-slim@sha256:5a2a80d11944804c01b8619bc967e31801ec39bf3257ab80b91070eb23625644
|
||||
RUN useradd --create-home --shell /bin/bash mockf5
|
||||
COPY --from=builder /out/f5-mock-icontrol /usr/local/bin/f5-mock-icontrol
|
||||
USER mockf5
|
||||
EXPOSE 443 8080
|
||||
ENTRYPOINT ["/usr/local/bin/f5-mock-icontrol"]
|
||||
@@ -1,3 +0,0 @@
|
||||
module github.com/certctl-io/certctl/deploy/test/f5-mock-icontrol
|
||||
|
||||
go 1.25.10
|
||||
@@ -1,320 +0,0 @@
|
||||
// Package main implements the f5-mock-icontrol sidecar — an in-tree
|
||||
// Go server that implements the subset of F5's iControl REST API
|
||||
// the certctl F5 connector exercises. Used by the deploy-hardening
|
||||
// II Phase 10 vendor-edge tests as a CI-friendly alternative to a
|
||||
// real F5 BIG-IP appliance.
|
||||
//
|
||||
// Per frozen decision 0.3 (deploy-hardening II): the operator-supplied
|
||||
// real F5 vagrant box documented in docs/connector-f5.md is the
|
||||
// validation tier above the mock. CI runs against this mock; paying-
|
||||
// customer validation runs against the real F5.
|
||||
//
|
||||
// Implements:
|
||||
// - POST /mgmt/shared/authn/login (token-based auth)
|
||||
// - POST /mgmt/shared/file-transfer/uploads/<filename> (multi-chunk)
|
||||
// - POST /mgmt/tm/sys/crypto/cert (install cert)
|
||||
// - POST /mgmt/tm/sys/crypto/key (install key)
|
||||
// - POST /mgmt/tm/transaction (create txn)
|
||||
// - POST /mgmt/tm/transaction/<txn-id> (commit txn)
|
||||
// - PATCH /mgmt/tm/ltm/profile/client-ssl/<name> (update SSL profile)
|
||||
// - GET /mgmt/tm/ltm/profile/client-ssl/<name> (read SSL profile)
|
||||
// - DELETE /mgmt/tm/sys/crypto/cert/<name> (remove cert)
|
||||
// - DELETE /mgmt/tm/sys/crypto/key/<name> (remove key)
|
||||
//
|
||||
// State: in-memory map per running process. Lost on container restart.
|
||||
// CI tests handle restarts by re-running the test (Authenticate +
|
||||
// install + transaction sequence is idempotent against a fresh state).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// state is the mock server's in-memory view of an F5 BIG-IP.
|
||||
type state struct {
|
||||
mu sync.RWMutex
|
||||
// uploads holds raw uploaded bytes keyed by filename.
|
||||
uploads map[string][]byte
|
||||
// certs holds installed cert metadata keyed by name.
|
||||
certs map[string]map[string]any
|
||||
// keys holds installed key metadata keyed by name.
|
||||
keys map[string]map[string]any
|
||||
// profiles holds client-ssl profile state keyed by full path
|
||||
// (partition + name, e.g., "~Common~my-ssl-profile").
|
||||
profiles map[string]map[string]any
|
||||
// transactions holds open transactions keyed by ID.
|
||||
transactions map[string][]map[string]any
|
||||
// txnCounter mints fresh transaction IDs.
|
||||
txnCounter atomic.Uint64
|
||||
// authToken is the singleton bearer token issued at /authn/login.
|
||||
// Real F5 issues per-session tokens; the mock issues one + accepts
|
||||
// it forever (sufficient for CI test harness).
|
||||
authToken string
|
||||
}
|
||||
|
||||
func newState() *state {
|
||||
return &state{
|
||||
uploads: make(map[string][]byte),
|
||||
certs: make(map[string]map[string]any),
|
||||
keys: make(map[string]map[string]any),
|
||||
profiles: make(map[string]map[string]any),
|
||||
transactions: make(map[string][]map[string]any),
|
||||
authToken: "mock-bearer-token-do-not-use-in-prod",
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
s := newState()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/mgmt/shared/authn/login", s.handleLogin)
|
||||
mux.HandleFunc("/mgmt/shared/file-transfer/uploads/", s.handleUpload)
|
||||
mux.HandleFunc("/mgmt/tm/sys/crypto/cert", s.handleInstallCert)
|
||||
mux.HandleFunc("/mgmt/tm/sys/crypto/cert/", s.handleDeleteCert)
|
||||
mux.HandleFunc("/mgmt/tm/sys/crypto/key", s.handleInstallKey)
|
||||
mux.HandleFunc("/mgmt/tm/sys/crypto/key/", s.handleDeleteKey)
|
||||
mux.HandleFunc("/mgmt/tm/transaction", s.handleCreateTxn)
|
||||
mux.HandleFunc("/mgmt/tm/transaction/", s.handleCommitTxn)
|
||||
mux.HandleFunc("/mgmt/tm/ltm/profile/client-ssl/", s.handleProfile)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
log.Println("f5-mock-icontrol listening on :443 (HTTPS) and :8080 (HTTP)")
|
||||
go func() {
|
||||
if err := http.ListenAndServe(":8080", mux); err != nil {
|
||||
log.Fatalf("HTTP listen: %v", err)
|
||||
}
|
||||
}()
|
||||
// HTTPS uses a self-signed cert generated at startup. Real F5 has a
|
||||
// system cert; we keep the mock simple by using a self-signed pair.
|
||||
cert, key := selfSignedCert()
|
||||
srv := &http.Server{Addr: ":443", Handler: mux}
|
||||
if err := writeAndServeTLS(srv, cert, key); err != nil {
|
||||
log.Fatalf("HTTPS listen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *state) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Real F5 validates username + password against TACACS+ / RADIUS /
|
||||
// local user table. Mock accepts any non-empty credentials.
|
||||
user, _ := req["username"].(string)
|
||||
pass, _ := req["password"].(string)
|
||||
if user == "" || pass == "" {
|
||||
http.Error(w, "missing credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"token": map[string]any{
|
||||
"token": s.authToken,
|
||||
"name": user,
|
||||
"timeout": 3600,
|
||||
"expirationMicros": 9999999999,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *state) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
filename := strings.TrimPrefix(r.URL.Path, "/mgmt/shared/file-transfer/uploads/")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("read body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.uploads[filename] = append(s.uploads[filename], body...)
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"localFilePath": "/var/config/rest/downloads/" + filename})
|
||||
}
|
||||
|
||||
func (s *state) handleInstallCert(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name, _ := req["name"].(string)
|
||||
if name == "" {
|
||||
http.Error(w, "missing name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.certs[name] = req
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(req)
|
||||
}
|
||||
|
||||
func (s *state) handleInstallKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name, _ := req["name"].(string)
|
||||
if name == "" {
|
||||
http.Error(w, "missing name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.keys[name] = req
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(req)
|
||||
}
|
||||
|
||||
func (s *state) handleCreateTxn(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := fmt.Sprintf("txn-%d", s.txnCounter.Add(1))
|
||||
s.mu.Lock()
|
||||
s.transactions[id] = []map[string]any{}
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"transId": id, "state": "STARTED"})
|
||||
}
|
||||
|
||||
func (s *state) handleCommitTxn(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/transaction/")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.transactions[id]; !ok {
|
||||
http.Error(w, "transaction not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(s.transactions, id)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"transId": id, "state": "COMPLETED"})
|
||||
}
|
||||
|
||||
func (s *state) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/ltm/profile/client-ssl/")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.mu.RLock()
|
||||
p, ok := s.profiles[name]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
// Return an empty default profile (mock convenience).
|
||||
p = map[string]any{"name": name, "cert": "", "key": "", "chain": ""}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(p)
|
||||
case http.MethodPatch, http.MethodPut:
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.profiles[name]; ok {
|
||||
for k, v := range req {
|
||||
existing[k] = v
|
||||
}
|
||||
} else {
|
||||
req["name"] = name
|
||||
s.profiles[name] = req
|
||||
}
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(s.profiles[name])
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *state) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/sys/crypto/cert/")
|
||||
s.mu.Lock()
|
||||
delete(s.certs, name)
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *state) handleDeleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/sys/crypto/key/")
|
||||
s.mu.Lock()
|
||||
delete(s.keys, name)
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *state) authOK(r *http.Request) bool {
|
||||
tok := r.Header.Get("X-F5-Auth-Token")
|
||||
if tok == "" {
|
||||
// Fall back to bearer
|
||||
bearer := r.Header.Get("Authorization")
|
||||
tok = strings.TrimPrefix(bearer, "Bearer ")
|
||||
}
|
||||
return tok == s.authToken
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// selfSignedCert generates a fresh ECDSA P-256 self-signed cert+key
|
||||
// at startup. Real F5 ships with a system cert; the mock keeps it
|
||||
// simple with a per-process self-signed pair (CI tests pin against
|
||||
// an InsecureSkipVerify TLS dial).
|
||||
func selfSignedCert() ([]byte, []byte) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "f5-mock-icontrol"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"f5-mock-icontrol", "localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
// writeAndServeTLS loads the in-memory cert+key into the server
|
||||
// without touching disk.
|
||||
func writeAndServeTLS(srv *http.Server, certPEM, keyPEM []byte) error {
|
||||
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{pair},
|
||||
}
|
||||
return srv.ListenAndServeTLS("", "")
|
||||
}
|
||||
Vendored
-42
@@ -1,42 +0,0 @@
|
||||
# deploy/test/fixtures — integration-test material
|
||||
|
||||
This folder holds the fixture material that
|
||||
`deploy/docker-compose.test.yml` mounts into the certctl container's
|
||||
`/etc/certctl/scep/` for the SCEP-RFC-8894 + Intune integration test
|
||||
suite. Test-only material; **do not use in production**.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Generated by | Purpose |
|
||||
| ---- | ------------ | ------- |
|
||||
| `intune_trust_anchor.pem` | `deploy/test/scep_intune_e2e_test.go::generateE2EIntuneTrustAnchor` (deterministic ECDSA-P256 from `e2eintuneSeed`) | Mounted at `CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH`. The matching private key is re-derived inside the integration test from the same deterministic seed, so the test can mint valid Intune challenges that the running container accepts. |
|
||||
| `ra.crt` + `ra.key` | `setup-trust.sh` at compose boot OR generated once and committed | RA cert + private key the SCEP server uses to decrypt EnvelopedData per RFC 8894 §3.2.2. Mode 0600 enforced on `ra.key` by `preflightSCEPRACertKey`. |
|
||||
|
||||
## Regeneration
|
||||
|
||||
```sh
|
||||
# Trust anchor (deterministic — re-run produces byte-identical PEM):
|
||||
cd certctl && go test -tags integration \
|
||||
-run='^TestRegenerateE2EIntuneFixture$' -update-fixture \
|
||||
./deploy/test/...
|
||||
|
||||
# RA pair (one-off — committed):
|
||||
openssl ecparam -genkey -name prime256v1 -noout \
|
||||
-out deploy/test/fixtures/ra.key && chmod 600 deploy/test/fixtures/ra.key
|
||||
openssl req -new -x509 -key deploy/test/fixtures/ra.key \
|
||||
-days 3650 -subj '/CN=certctl-test-ra' \
|
||||
-out deploy/test/fixtures/ra.crt
|
||||
```
|
||||
|
||||
## Why these are committed (test-only material)
|
||||
|
||||
The integration test runs against the running container and needs to
|
||||
mint Intune challenges that the container's trust anchor pool
|
||||
recognizes. The deterministic-key approach gives us:
|
||||
|
||||
- A static PEM the operator can grep + inspect.
|
||||
- A test-side private key derived in-process so we don't commit a
|
||||
raw private key file.
|
||||
|
||||
Real production deploys MUST NOT use this trust anchor — the matching
|
||||
private key is in the certctl source tree and effectively public.
|
||||
@@ -1,15 +0,0 @@
|
||||
global
|
||||
log stdout local0 info
|
||||
|
||||
defaults
|
||||
mode http
|
||||
timeout client 30s
|
||||
timeout server 30s
|
||||
timeout connect 5s
|
||||
|
||||
frontend https-in
|
||||
bind *:443 ssl crt /etc/haproxy/certs/cert.pem
|
||||
default_backend null-backend
|
||||
|
||||
backend null-backend
|
||||
server null 127.0.0.1:1 disabled
|
||||
@@ -1,233 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
// Package integration_test — image-level HEALTHCHECK contract.
|
||||
//
|
||||
// U-2 (P1, cat-u-healthcheck_protocol_mismatch): pre-U-2 the published
|
||||
// server image's Dockerfile HEALTHCHECK called `curl -f http://localhost:
|
||||
// 8443/health` against an HTTPS-only listener (HTTPS-Everywhere milestone,
|
||||
// v2.2 / tag v2.0.47). Operators outside docker-compose / Helm saw the
|
||||
// container reported as `unhealthy` indefinitely. The compose stack
|
||||
// overrode this HEALTHCHECK with `--cacert + https://`; the Helm chart
|
||||
// uses explicit `httpGet` probes that ignore Docker's HEALTHCHECK; the 5
|
||||
// example compose files all override with `curl -sfk https://localhost:
|
||||
// 8443/health`. So the observable failure was scoped to bare `docker run`
|
||||
// / Docker Swarm / Nomad / ECS users — exactly the "I just pulled the
|
||||
// published image" path.
|
||||
//
|
||||
// This file's tests pin the contract at the binary-image level. The
|
||||
// matching CI grep guardrail in .github/workflows/ci.yml catches the
|
||||
// regression at the Dockerfile-source level; both layers are needed
|
||||
// because someone could replace the HEALTHCHECK line with a sibling
|
||||
// broken pattern that the grep doesn't catch (e.g., a TCP-only check
|
||||
// against the HTTPS port).
|
||||
//
|
||||
// Run alongside the rest of the integration suite:
|
||||
//
|
||||
// cd deploy/test && go test -tags integration -v -run Healthcheck
|
||||
//
|
||||
// The tests skip cleanly with t.Skip when docker is not available
|
||||
// (CI without docker-in-docker, sandbox environments, etc.) so they
|
||||
// don't block local development on machines without docker.
|
||||
//
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): this file's 5 t.Skip sites are
|
||||
// audited and intentional:
|
||||
//
|
||||
// - Line 85, 146, 207: `if !dockerAvailable(t)` skips when `docker info`
|
||||
// fails. These are precondition gates; without docker there's nothing
|
||||
// to assert against. Run via: `docker info >/dev/null && go test
|
||||
// -tags integration ./deploy/test/...`.
|
||||
// - Line 209-210: `if testing.Short()` keeps the ~45s runtime probe
|
||||
// off the default `go test ./... -short` path. Run via: omit -short.
|
||||
// - Line 212: hard t.Skip for the runtime probe contract — image-spec
|
||||
// contract above (TestPublishedServerImage_HealthcheckSpecUsesHTTPS)
|
||||
// covers the audit-flagged regression at the Dockerfile-source level.
|
||||
// Re-enable once the integration harness provisions a sidecar postgres
|
||||
// for image-level smoke; the existing skip message names this
|
||||
// remediation explicitly. Tracked via the in-source TODO (intentional,
|
||||
// not abandoned).
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dockerAvailable returns true when `docker version` returns 0.
|
||||
// We cache it across tests in this file so the skip message prints once.
|
||||
func dockerAvailable(t *testing.T) bool {
|
||||
t.Helper()
|
||||
cmd := exec.Command("docker", "version", "--format", "{{.Server.Version}}")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Logf("docker not available: %v\noutput: %s", err, string(out))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dockerCmd runs `docker <args...>` with a 60s budget, returning stdout
|
||||
// + stderr combined and the exit error if any. Used for short-lived
|
||||
// probes (inspect, build, run -d).
|
||||
func dockerCmd(t *testing.T, timeout time.Duration, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("docker", args...)
|
||||
done := make(chan struct{})
|
||||
var out []byte
|
||||
var err error
|
||||
go func() {
|
||||
out, err = cmd.CombinedOutput()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
return string(out), err
|
||||
case <-time.After(timeout):
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatalf("docker %v timed out after %v", args, timeout)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishedServerImage_HealthcheckSpecUsesHTTPS performs the Dockerfile-
|
||||
// source-level shipped-shape pin: the inspected image's Healthcheck.Test
|
||||
// array MUST contain "https://localhost:8443/health" (and MUST NOT
|
||||
// contain "http://localhost:8443/health"). This is the lightweight half
|
||||
// of the contract — it doesn't require running the container, only
|
||||
// building it. It catches the audit-flagged bug directly.
|
||||
func TestPublishedServerImage_HealthcheckSpecUsesHTTPS(t *testing.T) {
|
||||
if !dockerAvailable(t) {
|
||||
t.Skip("docker not available — skipping image-level HEALTHCHECK test")
|
||||
}
|
||||
|
||||
const imgTag = "certctl-u2-healthcheck-spec-test"
|
||||
t.Cleanup(func() {
|
||||
_, _ = dockerCmd(t, 30*time.Second, "rmi", "-f", imgTag)
|
||||
})
|
||||
|
||||
// Build the server image. Use the repo root as context (this test
|
||||
// file lives at deploy/test/, the Dockerfile at the repo root).
|
||||
buildOut, err := dockerCmd(t, 5*time.Minute,
|
||||
"build", "-f", "../../Dockerfile", "-t", imgTag, "../..")
|
||||
if err != nil {
|
||||
t.Fatalf("docker build failed: %v\noutput:\n%s", err, buildOut)
|
||||
}
|
||||
|
||||
// Inspect the shipped HEALTHCHECK metadata.
|
||||
inspectOut, err := dockerCmd(t, 30*time.Second,
|
||||
"inspect", "--format", "{{json .Config.Healthcheck}}", imgTag)
|
||||
if err != nil {
|
||||
t.Fatalf("docker inspect failed: %v\noutput:\n%s", err, inspectOut)
|
||||
}
|
||||
|
||||
var hc struct {
|
||||
Test []string
|
||||
Interval int64
|
||||
Timeout int64
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(inspectOut)), &hc); err != nil {
|
||||
t.Fatalf("could not parse Healthcheck JSON %q: %v", inspectOut, err)
|
||||
}
|
||||
|
||||
joined := strings.Join(hc.Test, " ")
|
||||
|
||||
// Positive contract.
|
||||
if !strings.Contains(joined, "https://localhost:8443/health") {
|
||||
t.Errorf("Healthcheck.Test does not target https://localhost:8443/health\nfull: %v", hc.Test)
|
||||
}
|
||||
|
||||
// Negative contract — pre-U-2 regression shape MUST be absent.
|
||||
if strings.Contains(joined, "http://localhost:8443/health") {
|
||||
t.Errorf("Healthcheck.Test still contains the pre-U-2 plaintext shape: %v", hc.Test)
|
||||
}
|
||||
|
||||
// `-k` (or `--insecure`) must be present because the bootstrap cert
|
||||
// is per-deploy and the published image can't pin a CA bundle —
|
||||
// see the U-2 closure docblock on Dockerfile and the audit doc.
|
||||
if !strings.Contains(joined, "-k") && !strings.Contains(joined, "--insecure") {
|
||||
t.Errorf("Healthcheck.Test omits -k / --insecure flag (required for self-signed bootstrap probe): %v", hc.Test)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishedAgentImage_HealthcheckSpecExists pins the U-2 adjacent
|
||||
// fix that added a HEALTHCHECK to the agent image. Pre-U-2 the agent
|
||||
// image had no HEALTHCHECK declaration, so bare-`docker run` agents got
|
||||
// `none` health status from Docker. Post-U-2 the agent uses pgrep to
|
||||
// verify the process is alive (mirroring the docker-compose pattern at
|
||||
// deploy/docker-compose.yml:173, which also became reliable post-U-2
|
||||
// because procps is now installed in the runtime image).
|
||||
func TestPublishedAgentImage_HealthcheckSpecExists(t *testing.T) {
|
||||
if !dockerAvailable(t) {
|
||||
t.Skip("docker not available — skipping image-level HEALTHCHECK test")
|
||||
}
|
||||
|
||||
const imgTag = "certctl-u2-agent-healthcheck-spec-test"
|
||||
t.Cleanup(func() {
|
||||
_, _ = dockerCmd(t, 30*time.Second, "rmi", "-f", imgTag)
|
||||
})
|
||||
|
||||
buildOut, err := dockerCmd(t, 5*time.Minute,
|
||||
"build", "-f", "../../Dockerfile.agent", "-t", imgTag, "../..")
|
||||
if err != nil {
|
||||
t.Fatalf("docker build failed: %v\noutput:\n%s", err, buildOut)
|
||||
}
|
||||
|
||||
inspectOut, err := dockerCmd(t, 30*time.Second,
|
||||
"inspect", "--format", "{{json .Config.Healthcheck}}", imgTag)
|
||||
if err != nil {
|
||||
t.Fatalf("docker inspect failed: %v\noutput:\n%s", err, inspectOut)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(inspectOut)
|
||||
if trimmed == "null" || trimmed == "" {
|
||||
t.Fatalf("agent image has no HEALTHCHECK (got %q) — U-2 adjacent fix regressed", inspectOut)
|
||||
}
|
||||
|
||||
var hc struct {
|
||||
Test []string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(trimmed), &hc); err != nil {
|
||||
t.Fatalf("could not parse Healthcheck JSON %q: %v", inspectOut, err)
|
||||
}
|
||||
|
||||
joined := strings.Join(hc.Test, " ")
|
||||
if !strings.Contains(joined, "pgrep") {
|
||||
t.Errorf("agent Healthcheck.Test does not use pgrep (lost the process-presence shape): %v", hc.Test)
|
||||
}
|
||||
if !strings.Contains(joined, "certctl-agent") {
|
||||
t.Errorf("agent Healthcheck.Test does not target the certctl-agent process name: %v", hc.Test)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishedServerImage_HealthcheckTransitionsToHealthy is the
|
||||
// runtime-level contract: the built image, when started, must transition
|
||||
// to `healthy` within the start-period + 30s observability budget. This
|
||||
// is the heavy test — it requires the server to actually start, which
|
||||
// in turn requires either a reachable database OR a startup that fails
|
||||
// gracefully enough to keep the HEALTHCHECK probe target alive.
|
||||
//
|
||||
// The container is started with CERTCTL_DATABASE_URL pointing at an
|
||||
// unreachable host so the server fails its postgres bring-up — but
|
||||
// importantly, fails AFTER the TLS listener has come up, because the
|
||||
// HEALTHCHECK probe target is the TLS listener. We don't actually need
|
||||
// the database to validate the HEALTHCHECK shape.
|
||||
//
|
||||
// IMPORTANT: this test is the runtime contract. If you're working on the
|
||||
// server's startup ordering and the listener now comes up AFTER the
|
||||
// database, this test must adapt — start a sidecar postgres via
|
||||
// testcontainers-go (see internal/integration/lifecycle_test.go for the
|
||||
// pattern) and connect the certctl-server container to it.
|
||||
func TestPublishedServerImage_HealthcheckTransitionsToHealthy(t *testing.T) {
|
||||
if !dockerAvailable(t) {
|
||||
t.Skip("docker not available — skipping runtime HEALTHCHECK test")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("runtime HEALTHCHECK test takes ~45s; skipping under -short")
|
||||
}
|
||||
t.Skip("runtime probe contract not yet wired to a sidecar postgres; " +
|
||||
"image-spec contract above (TestPublishedServerImage_HealthcheckSpecUsesHTTPS) " +
|
||||
"covers the audit-flagged regression. Re-enable once the integration " +
|
||||
"harness provisions postgres for image-level smoke.")
|
||||
}
|
||||
@@ -500,15 +500,6 @@ func TestIntegrationSuite(t *testing.T) {
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): this is a poll-with-skip, not a
|
||||
// silent skip. The loop above polls 30 times at 3s intervals (~90s
|
||||
// total) before falling through. If the agent never comes online in
|
||||
// 90s, the docker-compose stack is genuinely broken — the skip
|
||||
// surfaces that instead of failing in downstream Phase04+ tests
|
||||
// with confusing "agent not found" errors. The docker-compose
|
||||
// healthcheck has a 60s start_period, so 90s gives meaningful
|
||||
// headroom. Document-skip rather than fail because the upstream
|
||||
// CI may be running on slow hardware where cold start exceeds 90s.
|
||||
if !ok {
|
||||
t.Skip("agent not yet online (may be slow to heartbeat)")
|
||||
}
|
||||
@@ -795,12 +786,6 @@ func TestIntegrationSuite(t *testing.T) {
|
||||
// Phase 7: Revocation
|
||||
// -----------------------------------------------------------------------
|
||||
t.Run("Phase07_Revocation", func(t *testing.T) {
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): inter-test ordering — Phase07
|
||||
// revokes mc-local-test, which Phase04 creates. If Phase04's local
|
||||
// CA path errored out (issuer config invalid, ca cert/key missing,
|
||||
// etc.) localCertCreated stays false and there's no certificate
|
||||
// to revoke. Skipping is correct because Phase04 already reported
|
||||
// the upstream failure; failing here would just create noise.
|
||||
if !localCertCreated {
|
||||
t.Skip("depends on Phase04 (Local CA cert not created)")
|
||||
}
|
||||
@@ -888,15 +873,6 @@ func TestIntegrationSuite(t *testing.T) {
|
||||
if err := decodeJSON(resp, &pr); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): the discovery scan runs on a
|
||||
// scheduler tick, not synchronously with this test. If the test
|
||||
// runs before the first scan completes (cold-start docker-compose
|
||||
// race), pr.Total is 0 and there's no discovered cert to assert
|
||||
// against. Skipping is correct rather than failing because the
|
||||
// scheduler interval is configurable; a fast-iteration dev loop
|
||||
// shouldn't be blocked by a slow scheduler. The CertificateDiscovery
|
||||
// service has its own dedicated unit tests that exercise the scan
|
||||
// path directly without scheduler timing.
|
||||
if pr.Total < 1 {
|
||||
t.Skip("no discovered certificates yet (agent scan may not have run)")
|
||||
}
|
||||
@@ -931,13 +907,6 @@ func TestIntegrationSuite(t *testing.T) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): inter-test fallthrough —
|
||||
// Phase09 renews the first Active cert it finds among the candidate
|
||||
// list. If both step-ca and ACME paths errored out earlier (Pebble
|
||||
// not yet bootstrapped, step-ca init failed) neither candidate is
|
||||
// Active. Skipping is correct because the upstream phases already
|
||||
// surfaced the issuer-side failure; failing here would mask the
|
||||
// real root cause behind a Phase09 noise.
|
||||
if renewalCert == "" {
|
||||
t.Skip("no certificate in Active state for renewal test")
|
||||
}
|
||||
@@ -1118,13 +1087,6 @@ func TestIntegrationSuite(t *testing.T) {
|
||||
|
||||
lastVersion := versions[len(versions)-1]
|
||||
pemData := lastVersion.PEMChain
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): assertion fallback — the
|
||||
// version row exists but the PEM blob is empty. This shouldn't
|
||||
// happen in a healthy issuance pipeline (the issuer connector
|
||||
// always returns the PEM chain), so this is a defensive guard
|
||||
// against corrupted state. Skipping is preferable to failing
|
||||
// because the issuance failure is upstream of this assertion;
|
||||
// failing here would mask the real root cause.
|
||||
if pemData == "" {
|
||||
t.Skip("no PEM data in certificate version")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user