mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41:31 +00:00
Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a9a76147 | |||
| a54805c63c | |||
| 0e29c416b1 | |||
| 8a3086c4ae | |||
| d4c421b98d | |||
| 1bdab897ef | |||
| 94ca69554b | |||
| c4d231e728 | |||
| 1c6009a920 | |||
| a39f5af22a | |||
| 3e78ecb799 | |||
| 24f25353f8 | |||
| 25c34ace45 | |||
| 5e4eaa78b1 | |||
| 2419f8cd27 | |||
| 6f045293e9 | |||
| 530da674f8 | |||
| 555eef449e | |||
| 55eb7135be | |||
| 2edac7e78b | |||
| b8a4318082 | |||
| 097995e503 | |||
| 3fc1a2222f | |||
| f0865bb051 | |||
| 677524d9ec | |||
| 9dc0742e77 | |||
| 1440a30d28 | |||
| a3d8b9c607 | |||
| aa6fafdee9 | |||
| 86fffa305a | |||
| e17788355b | |||
| 87213128cc | |||
| 697fa792ea | |||
| 9c1d446e40 | |||
| 3192cd15c5 | |||
| af47d19ae2 | |||
| cfc234ec42 | |||
| a91197014f | |||
| d6959a75c1 | |||
| 97b23e98d9 | |||
| 4cf5fcdb4f | |||
| 1ee67b7792 | |||
| 128d0eeaa8 | |||
| 9834b4e4a4 | |||
| cab579368b | |||
| 4e5522a999 | |||
| 55ce86b132 | |||
| 52248be717 | |||
| 04c7eca615 | |||
| 6e646e0fe8 | |||
| 675b87ba63 | |||
| 707d8de4fb | |||
| 0725713e19 | |||
| 1ee77c89f8 | |||
| 4bc8b3e723 | |||
| 469611650c | |||
| 91642e2860 | |||
| 0200c7f4a4 | |||
| fe7e766510 | |||
| ff7357f889 | |||
| 3287e174dc | |||
| a53a4b845b | |||
| 9143da5fa8 | |||
| b3cc7cbdb2 | |||
| eef1db0f0a | |||
| 72f5246ce3 | |||
| cb308bb4c7 | |||
| ad93e99158 | |||
| 9d0c3dfa15 | |||
| 2c9602db71 | |||
| ef670fa6da | |||
| 5a6ec39cfd | |||
| e3196e7b50 | |||
| bea69efd12 | |||
| 283ec27ca4 | |||
| a67a6b6c30 | |||
| ccd89c348f | |||
| 478a141498 | |||
| 2497be496d | |||
| 25dd6c07f3 | |||
| eb14236166 | |||
| bbb628243f | |||
| cdc9d03d5b | |||
| e951d319d0 | |||
| d14a45401b | |||
| 655e2879e6 | |||
| e757ef1471 | |||
| 27afa4463d | |||
| 80450c7180 | |||
| c655e0f8c5 | |||
| 5abeeb882b | |||
| b1df6dab27 | |||
| 672e1d991d | |||
| 89b910a8f1 | |||
| 6315ef102a | |||
| 119986fa7e | |||
| 3853b7460c | |||
| e9947dc0fe | |||
| b813660c74 | |||
| 387fb555ac | |||
| f549a7aa79 | |||
| b219e5d68a | |||
| 1f6cf0eafa | |||
| a49eae8155 | |||
| 1c7d085f16 | |||
| cc6eec3608 | |||
| 86fb140414 | |||
| 13cd4d98ba | |||
| 84bc1245a1 | |||
| e1bcde4cf1 | |||
| 3f619bcaac | |||
| f3a85d6b08 | |||
| 596d86a206 | |||
| f2e60b93a3 | |||
| f16a9c767a | |||
| 3a27c87b3f | |||
| 0ed8676066 | |||
| bcefb11e65 | |||
| 75cf8475f5 | |||
| c015cab2f4 | |||
| 3da6584ab8 | |||
| 68f6fd474b | |||
| 614e4e636b | |||
| 370f856725 | |||
| 7382e5f03b | |||
| 5567d4b411 | |||
| e5516d7286 | |||
| fd94e0bd19 | |||
| d0415d3b5e | |||
| c6efa4ab39 | |||
| dedf7fa3a9 | |||
| 4b5927dfff | |||
| cc03f55006 | |||
| 93e1dc598c | |||
| 25f33b830f | |||
| 7d6ef44e21 | |||
| dfa4dbbcbd | |||
| f92c997a50 | |||
| 697c0be9f3 | |||
| 8f146e08d6 | |||
| e6088c79a3 | |||
| e19b8c95fe | |||
| 995b72df05 | |||
| 9954fd1100 | |||
| 2a14a1da01 | |||
| 5a53b648b1 | |||
| cb72292b83 | |||
| 3a11e447cf | |||
| bad02e6f23 | |||
| 4c3b7cbb16 | |||
| e8c64b47dd | |||
| 9feb6c796d | |||
| fd05bacb76 | |||
| f51571297d | |||
| 9a41d0ca39 | |||
| 8b52da6aef | |||
| adfb682754 | |||
| 0822f748a5 | |||
| 368ea681a5 | |||
| b059ec930f | |||
| 2238f28610 | |||
| bbba618beb | |||
| cfc4d3f3e8 | |||
| c06d23dd7a | |||
| 6c8d4eca40 | |||
| 836534f2a7 | |||
| 648e2f7ab1 | |||
| 6375909591 | |||
| 3e5ff4b9c3 | |||
| 76d0ce2a0f | |||
| 207f2c6879 | |||
| 46a58d518a | |||
| c5be6d059f | |||
| ec209c9736 | |||
| d4f02c5f4b | |||
| 2409f2e464 | |||
| 225c7141b8 | |||
| 8807a7303d | |||
| a6515b4323 | |||
| 11173a74c6 | |||
| ec0e7a3560 | |||
| a0b9285323 | |||
| 2655493ac8 | |||
| a8fc177118 | |||
| 20378ea7bb |
+25
-4
@@ -13,22 +13,43 @@ POSTGRES_PASSWORD=change-me-in-production
|
||||
# Certctl Server
|
||||
# All server vars use the CERTCTL_ prefix (see internal/config/config.go)
|
||||
# ==============================================================================
|
||||
CERTCTL_DATABASE_URL=postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
|
||||
# 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:change-me-in-production@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", "jwt", or "none" (for demo/development)
|
||||
# Auth type: "api-key" (production) or "none" (demo/development).
|
||||
# For JWT/OIDC, 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.
|
||||
CERTCTL_AUTH_TYPE=none
|
||||
# Required when CERTCTL_AUTH_TYPE is "api-key" or "jwt"
|
||||
# Required when CERTCTL_AUTH_TYPE is "api-key".
|
||||
# Generate with: openssl rand -base64 32
|
||||
# CERTCTL_AUTH_SECRET=change-me-in-production
|
||||
|
||||
# ==============================================================================
|
||||
# Certctl Agent
|
||||
# ==============================================================================
|
||||
CERTCTL_SERVER_URL=http://localhost:8443
|
||||
# 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
|
||||
CERTCTL_API_KEY=change-me-in-production
|
||||
CERTCTL_AGENT_NAME=local-agent
|
||||
|
||||
|
||||
+723
-6
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.25.9'
|
||||
|
||||
- name: Go Build
|
||||
run: |
|
||||
@@ -44,12 +44,522 @@ jobs:
|
||||
- name: Run govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
- name: Forbidden auth-type literal regression guard (G-1)
|
||||
# G-1 closed the JWT silent auth downgrade by removing "jwt" from the
|
||||
# accepted CERTCTL_AUTH_TYPE values. This step grep-fails the build
|
||||
# if "jwt" reappears in any of the *additive* auth-type surfaces:
|
||||
# the validAuthTypes / ValidAuthTypes() set, the OpenAPI enum, the
|
||||
# helm chart's allowed-types list, or the .env.example default.
|
||||
# Comment lines and the dedicated rejection branch in config.go
|
||||
# (`c.Auth.Type == "jwt"`) are intentionally exempt — those are the
|
||||
# G-1 fix itself, not a regression.
|
||||
#
|
||||
# Connector packages (internal/connector/) are exempt because the
|
||||
# Google OAuth2 service-account JWT and step-ca provisioner one-
|
||||
# time-token JWT are external-protocol uses, unrelated to certctl's
|
||||
# own auth shape. Test files (_test.go) are exempt so negative
|
||||
# tests can pass the literal.
|
||||
#
|
||||
# See docs/upgrade-to-v2-jwt-removal.md for the closure rationale,
|
||||
# or internal/config/config.go::ValidAuthTypes for the allowed set.
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Scoped patterns that indicate "jwt" being added back to an
|
||||
# allowed-set surface. Each catches a regression shape we've
|
||||
# actually seen in pre-G-1 code:
|
||||
# - Go map/slice literal: "jwt": true or "jwt",
|
||||
# - Go switch case: case "jwt"
|
||||
# - YAML enum: enum: [..., jwt, ...] or - jwt
|
||||
# - .env conditional: AUTH_TYPE.*"jwt"|=jwt$
|
||||
BAD=$(grep -rnEH \
|
||||
-e '"jwt"\s*:\s*true' \
|
||||
-e '"jwt"\s*,' \
|
||||
-e 'case\s+"jwt"' \
|
||||
-e 'enum:.*\bjwt\b' \
|
||||
-e '^\s*-\s*jwt\s*$' \
|
||||
-e 'AUTH_TYPE\s*=\s*jwt\s*$' \
|
||||
-e 'AUTH_TYPE\s*=\s*jwt\s*#' \
|
||||
-e 'auth\.type\s*=\s*jwt\s*$' \
|
||||
-e 'AuthType\("jwt"\)' \
|
||||
internal/config/ \
|
||||
internal/api/ \
|
||||
cmd/ \
|
||||
api/openapi.yaml \
|
||||
.env.example \
|
||||
deploy/.env.example \
|
||||
deploy/helm/certctl/values.yaml \
|
||||
deploy/helm/certctl/templates/ \
|
||||
2>/dev/null \
|
||||
| grep -v '_test.go' \
|
||||
| grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \
|
||||
| grep -v 'is no longer accepted' \
|
||||
|| true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "G-1 regression: \"jwt\" reappeared in an allowed-set surface:"
|
||||
echo "$BAD"
|
||||
echo ""
|
||||
echo "Allowed surface for 'jwt' literals: comment lines, the"
|
||||
echo "dedicated rejection branch in internal/config/config.go,"
|
||||
echo "and connector packages (Google OAuth2, step-ca)."
|
||||
echo "See docs/upgrade-to-v2-jwt-removal.md and"
|
||||
echo "internal/config/config.go::ValidAuthTypes()."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbidden api_key_hash JSON-shape regression guard (G-2)
|
||||
# G-2 closed cat-s5-apikey_leak by tagging Agent.APIKeyHash
|
||||
# `json:"-"` and adding a defense-in-depth Agent.MarshalJSON that
|
||||
# zeroes the field on the marshal-time copy. This step grep-fails
|
||||
# the build if `api_key_hash` reappears in any of the *additive*
|
||||
# JSON-emitting surfaces: a Go struct json tag in internal/domain/,
|
||||
# an OpenAPI Agent schema property, a TypeScript field declaration
|
||||
# in web/src/, or an enum-list / discriminator in handler
|
||||
# production code.
|
||||
#
|
||||
# Repository, migration, seed, service, integration-test, and
|
||||
# unit-test files are exempt — those are server-internal use
|
||||
# sites (the DB column stays, the in-memory struct field stays,
|
||||
# the auth-lookup path stays). Comment lines are exempt so the
|
||||
# G-2 closure rationale can stay in the source.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-s5-apikey_leak for the closure rationale, or
|
||||
# internal/domain/connector.go::Agent::MarshalJSON for the
|
||||
# redaction enforcement.
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Scoped patterns that indicate api_key_hash being added back
|
||||
# to a JSON-emitting surface. Each catches a regression shape
|
||||
# that pre-G-2 actually shipped or that a future refactor
|
||||
# could plausibly introduce:
|
||||
# - Go struct tag: `json:"api_key_hash"`
|
||||
# - Frontend interface: api_key_hash[?]: string
|
||||
# - OpenAPI schema property: api_key_hash: (column-aligned)
|
||||
# - YAML enum / array: - api_key_hash
|
||||
BAD=$(grep -rnEH \
|
||||
-e 'json:"api_key_hash[",]' \
|
||||
-e '^\s*api_key_hash\??\s*:' \
|
||||
-e '^\s*-\s*api_key_hash\s*$' \
|
||||
internal/domain/ \
|
||||
internal/api/ \
|
||||
cmd/ \
|
||||
api/openapi.yaml \
|
||||
web/src/ \
|
||||
2>/dev/null \
|
||||
| grep -v '_test.go' \
|
||||
| grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \
|
||||
|| true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "G-2 regression: api_key_hash reappeared in a JSON-emitting surface:"
|
||||
echo "$BAD"
|
||||
echo ""
|
||||
echo "Allowed surface for api_key_hash literals: comment lines,"
|
||||
echo "the database column (migrations/), the in-memory struct"
|
||||
echo "field tagged \`json:\"-\"\`, and the repository / service"
|
||||
echo "use sites. See internal/domain/connector.go::Agent and"
|
||||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||
echo "cat-s5-apikey_leak for the closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbidden plaintext HEALTHCHECK regression guard (U-2)
|
||||
# U-2 closed cat-u-healthcheck_protocol_mismatch by switching the
|
||||
# published image's HEALTHCHECK from `curl -f http://localhost:
|
||||
# 8443/health` (always failed against the HTTPS-only listener) to
|
||||
# `curl -fsk https://localhost:8443/health`. This step grep-fails
|
||||
# the build if any Dockerfile in the repo carries the pre-U-2
|
||||
# plaintext shape — either explicitly (`http://localhost:8443/
|
||||
# health` in a HEALTHCHECK) or via the looser pattern of any
|
||||
# HEALTHCHECK that targets `http://` against the certctl server
|
||||
# port.
|
||||
#
|
||||
# Comment lines and the docs/upgrade-to-tls.md:182 expected-to-
|
||||
# fail invariant ("plaintext is gone, expect Connection refused")
|
||||
# are intentionally exempt — we DO want the upgrade-doc string
|
||||
# `http://localhost:8443/health` to remain there, since it
|
||||
# documents what operators should test for to confirm plaintext
|
||||
# is dead. The guardrail is scoped to Dockerfile* only, so docs
|
||||
# are out of its reach.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-u-healthcheck_protocol_mismatch for the closure rationale,
|
||||
# or deploy/test/healthcheck_test.go for the binary-image
|
||||
# contract the runtime test pins.
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Patterns that catch the actual regression shapes:
|
||||
# - HEALTHCHECK directive carrying any http:// (even if the
|
||||
# port differs, no plaintext probe should ship).
|
||||
# - The exact pre-U-2 string for grep-friendliness.
|
||||
BAD=$(grep -rnEH \
|
||||
-e 'HEALTHCHECK.*http://' \
|
||||
-e 'curl[^|&;]*-f[^|&;]*http://localhost:8443/health' \
|
||||
Dockerfile Dockerfile.agent Dockerfile.* 2>/dev/null \
|
||||
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|
||||
|| true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "U-2 regression: plaintext HEALTHCHECK reappeared in a Dockerfile:"
|
||||
echo "$BAD"
|
||||
echo ""
|
||||
echo "Allowed: HTTPS HEALTHCHECK with -k (acceptable for"
|
||||
echo "localhost-to-localhost), or non-HTTP probe shapes"
|
||||
echo "(pgrep, /proc check). See Dockerfile / Dockerfile.agent"
|
||||
echo "for the post-U-2 reference shape and"
|
||||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||
echo "cat-u-healthcheck_protocol_mismatch for rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbidden migration mount in compose initdb (U-3)
|
||||
# U-3 closed cat-u-seed_initdb_schema_drift (GitHub #10) by
|
||||
# eliminating the dual-source-of-truth between
|
||||
# `migrations/*.up.sql` mounted into postgres
|
||||
# `/docker-entrypoint-initdb.d/` and the same files re-applied at
|
||||
# runtime by `RunMigrations`. Pre-U-3 every new migration that
|
||||
# the seed depended on (000013 added `policy_rules.severity`,
|
||||
# 000017 renames `retry_interval_seconds`, etc.) had to be added
|
||||
# by hand to the compose mount list; missing the update crashed
|
||||
# initdb on first boot, postgres flagged unhealthy, and the
|
||||
# whole stack failed to start from a fresh clone. Post-U-3 the
|
||||
# server is the single source of truth — `RunMigrations` +
|
||||
# `RunSeed` apply everything at boot.
|
||||
#
|
||||
# This step grep-fails the build if any compose file under
|
||||
# `deploy/` re-introduces a `migrations/.*\.sql` mount into
|
||||
# `/docker-entrypoint-initdb.d`. Comments are exempt so the
|
||||
# post-fix rationale block in the compose files (which
|
||||
# documents WHY the mounts were removed) doesn't trip the guard.
|
||||
# The demo overlay's `seed_demo.sql` is the explicit exception:
|
||||
# it is tolerated only when it lives behind the
|
||||
# CERTCTL_DEMO_SEED env var (post-U-3 demo path) — bare initdb
|
||||
# mounts are NOT tolerated. The grep matches all compose
|
||||
# mount-list shapes (`-` indented, `volumes:` indented, both),
|
||||
# so any future drift surfaces here before the operator hits it
|
||||
# on a fresh clone.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-u-seed_initdb_schema_drift for the closure rationale, or
|
||||
# internal/repository/postgres/db.go::RunSeed for the runtime
|
||||
# contract.
|
||||
run: |
|
||||
set -e
|
||||
|
||||
BAD=$(grep -rnEH \
|
||||
-e 'migrations/.*\.sql:.*docker-entrypoint-initdb' \
|
||||
-e 'seed.*\.sql:.*docker-entrypoint-initdb' \
|
||||
deploy/docker-compose.yml \
|
||||
deploy/docker-compose.test.yml \
|
||||
deploy/docker-compose.demo.yml \
|
||||
2>/dev/null \
|
||||
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|
||||
|| true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "U-3 regression: migration/seed mount into postgres initdb reappeared:"
|
||||
echo "$BAD"
|
||||
echo ""
|
||||
echo "The post-U-3 contract is: postgres comes up with an empty"
|
||||
echo "schema and the server applies migrations + seed at boot via"
|
||||
echo "internal/repository/postgres.RunMigrations + RunSeed. Demo"
|
||||
echo "data lives behind CERTCTL_DEMO_SEED=true (RunDemoSeed),"
|
||||
echo "not an initdb mount. See"
|
||||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||
echo "cat-u-seed_initdb_schema_drift for the closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)
|
||||
# D-1 master closed cat-d-359e92c20cbf (Agent: 'Stale' dead key,
|
||||
# 'Degraded' missing), cat-d-9f4c8e4a91f1 (Notification: 'dead'
|
||||
# missing), cat-d-1447e04732e7 (Cert: 'PendingIssuance' dead
|
||||
# key), cat-f-cert_detail_page_key_render_fallback (render-site
|
||||
# uses cert.X directly), and cat-f-ae0d06b6588f (Certificate
|
||||
# TS phantom fields). This step grep-fails the build if either
|
||||
# half of the closure is reverted:
|
||||
#
|
||||
# 1. The dead StatusBadge keys ('Stale' for Agent, 'PendingIssuance'
|
||||
# for Cert) reappearing as map literals, OR
|
||||
# 2. The five phantom Certificate TS fields (serial_number,
|
||||
# fingerprint_sha256, key_algorithm, key_size, issued_at)
|
||||
# reappearing on the `Certificate` interface in types.ts
|
||||
# (CertificateVersion legitimately carries them and is
|
||||
# explicitly excluded by the awk pre-filter below).
|
||||
#
|
||||
# Comments are exempt so the closure prose in StatusBadge.tsx +
|
||||
# types.ts can stay. Test files are exempt so negative tests
|
||||
# asserting the dead keys fall through to neutral keep working.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-d-* / cat-f-* for the closure rationale, or
|
||||
# web/src/components/StatusBadge.test.tsx for the live
|
||||
# enum-coverage contract.
|
||||
run: |
|
||||
set -e
|
||||
|
||||
BAD_BADGE=$(grep -nE "^\s*(Stale|PendingIssuance)\s*:\s*'badge-" \
|
||||
web/src/components/StatusBadge.tsx 2>/dev/null \
|
||||
| grep -v '\.test\.' \
|
||||
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||||
|| true)
|
||||
if [ -n "$BAD_BADGE" ]; then
|
||||
echo "D-1 regression: dead StatusBadge key reappeared:"
|
||||
echo "$BAD_BADGE"
|
||||
echo ""
|
||||
echo "Allowed surface: comment lines naming the removed key in"
|
||||
echo "the file's preamble. The Go-side AgentStatus values are"
|
||||
echo "Online/Offline/Degraded (no Stale); CertificateStatus values"
|
||||
echo "are Pending/Active/... (no PendingIssuance). See"
|
||||
echo "web/src/components/StatusBadge.test.tsx for the contract."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Certificate TS phantom-field check. Scoped to the
|
||||
# `export interface Certificate {` block in web/src/api/types.ts
|
||||
# — CertificateVersion legitimately declares these fields and
|
||||
# must NOT trip the guardrail. The awk window opens on the
|
||||
# exact `Certificate {` header (not `CertificateVersion {`,
|
||||
# not `CertificateProfile {`) and closes at the first `}`,
|
||||
# then the grep matches a phantom-field declaration anywhere
|
||||
# in that window.
|
||||
BAD_TS=$(awk '
|
||||
/^export interface Certificate \{/ { flag=1; next }
|
||||
flag && /^\}/ { flag=0 }
|
||||
flag { print FILENAME":"NR":"$0 }
|
||||
' web/src/api/types.ts \
|
||||
| grep -E '\b(serial_number|fingerprint_sha256|key_algorithm|key_size|issued_at)\??\s*:' \
|
||||
|| true)
|
||||
if [ -n "$BAD_TS" ]; then
|
||||
echo "D-1 regression: Certificate TS interface re-added a phantom field:"
|
||||
echo "$BAD_TS"
|
||||
echo ""
|
||||
echo "These fields live on CertificateVersion, not ManagedCertificate."
|
||||
echo "The Go-side ManagedCertificate has never carried them; the"
|
||||
echo "TS optional declarations were silently undefined on every"
|
||||
echo "list response. Render-site consumers (e.g. CertificateDetailPage)"
|
||||
echo "use latestVersion?.field as the canonical access path."
|
||||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||
echo "cat-f-ae0d06b6588f for the closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# D-2 master closed five diff-05x06-* type-drift findings:
|
||||
# Agent (5 phantoms), Issuer (1 phantom), Notification (1 phantom)
|
||||
# — TRIM half. The Target (2 missing fields) and DiscoveredCertificate
|
||||
# (1 missing field) — ADD half is pinned by the literal-construction
|
||||
# blocks in web/src/api/types.test.ts, not a CI grep. The phantom-
|
||||
# trim regression vector is an awk-windowed grep per interface
|
||||
# mirroring the D-1 Certificate check above.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# diff-05x06-7cdf4e78ae24 (Agent), diff-05x06-97fab8783a5c (Issuer),
|
||||
# diff-05x06-caba9eb3620e (Notification) for the closure rationale.
|
||||
|
||||
# D-2 Agent phantom-field check. The grep matches `last_heartbeat`
|
||||
# but NOT `last_heartbeat_at` (the legitimate Go-emitted field) —
|
||||
# the `\b...\b` boundaries plus the `grep -v 'last_heartbeat_at'`
|
||||
# filter handle that.
|
||||
BAD_AGENT=$(awk '
|
||||
/^export interface Agent \{/ { flag=1; next }
|
||||
flag && /^\}/ { flag=0 }
|
||||
flag { print FILENAME":"NR":"$0 }
|
||||
' web/src/api/types.ts \
|
||||
| grep -E '\b(last_heartbeat|capabilities|tags|created_at|updated_at)\??\s*:' \
|
||||
| grep -v 'last_heartbeat_at' \
|
||||
|| true)
|
||||
if [ -n "$BAD_AGENT" ]; then
|
||||
echo "D-2 regression: Agent TS interface re-added a phantom field:"
|
||||
echo "$BAD_AGENT"
|
||||
echo ""
|
||||
echo "The Go-side internal/domain/connector.go::Agent emits exactly:"
|
||||
echo "id, name, hostname, status, last_heartbeat_at?, registered_at,"
|
||||
echo "os, architecture, ip_address, version, retired_at?, retired_reason?."
|
||||
echo "The five fields blocked by this guard (last_heartbeat,"
|
||||
echo "capabilities, tags, created_at, updated_at) were TS phantoms"
|
||||
echo "the Go struct never emitted. See unified-audit.md"
|
||||
echo "diff-05x06-7cdf4e78ae24 for closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# D-2 Issuer phantom-field check.
|
||||
BAD_ISSUER=$(awk '
|
||||
/^export interface Issuer \{/ { flag=1; next }
|
||||
flag && /^\}/ { flag=0 }
|
||||
flag { print FILENAME":"NR":"$0 }
|
||||
' web/src/api/types.ts \
|
||||
| grep -E '\bstatus\??\s*:' \
|
||||
|| true)
|
||||
if [ -n "$BAD_ISSUER" ]; then
|
||||
echo "D-2 regression: Issuer TS interface re-added a phantom 'status' field:"
|
||||
echo "$BAD_ISSUER"
|
||||
echo ""
|
||||
echo "The Go-side internal/domain/connector.go::Issuer has no 'status'"
|
||||
echo "field — only 'enabled' (bool). Render sites derive the displayed"
|
||||
echo "status from 'enabled' at the call site (see"
|
||||
echo "web/src/pages/IssuersPage.tsx::issuerStatus). See unified-audit.md"
|
||||
echo "diff-05x06-97fab8783a5c for closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# D-2 Notification phantom-field check.
|
||||
BAD_NOTIF=$(awk '
|
||||
/^export interface Notification \{/ { flag=1; next }
|
||||
flag && /^\}/ { flag=0 }
|
||||
flag { print FILENAME":"NR":"$0 }
|
||||
' web/src/api/types.ts \
|
||||
| grep -E '\bsubject\??\s*:' \
|
||||
|| true)
|
||||
if [ -n "$BAD_NOTIF" ]; then
|
||||
echo "D-2 regression: Notification TS interface re-added a phantom 'subject' field:"
|
||||
echo "$BAD_NOTIF"
|
||||
echo ""
|
||||
echo "The Go-side internal/domain/notification.go::NotificationEvent"
|
||||
echo "has no 'subject' field — only 'message'. Pre-D-2 the consumer"
|
||||
echo "at NotificationsPage.tsx had a dead '|| n.subject' fallback"
|
||||
echo "that always fell through. See unified-audit.md"
|
||||
echo "diff-05x06-caba9eb3620e for closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbidden client-side bulk-action loop regression guard (L-1)
|
||||
# L-1 master closed cat-l-fa0c1ac07ab5 (bulk-renew loop) and
|
||||
# cat-l-8a1fb258a38a (bulk-reassign loop) by adding server-side
|
||||
# bulk endpoints (POST /api/v1/certificates/bulk-renew and
|
||||
# POST /api/v1/certificates/bulk-reassign) that the GUI calls
|
||||
# in a single round-trip. Pre-L-1 the GUI looped per-cert
|
||||
# HTTP calls — 100 selected certs = 100 round-trips × ~50–200ms
|
||||
# each = a 5–20-second wedge during which the operator stares
|
||||
# at a progress bar.
|
||||
#
|
||||
# This step grep-fails the build if either loop shape reappears
|
||||
# in CertificatesPage.tsx. Patterns catch the actual pre-L-1
|
||||
# shapes:
|
||||
# - `for (const id of ids) { await triggerRenewal(id) }`
|
||||
# - `for (const id of ids) { await updateCertificate(id, { owner_id }) }`
|
||||
# - `for (let i = 0; i < ids.length; i++) { await triggerRenewal(ids[i]) }`
|
||||
#
|
||||
# Allowed: comment lines explaining the pre-L-1 pattern in the
|
||||
# docblock above each handler. Test files (_test.tsx) exempt
|
||||
# so negative-pattern tests can keep working.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-l-fa0c1ac07ab5 and cat-l-8a1fb258a38a for closure
|
||||
# rationale, or web/src/api/client.ts::bulkRenewCertificates
|
||||
# / bulkReassignCertificates for the canonical call path.
|
||||
run: |
|
||||
set -e
|
||||
|
||||
BAD_LOOP=$(grep -nE 'for[[:space:]]*\(' web/src/pages/CertificatesPage.tsx 2>/dev/null \
|
||||
| grep -E 'await[[:space:]]+(triggerRenewal|updateCertificate)\(' \
|
||||
| grep -v '\.test\.' \
|
||||
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||||
|| true)
|
||||
if [ -n "$BAD_LOOP" ]; then
|
||||
echo "L-1 regression: client-side bulk-action loop reappeared in CertificatesPage.tsx:"
|
||||
echo "$BAD_LOOP"
|
||||
echo ""
|
||||
echo "Use bulkRenewCertificates({ certificate_ids: [...] }) or"
|
||||
echo "bulkReassignCertificates({ certificate_ids: [...], owner_id, team_id? })"
|
||||
echo "instead of looping per-item HTTP calls. See"
|
||||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-l-* for rationale."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbidden orphan-CRUD client function regression guard (B-1)
|
||||
# B-1 master closed four audit findings — three orphan-update fns
|
||||
# (cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9) and one orphan CRUD
|
||||
# surface (cat-b-4631ca092bee, RenewalPolicy) — by wiring per-page
|
||||
# Edit modals so every backend write endpoint has at least one
|
||||
# GUI consumer. The fourth finding (cat-b-9b97ffb35ef7) deleted
|
||||
# the dead `exportCertificatePEM` duplicate.
|
||||
#
|
||||
# Pre-B-1 the failure mode was: backend ships a CRUD handler,
|
||||
# client.ts ships the matching `update*` / `delete*` / `create*`
|
||||
# function, but no page imports it. Operators were forced to
|
||||
# `psql` directly to edit team names, owner emails, agent-group
|
||||
# match rules, issuer names, profile names, or any renewal-policy
|
||||
# field — turning a 30-second GUI task into a 30-minute database
|
||||
# excursion with audit-trail gaps.
|
||||
#
|
||||
# This step fails the build if any of the eight previously-orphan
|
||||
# client functions loses its page consumer (i.e. a future refactor
|
||||
# accidentally re-orphans them). Each fn must have ≥1 non-test
|
||||
# consumer under web/src/pages/. Tests (*.test.ts(x)) and the
|
||||
# client.ts definition file itself are exempt.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9, cat-b-4631ca092bee,
|
||||
# cat-b-9b97ffb35ef7 for closure rationale.
|
||||
run: |
|
||||
set -e
|
||||
ORPHAN_FNS="updateOwner updateTeam updateAgentGroup updateIssuer updateProfile createRenewalPolicy updateRenewalPolicy deleteRenewalPolicy"
|
||||
FAIL=0
|
||||
for fn in $ORPHAN_FNS; do
|
||||
HITS=$(grep -rE "\b${fn}\b" web/src/pages/ 2>/dev/null \
|
||||
| grep -vE '\.test\.(ts|tsx):' \
|
||||
| wc -l)
|
||||
if [ "$HITS" -eq 0 ]; then
|
||||
echo "::error::B-1 regression: client function '${fn}' has zero consumers under web/src/pages/."
|
||||
echo " Every backend CRUD endpoint must have a GUI consumer to avoid forcing operators to psql."
|
||||
echo " Either restore the page consumer or delete the client function in the same commit."
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
# cat-b-9b97ffb35ef7: exportCertificatePEM was deleted as a dead
|
||||
# duplicate of downloadCertificatePEM. Block resurrection.
|
||||
if grep -nE 'export\s+const\s+exportCertificatePEM' web/src/api/client.ts >/dev/null 2>&1; then
|
||||
echo "::error::B-1 regression: exportCertificatePEM was removed as a dead duplicate of downloadCertificatePEM."
|
||||
echo " If a JSON variant is needed, add an explicit page consumer in the same commit."
|
||||
FAIL=1
|
||||
fi
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "B-1 orphan-CRUD client function guardrail: all 8 functions have page consumers."
|
||||
|
||||
- name: Forbidden strings.Contains(err.Error()) regression guard (S-2)
|
||||
# S-2 closure (cat-s6-efc7f6f6bd50): replaced 30 brittle
|
||||
# substring-match error-dispatch sites in internal/api/handler/
|
||||
# with errors.Is + typed sentinels (repository.ErrNotFound,
|
||||
# repository.ErrForeignKeyConstraint via the
|
||||
# repository.IsForeignKeyError helper). This step grep-fails
|
||||
# the build if any new strings.Contains(err.Error(), "not found")
|
||||
# or strings.Contains(err.Error(), "violates foreign key")
|
||||
# site appears under internal/api/handler/.
|
||||
#
|
||||
# Allowed: closure-comments documenting the convention (e.g.
|
||||
# bulk_reassignment.go's "post-M-1 errToStatus convention"
|
||||
# docblock); domain-specific substring patterns that are
|
||||
# legitimately one-off ("cannot approve", "cannot reject",
|
||||
# "cannot be parsed", "challenge password") — flagged as
|
||||
# deferred follow-ups in the S-2 commit message.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-s6-efc7f6f6bd50 for closure rationale.
|
||||
run: |
|
||||
set -e
|
||||
BAD=$(grep -rnE 'strings\.Contains\(err\.Error\(\),\s*"(not found|violates foreign key|RESTRICT)"' internal/api/handler/ 2>/dev/null \
|
||||
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||||
|| true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "S-2 regression: brittle substring-match error-dispatch reappeared:"
|
||||
echo "$BAD"
|
||||
echo ""
|
||||
echo "Use errors.Is(err, repository.ErrNotFound) for not-found dispatch,"
|
||||
echo "or repository.IsForeignKeyError(err) for FK violations."
|
||||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||
echo "cat-s6-efc7f6f6bd50 for closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
echo "S-2 typed-sentinel error-dispatch guardrail: clean."
|
||||
|
||||
- name: Race Detection
|
||||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... -count=1 -timeout 300s
|
||||
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
|
||||
run: |
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... -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
|
||||
run: |
|
||||
@@ -73,6 +583,13 @@ jobs:
|
||||
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"
|
||||
@@ -90,6 +607,10 @@ jobs:
|
||||
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
|
||||
@@ -126,6 +647,176 @@ jobs:
|
||||
working-directory: web
|
||||
run: npx vite build
|
||||
|
||||
- name: Forbidden hardcoded source-count prose regression guard (S-1)
|
||||
# S-1 master closed cat-s1-9ce1cbe26876 (README + features.md
|
||||
# stale numeric counts; explicit CLAUDE.md violation per
|
||||
# "version-stamped numbers rot") and
|
||||
# cat-s1-features_md_issuer_count_contradiction (features.md
|
||||
# self-disagreed on issuer count: 9 vs 12 in the same doc).
|
||||
# The fix replaced source-derived numbers in prose with
|
||||
# "rebuild via <command>" patterns documented in CLAUDE.md::
|
||||
# "Current-state commands". This step grep-fails the build if
|
||||
# any of the previously-stale sites reintroduces a hardcoded
|
||||
# count.
|
||||
#
|
||||
# Allowed surfaces: demo-fixture prose in README ("32
|
||||
# certificates" — those are seed_demo.sql facts, not live
|
||||
# source counts), historical-milestone counts in
|
||||
# WORKSPACE-CHANGELOG.md, the testing-guide example phrasing
|
||||
# ("README claims 8 issuer connectors but only 6 exist"),
|
||||
# and any number that quotes the source command immediately
|
||||
# adjacent.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-s1-9ce1cbe26876 + cat-s1-features_md_issuer_count_contradiction
|
||||
# for closure rationale.
|
||||
run: |
|
||||
set -e
|
||||
BAD=$(grep -rnE '\b[0-9]+\s+(issuer connectors?|target connectors?|notifier connectors?|discovery connectors?|MCP tools|OpenAPI operations|migrations|database tables|frontend pages|HTTP routes)\b' \
|
||||
README.md docs/ 2>/dev/null \
|
||||
| grep -vE 'WORKSPACE-CHANGELOG|seed_demo|demo override' \
|
||||
| grep -vE 'DRIFT HAZARD|Source: |Rebuild|rebuild via|grep -|wc -l|ls -d|find ' \
|
||||
| grep -vE 'README claims [0-9]+ issuer connectors but only [0-9]+ exist' \
|
||||
|| true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "S-1 regression: hardcoded source-count prose reappeared:"
|
||||
echo "$BAD"
|
||||
echo ""
|
||||
echo "CLAUDE.md rule: 'Numeric claims about current state rot.'"
|
||||
echo "Replace the count with the grep command from CLAUDE.md::"
|
||||
echo "'Current-state commands' (e.g. 'ls -d internal/connector/issuer/*/ | wc -l')"
|
||||
echo "or rephrase to reference the rebuild command on the same line."
|
||||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||
echo "cat-s1-9ce1cbe26876 for closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
echo "S-1 stale-counts guardrail: clean."
|
||||
|
||||
- name: Documented orphan client fns sync guard (P-1)
|
||||
# P-1 master closed diff-04x03-d24864996ad4 + cat-b-dc46aadab98e
|
||||
# by documenting 17 detail-page-candidate orphan client.ts
|
||||
# functions in a docblock at the top of web/src/api/client.ts.
|
||||
# This step verifies the docblock list ↔ export list relationship:
|
||||
# every name listed in the docblock must still be declared as
|
||||
# an export below it (catches drift where someone deletes the
|
||||
# export but forgets the docblock, or vice versa).
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
|
||||
run: |
|
||||
set -e
|
||||
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
|
||||
MISSING=""
|
||||
for fn in $DOCUMENTED; do
|
||||
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
|
||||
MISSING="${MISSING}${fn} "
|
||||
fi
|
||||
done
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "P-1 regression: documented orphan(s) missing from client.ts exports:"
|
||||
echo " $MISSING"
|
||||
echo ""
|
||||
echo "Either restore the export, or delete the corresponding line"
|
||||
echo "in the documented-orphans docblock at the top of client.ts."
|
||||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||
echo "diff-04x03-d24864996ad4 for closure rationale."
|
||||
exit 1
|
||||
fi
|
||||
echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)."
|
||||
|
||||
- name: Forbidden env-var docs drift regression guard (G-3)
|
||||
# G-3 master closed cat-g-163dae19bc59 (docs-only env vars
|
||||
# phantom in features.md), cat-g-b8f8f8796159 (6 config-only
|
||||
# env vars never documented), and cat-g-renewal_check_interval_rename_drift
|
||||
# (features.md still advertised the pre-rename
|
||||
# CERTCTL_RENEWAL_CHECK_INTERVAL after it was renamed to
|
||||
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL). This step runs
|
||||
# `comm -23` both ways between the env vars defined in Go
|
||||
# source (config.go + cmd/agent + deploy/test fixtures + ACME
|
||||
# DNS-01 script env exports) and the env vars mentioned in
|
||||
# README + docs/ + deploy/helm/.
|
||||
#
|
||||
# Allowlist: env vars that are documented as integration-
|
||||
# surface contracts (script env exports for ACME DNS-01,
|
||||
# OpenSSL CA scripts, StepCA per-issuer-config-blob fields,
|
||||
# Webhook per-notifier-config-blob fields, ACME EAB, audit
|
||||
# exclusion, demo-stack overrides) but not consumed directly
|
||||
# by config.go. Each entry below has a one-line justification
|
||||
# — if you add a new entry, add the justification too.
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-g-* for closure rationale.
|
||||
run: |
|
||||
set -e
|
||||
# Defined: config.go + agent + cli + mcp-server + server cmds + test fixtures + ACME DNS export
|
||||
{
|
||||
grep -nE '"CERTCTL_[A-Z_]+"' internal/config/config.go | sed -E 's/.*"(CERTCTL_[A-Z_]+)".*/\1/'
|
||||
grep -rhoE '"CERTCTL_[A-Z_]+"' cmd/agent/*.go cmd/cli/*.go cmd/mcp-server/*.go cmd/server/*.go 2>/dev/null | sed -E 's/"(CERTCTL_[A-Z_]+)"/\1/'
|
||||
grep -rhoE 'CERTCTL_[A-Z_]+' deploy/test/qa_test.go internal/connector/issuer/acme/dns.go 2>/dev/null
|
||||
} | grep -E '^CERTCTL_' | sort -u > /tmp/g3-defined.txt
|
||||
# Documented: README + docs + helm
|
||||
grep -rhoE '\bCERTCTL_[A-Z_]+\b' README.md docs/ deploy/helm/ 2>/dev/null | sort -u > /tmp/g3-docs.txt
|
||||
# Allowlist of env vars documented as external integration contracts.
|
||||
# Each entry justifies itself in one line; if you add to this list,
|
||||
# add the justification.
|
||||
ALLOWED='^(
|
||||
CERTCTL_OPENSSL_SIGN_SCRIPT|
|
||||
CERTCTL_OPENSSL_REVOKE_SCRIPT|
|
||||
CERTCTL_OPENSSL_CRL_SCRIPT|
|
||||
CERTCTL_OPENSSL_TIMEOUT_SECONDS|
|
||||
CERTCTL_STEPCA_URL|
|
||||
CERTCTL_STEPCA_FINGERPRINT|
|
||||
CERTCTL_STEPCA_PROVISIONER|
|
||||
CERTCTL_STEPCA_PROVISIONER_NAME|
|
||||
CERTCTL_STEPCA_PROVISIONER_KEY|
|
||||
CERTCTL_STEPCA_PROVISIONER_JWK|
|
||||
CERTCTL_STEPCA_PROVISIONER_PASSWORD|
|
||||
CERTCTL_STEPCA_PASSWORD|
|
||||
CERTCTL_STEPCA_KEY_PATH|
|
||||
CERTCTL_STEPCA_ROOT_CA|
|
||||
CERTCTL_WEBHOOK_URL|
|
||||
CERTCTL_WEBHOOK_SECRET|
|
||||
CERTCTL_ACME_EAB_KID|
|
||||
CERTCTL_ACME_EAB_HMAC|
|
||||
CERTCTL_ACME_DNS_PROPAGATION_WAIT|
|
||||
CERTCTL_AUDIT_EXCLUDE_PATHS|
|
||||
CERTCTL_TLS_|
|
||||
CERTCTL_TLS_INSECURE_SKIP_VERIFY|
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH|
|
||||
CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY|
|
||||
CERTCTL_QA_[A-Z_]+
|
||||
)$'
|
||||
# ^ The CERTCTL_OPENSSL_* / CERTCTL_STEPCA_* / CERTCTL_WEBHOOK_* /
|
||||
# CERTCTL_ACME_EAB_* / CERTCTL_ACME_DNS_PROPAGATION_WAIT /
|
||||
# CERTCTL_AUDIT_EXCLUDE_PATHS / CERTCTL_TLS_* / CERTCTL_SERVER_* /
|
||||
# CERTCTL_QA_* sets are documented integration-surface contracts
|
||||
# (script invocations, per-issuer config-blob field names,
|
||||
# per-notifier config-blob field names, demo-stack overrides,
|
||||
# test fixtures) — not server-side env vars in config.go.
|
||||
# The audit's "37 docs-only" count over-flagged these; the
|
||||
# closure narrows the gate to the specific drift sites
|
||||
# (renewal-interval rename + 6 config-only) and allowlists
|
||||
# the documented external contracts here.
|
||||
ALLOWED_FLAT=$(echo "$ALLOWED" | tr -d '\n ')
|
||||
DOCS_ONLY=$(comm -13 /tmp/g3-defined.txt /tmp/g3-docs.txt | grep -vE "$ALLOWED_FLAT" || true)
|
||||
CONFIG_ONLY=$(comm -23 /tmp/g3-defined.txt /tmp/g3-docs.txt || true)
|
||||
if [ -n "$DOCS_ONLY" ]; then
|
||||
echo "G-3 regression: env var(s) mentioned in docs but not defined in Go source AND not in the documented integration-surface allowlist:"
|
||||
echo "$DOCS_ONLY"
|
||||
echo ""
|
||||
echo "Either delete from docs (phantom/typo) or add to config.go,"
|
||||
echo "or add to the ALLOWED list with a one-line justification."
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CONFIG_ONLY" ]; then
|
||||
echo "G-3 regression: env var(s) defined in Go source but never documented:"
|
||||
echo "$CONFIG_ONLY"
|
||||
echo ""
|
||||
echo "Add an entry to docs/features.md (or another canonical doc) so operators can find it."
|
||||
exit 1
|
||||
fi
|
||||
echo "G-3 env-var docs drift guardrail: clean."
|
||||
|
||||
helm-lint:
|
||||
name: Helm Chart Validation
|
||||
runs-on: ubuntu-latest
|
||||
@@ -137,8 +828,34 @@ jobs:
|
||||
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/tls.md.
|
||||
- name: Lint Helm Chart
|
||||
run: helm lint deploy/helm/certctl/
|
||||
run: |
|
||||
helm lint deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci
|
||||
|
||||
- name: Template Helm Chart
|
||||
run: helm template certctl deploy/helm/certctl/ > /dev/null
|
||||
- name: Template Helm Chart (existingSecret mode)
|
||||
run: |
|
||||
helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci \
|
||||
> /dev/null
|
||||
|
||||
- name: Template Helm Chart (cert-manager mode)
|
||||
run: |
|
||||
helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.certManager.enabled=true \
|
||||
--set server.tls.certManager.issuerRef.name=letsencrypt-prod \
|
||||
> /dev/null
|
||||
|
||||
- name: Template Helm Chart (guard fails without TLS)
|
||||
run: |
|
||||
# Inverse test: the chart MUST refuse to render when no TLS source is
|
||||
# configured. If this ever renders successfully, the fail-loud guard
|
||||
# in certctl.tls.required has regressed.
|
||||
if helm template certctl deploy/helm/certctl/ > /dev/null 2>&1; then
|
||||
echo "::error::Helm chart rendered without a TLS source — fail-loud guard regressed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+292
-43
@@ -7,40 +7,30 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GO_VERSION: '1.22'
|
||||
# Keep in lock-step with .github/workflows/ci.yml (M-3).
|
||||
GO_VERSION: '1.25.9'
|
||||
IMAGE_NAMESPACE: shankar0123
|
||||
|
||||
jobs:
|
||||
# Cross-compile agent and server binaries for multiple platforms
|
||||
# ----------------------------------------------------------------------
|
||||
# build-binaries (M-3): matrix build every (binary × OS × arch) tuple.
|
||||
# For each tuple we produce: the binary, a SPDX-JSON SBOM, a keyless
|
||||
# Cosign signature + certificate bundle, and a single-line sha256sum
|
||||
# file. All artefacts are uploaded to a workflow-scoped artifact; the
|
||||
# aggregate-checksums job fans them back in for release upload.
|
||||
# ----------------------------------------------------------------------
|
||||
build-binaries:
|
||||
name: Build Cross-Platform Binaries
|
||||
name: Build ${{ matrix.binary }} (${{ matrix.os }}/${{ matrix.arch }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
contents: read
|
||||
id-token: write # Cosign keyless OIDC identity token
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Agent binaries (4 platforms)
|
||||
- os: linux
|
||||
arch: amd64
|
||||
binary: agent
|
||||
- os: linux
|
||||
arch: arm64
|
||||
binary: agent
|
||||
- os: darwin
|
||||
arch: amd64
|
||||
binary: agent
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
binary: agent
|
||||
# Server binaries (2 platforms)
|
||||
- os: linux
|
||||
arch: amd64
|
||||
binary: server
|
||||
- os: linux
|
||||
arch: arm64
|
||||
binary: server
|
||||
|
||||
binary: [agent, server, cli, mcp-server]
|
||||
os: [linux, darwin]
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -51,35 +41,174 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build ${{ matrix.binary }} binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
- name: Build binary
|
||||
id: build
|
||||
env:
|
||||
GOOS: ${{ matrix.os }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
CGO_ENABLED: 0
|
||||
CGO_ENABLED: '0'
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OUTPUT_NAME="certctl-${{ matrix.binary }}-${{ matrix.os }}-${{ matrix.arch }}"
|
||||
go build -ldflags="-w -s -X main.Version=${{ steps.version.outputs.VERSION }}" \
|
||||
mkdir -p dist
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="-w -s -X main.Version=${VERSION}" \
|
||||
-o "dist/${OUTPUT_NAME}" \
|
||||
"./cmd/${{ matrix.binary }}"
|
||||
ls -lh "dist/${OUTPUT_NAME}"
|
||||
echo "output_name=${OUTPUT_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload binaries to release
|
||||
- name: Generate SBOM (SPDX-JSON)
|
||||
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
|
||||
with:
|
||||
file: dist/${{ steps.build.outputs.output_name }}
|
||||
format: spdx-json
|
||||
output-file: dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json
|
||||
upload-artifact: false
|
||||
upload-release-assets: false
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Keyless-sign binary with Cosign
|
||||
env:
|
||||
OUTPUT_NAME: ${{ steps.build.outputs.output_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Cosign v3.0 (shipped by cosign-installer@v4.1.1 default
|
||||
# cosign-release=v3.0.5) removed --output-signature/--output-certificate
|
||||
# on sign-blob. The replacement is --bundle, which emits a unified
|
||||
# Sigstore bundle (signature + cert chain + Rekor inclusion proof) as
|
||||
# a single .sigstore.json artefact. M-11.
|
||||
cosign sign-blob \
|
||||
--yes \
|
||||
--bundle "dist/${OUTPUT_NAME}.sigstore.json" \
|
||||
"dist/${OUTPUT_NAME}"
|
||||
|
||||
- name: Compute SHA-256 sidecar
|
||||
env:
|
||||
OUTPUT_NAME: ${{ steps.build.outputs.output_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
sha256sum "${OUTPUT_NAME}" > "${OUTPUT_NAME}.sha256"
|
||||
cat "${OUTPUT_NAME}.sha256"
|
||||
|
||||
- name: Upload build artefacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ steps.build.outputs.output_name }}
|
||||
path: |
|
||||
dist/${{ steps.build.outputs.output_name }}
|
||||
dist/${{ steps.build.outputs.output_name }}.sigstore.json
|
||||
dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json
|
||||
dist/${{ steps.build.outputs.output_name }}.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# aggregate-checksums (M-3): fan in every matrix artefact, produce a
|
||||
# single checksums.txt (sha256sum format, compatible with `sha256sum
|
||||
# -c`), sign it with Cosign, upload everything to the GitHub Release,
|
||||
# and emit a base64-encoded hash manifest for the SLSA generator.
|
||||
# ----------------------------------------------------------------------
|
||||
aggregate-checksums:
|
||||
name: Aggregate checksums & sign
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries]
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # Cosign keyless OIDC identity token
|
||||
outputs:
|
||||
hashes: ${{ steps.hashes.outputs.hashes }}
|
||||
steps:
|
||||
- name: Download binary artefacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: binary-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Aggregate SHA-256 sums
|
||||
id: hashes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd artifacts
|
||||
: > checksums.txt
|
||||
for f in certctl-*; do
|
||||
case "$f" in
|
||||
*.sigstore.json|*.sbom.spdx.json|*.sha256|checksums.txt)
|
||||
continue ;;
|
||||
esac
|
||||
sha256sum "$f" >> checksums.txt
|
||||
done
|
||||
echo "=== checksums.txt ==="
|
||||
cat checksums.txt
|
||||
# base64 hashes (single line, no wrapping) for SLSA generator.
|
||||
HASHES=$(base64 -w0 < checksums.txt)
|
||||
echo "hashes=${HASHES}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Keyless-sign checksums.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd artifacts
|
||||
# Cosign v3.0 --bundle replaces the removed v2 flag pair
|
||||
# --output-signature / --output-certificate. See M-11.
|
||||
cosign sign-blob \
|
||||
--yes \
|
||||
--bundle checksums.txt.sigstore.json \
|
||||
checksums.txt
|
||||
|
||||
- name: Upload artefacts to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
dist/certctl-agent-*
|
||||
dist/certctl-server-*
|
||||
artifacts/certctl-*
|
||||
artifacts/checksums.txt
|
||||
artifacts/checksums.txt.sigstore.json
|
||||
|
||||
# Build and push Docker images
|
||||
# ----------------------------------------------------------------------
|
||||
# provenance-binaries (M-3): SLSA Level 3 provenance for every binary.
|
||||
# The SLSA generic generator reusable workflow runs in a hermetic
|
||||
# workflow run, producing multiple.intoto.jsonl from the base64 hash
|
||||
# manifest and uploading it as a release asset.
|
||||
# ----------------------------------------------------------------------
|
||||
provenance-binaries:
|
||||
name: SLSA provenance (binaries)
|
||||
needs: [aggregate-checksums]
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
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
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# build-and-push-docker: push container images to GHCR with native
|
||||
# SLSA L3 provenance (mode=max) and SBOM attestations emitted by
|
||||
# docker/build-push-action@v6, plus a keyless Cosign signature on the
|
||||
# image digest for identity-bound verification. The M-4 proxy-propagation
|
||||
# build-args block is retained verbatim — M-3 only adds supply-chain
|
||||
# steps; it never touches M-4 wiring.
|
||||
# ----------------------------------------------------------------------
|
||||
build-and-push-docker:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write # Cosign keyless OIDC identity token
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -93,40 +222,90 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
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@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-server:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:latest
|
||||
# Proxy propagation (M-4, Issue #9) — forwards runner-level proxy
|
||||
# secrets into the Docker build so self-hosted runners behind
|
||||
# corporate proxies can reach public registries. GitHub-hosted
|
||||
# runners don't need proxies, so the secrets are optional and
|
||||
# resolve to empty strings when unset — byte-identical to the
|
||||
# pre-fix behaviour for the public-runner path.
|
||||
build-args: |
|
||||
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
||||
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
||||
NO_PROXY=${{ secrets.NO_PROXY }}
|
||||
# Supply-chain hardening (M-3): emit native SLSA L3 provenance
|
||||
# and SBOM attestations bound to the image manifest.
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Keyless-sign server image with Cosign
|
||||
env:
|
||||
DIGEST: ${{ steps.server-push.outputs.digest }}
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign --yes "${IMAGE}@${DIGEST}"
|
||||
|
||||
- name: Build and push agent image
|
||||
id: agent-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.agent
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-agent:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:latest
|
||||
# Proxy propagation (M-4, Issue #9) — see server-image step for
|
||||
# rationale. Empty secrets resolve to empty build args, leaving
|
||||
# the un-proxied code path byte-identical to the pre-fix tree.
|
||||
build-args: |
|
||||
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
||||
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
||||
NO_PROXY=${{ secrets.NO_PROXY }}
|
||||
# Supply-chain hardening (M-3): emit native SLSA L3 provenance
|
||||
# and SBOM attestations bound to the image manifest.
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Create release notes with all artifacts
|
||||
- name: Keyless-sign agent image with Cosign
|
||||
env:
|
||||
DIGEST: ${{ steps.agent-push.outputs.digest }}
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign --yes "${IMAGE}@${DIGEST}"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# create-release: stamp the release body. The actual asset uploads are
|
||||
# handled by aggregate-checksums (binaries, SBOMs, sigs, certs,
|
||||
# checksums.txt + signature) and the SLSA generator (multiple.intoto.jsonl).
|
||||
# ----------------------------------------------------------------------
|
||||
create-release:
|
||||
name: Create Release Notes
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, build-and-push-docker]
|
||||
needs: [build-binaries, aggregate-checksums, provenance-binaries, build-and-push-docker]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -135,7 +314,7 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release with notes
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -197,6 +376,76 @@ jobs:
|
||||
|
||||
- **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
|
||||
|
||||
Every binary, `checksums.txt`, and container image is signed with Cosign
|
||||
keyless OIDC. Each binary ships with a SPDX-JSON SBOM. Binaries are covered
|
||||
by SLSA Level 3 provenance; container images carry native SLSA L3 provenance
|
||||
and SBOM attestations (docker/build-push-action `provenance: mode=max`,
|
||||
`sbom: true`) in addition to a Cosign signature on the digest.
|
||||
|
||||
**1. Verify SHA-256 checksums:**
|
||||
|
||||
```bash
|
||||
sha256sum -c checksums.txt
|
||||
```
|
||||
|
||||
**2. Verify the Cosign signature on checksums.txt (keyless OIDC):**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Replace `checksums.txt` with any individual binary name to verify that
|
||||
artefact directly (each binary ships with its own `.sigstore.json`
|
||||
bundle, e.g. `cosign verify-blob --bundle certctl-agent-linux-amd64.sigstore.json …`).
|
||||
|
||||
**3. Verify SLSA Level 3 provenance (binaries):**
|
||||
|
||||
```bash
|
||||
slsa-verifier verify-artifact \
|
||||
--provenance-path multiple.intoto.jsonl \
|
||||
--source-uri github.com/shankar0123/certctl \
|
||||
--source-tag ${{ steps.version.outputs.VERSION }} \
|
||||
certctl-agent-linux-amd64
|
||||
```
|
||||
|
||||
**4. Verify container image signature and attestations:**
|
||||
|
||||
```bash
|
||||
IMAGE=ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
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 (mode=max)
|
||||
cosign verify-attestation --type slsaprovenance \
|
||||
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
```
|
||||
|
||||
## Helm Chart
|
||||
|
||||
|
||||
+19
-2
@@ -62,12 +62,29 @@ certctl-agent
|
||||
certctl-cli
|
||||
/server
|
||||
/agent
|
||||
/cli
|
||||
/mcp-server
|
||||
|
||||
# Private strategy docs
|
||||
roadmap.md
|
||||
SECURITY_REMEDIATION.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
mcp-server
|
||||
|
||||
# Local Go build/module caches (session-scoped, never committed)
|
||||
/.gocache/
|
||||
/.gomodcache/
|
||||
/.gopath/
|
||||
/.gomodcache-gopath/
|
||||
|
||||
# Design scratch files (session-scoped)
|
||||
/.i004-design.md
|
||||
/.i005-design.md
|
||||
|
||||
# HTTPS-Everywhere (M-007) Phase 6: the docker-compose.test.yml tls-init
|
||||
# container writes ca.crt / server.crt / server.key into this directory so
|
||||
# the host-side integration_test.go binary can pin the CA via
|
||||
# CERTCTL_TEST_CA_BUNDLE=./certs/ca.crt. Material is regenerated on every
|
||||
# `docker compose up` and never belongs in git.
|
||||
/deploy/test/certs/
|
||||
|
||||
@@ -6,6 +6,7 @@ run:
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- contextcheck
|
||||
- govet
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [unreleased] — 2026-04-25
|
||||
|
||||
### H-1: Security hardening trio — closed end-to-end
|
||||
|
||||
> Three 2026-04-24 audit findings (all P2) that together complete the HTTPS-Everywhere security baseline. The audit flagged: (1) the unauth surface (EST RFC 7030, SCEP, PKI CRL/OCSP, /health, /ready) accepted arbitrary-size request bodies because the `noAuthHandler` middleware chain was missing the `bodyLimitMiddleware` that the authed `apiHandler` chain has; (2) zero security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) were emitted on any response — enabling clickjacking, MIME-sniffing, and untrusted-origin resource loads against the dashboard and API; (3) `CERTCTL_CONFIG_ENCRYPTION_KEY` was accepted with any non-empty value, including a single character — PBKDF2-SHA256 with 100k rounds does not compensate for low-entropy passphrases at scale (CWE-916 / CWE-329).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
**Operators with low-entropy `CERTCTL_CONFIG_ENCRYPTION_KEY` will fail to start after upgrade.** Pre-H-1 the field accepted any non-empty string. Post-H-1 it requires ≥32 bytes (e.g. `openssl rand -base64 32`). The startup error names the offending env var, the actual length, the required minimum, and the canonical generation command. Empty (`""`) remains accepted — the existing fail-closed sentinel `crypto.ErrEncryptionKeyRequired` triggers downstream when an empty key tries to encrypt or decrypt. Operators using a short passphrase must rotate before the upgrade.
|
||||
|
||||
### Added
|
||||
|
||||
- **`internal/api/middleware/securityheaders.go`** (new) — `SecurityHeaders` middleware applies HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a conservative Content-Security-Policy on every response. Defaults via `SecurityHeadersDefaults()` are: `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer-when-downgrade`, and `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'`. Operators behind a customising reverse proxy can override per-header by setting any field of the config struct to the empty string (omits that header).
|
||||
- **`bodyLimitMiddleware` wired into `noAuthHandler`** in `cmd/server/main.go`. Same default cap (1 MB, configurable via `CERTCTL_MAX_BODY_SIZE`), same 413 response on overflow. Pre-H-1 only the authed surface had this protection.
|
||||
- **`securityHeadersMiddleware` wired into BOTH chains** (`middlewareStack` for authed routes; `noAuthHandler` for unauth routes). Applied before the audit middleware so headers reach 4xx/5xx responses too — critical for security posture (an attacker probing for misconfiguration sees the same headers on a 401 as on a 200).
|
||||
- **`CERTCTL_CONFIG_ENCRYPTION_KEY` length validation** in `internal/config/config.go::Validate()` — rejects keys shorter than 32 bytes with a structured error naming the actual length, the required minimum, and the canonical generation command. Empty keys remain accepted (downstream fail-closed sentinel handles it).
|
||||
- **Tests:** `internal/api/middleware/securityheaders_test.go` (4 cases — defaults present, empty disables single header, override applied, headers on 4xx/5xx). `internal/config/config_test.go` adds 5 cases for the encryption-key length check (empty accepted, 1-byte rejected, 31-byte rejected at boundary, 32-byte accepted, 44-byte realistic operator key accepted).
|
||||
|
||||
### Audit findings closed
|
||||
|
||||
- `cat-s5-4936a1cf0118` (P2, EST/SCEP/PKI unauth endpoints bypass `http.MaxBytesReader`)
|
||||
- `cat-s11-missing_security_headers` (P2, no CSP / HSTS / X-Frame-Options on responses)
|
||||
- `cat-r-encryption_key_no_length_validation` (P2, encryption key accepted with zero entropy validation)
|
||||
|
||||
### Known follow-ups (deferred from H-1 scope)
|
||||
|
||||
A weak-key dictionary check (reject `password123`, common ASCII patterns) is deferred — adds operational friction with low marginal entropy gain at the 32-byte minimum. CSP `'unsafe-inline'` for styles is required because Tailwind via Vite injects per-component `<style>` blocks at build time; removing it would require an HTML report or component refactor outside H-1 scope. A `Permissions-Policy` (formerly Feature-Policy) header is not in the H-1 baseline because the dashboard uses no advanced browser APIs (camera, microphone, geolocation); deferred until a real consumer needs it.
|
||||
|
||||
### D-2: TS ↔ Go type drift cluster — closed end-to-end
|
||||
|
||||
> The 2026-04-24 coverage-gap audit flagged five `diff-05x06-*` findings — every one a TypeScript-vs-Go shape mismatch where the on-wire JSON the backend emits and the TS interface in `web/src/api/types.ts` had drifted apart. D-1 master closed the same pattern for `Certificate` (cat-f-ae0d06b6588f, 5 phantom fields trimmed, plus the cat-f-cert_detail_page_key_render_fallback render-site fix). D-2 closes it for the remaining five entities: Agent, Target, DiscoveredCertificate, Issuer, and Notification. The audit's blunt rule "stricter side is the contract" decides the per-entity verdict — for TS phantoms (fields declared on TS, never emitted by Go) the Go side wins and TS gets trimmed; for TS-missing fields (emitted by Go, absent from TS) the Go side still wins and TS gets the addition. Pre-D-2 the failure modes were: phantom fields silently rendered `'—'` at consumer sites (e.g. AgentDetailPage's "Capabilities" + "Tags" sections always rendered empty; IssuersPage rendered `'Unknown'` for every issuer; NotificationsPage's `n.message || n.subject` fallback always fell through), and missing fields forced `(target as any).retired_at` escapes that lost type-checking. Verify-only side task: Certificate / ManagedCertificate confirmed clean since D-1.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None on the wire. The JSON the backend emits is byte-identical pre/post-D-2 — D-2 is purely TS-side reconciliation. The interface shapes change in ways that are TypeScript compile errors at consumer sites that read trimmed phantoms (intentionally — that's the closure mechanism) but no operator-visible behaviour shifts.
|
||||
|
||||
### Added
|
||||
|
||||
- `Target` interface gains `retired_at?: string | null` and `retired_reason?: string | null` (mirrors the Agent retirement-fields shape and the Go-side `internal/domain/connector.go::DeploymentTarget` I-004 model). An Agent retire cascades to all associated Targets per `service.RetireAgent → repository.RetireTarget`; the GUI can now type-check the retired-state surfacing without `(target as any).retired_at` escapes.
|
||||
- `DiscoveredCertificate` interface gains `pem_data?: string`. The Go-side struct (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`, `omitempty`) emits this field on the wire — populated by the agent filesystem scanner, the cloud-secret-manager connectors, and the repo SELECT. Optional because Go uses `omitempty`. Consumers can now reach the raw PEM with type-checked code.
|
||||
- **CI regression guardrail extension** in `.github/workflows/ci.yml` (renamed `Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) — adds three new awk-windowed greps over the Agent / Issuer / Notification interfaces in `types.ts` that fail the build if any of the trimmed phantom fields reappear. The Agent regex `\b(last_heartbeat|capabilities|tags|created_at|updated_at)\b` is paired with a `grep -v 'last_heartbeat_at'` filter to avoid false positives on the legitimate Go-emitted heartbeat field.
|
||||
|
||||
### Removed
|
||||
|
||||
- `Agent` interface — 5 phantom fields trimmed: `last_heartbeat`, `capabilities`, `tags`, `created_at`, `updated_at`. None emitted by `internal/domain/connector.go::Agent`. Two had real consumers in `AgentDetailPage.tsx` (capabilities + tags sections) — both were removed because their guards always evaluated false. The "Updated" InfoRow that read `agent.updated_at` was also dropped (Go has no equivalent timestamp on Agent). `last_heartbeat_at` flipped from required to optional to match Go's `*time.Time omitempty`.
|
||||
- `Issuer` interface — phantom `status: string` removed. Go has only `Enabled bool`. Both `IssuersPage.tsx::issuerStatus` and `IssuerDetailPage.tsx::issuerStatus` rewritten to compute `i.enabled ? 'Enabled' : 'Disabled'` exclusively (the pre-D-2 fallback `issuer.status || 'Unknown'` always rendered 'Unknown').
|
||||
- `Notification` interface — phantom `subject?: string` removed. The dead `{n.message || n.subject}` fallback at `NotificationsPage.tsx:241` was simplified to `{n.message}`. Test mocks in `NotificationsPage.test.tsx` no longer set the field.
|
||||
|
||||
### Audit findings closed
|
||||
|
||||
- diff-05x06-7cdf4e78ae24 (P2, Agent TS↔Go drift)
|
||||
- diff-05x06-2044a46f4dd0 (P2, Target TS↔DeploymentTarget Go drift)
|
||||
- diff-05x06-85ab6b98a2f7 (P2, DiscoveredCertificate TS↔Go drift)
|
||||
- diff-05x06-97fab8783a5c (P2, Issuer TS↔Go drift)
|
||||
- diff-05x06-caba9eb3620e (P2, Notification TS↔NotificationEvent Go drift)
|
||||
- diff-05x06-af18a8d7ef41 (P2, Certificate / ManagedCertificate) — verified no residual drift since D-1; no edit required
|
||||
|
||||
### Known follow-ups (deferred from D-2 scope)
|
||||
|
||||
A richer Issuer status view that derives from `enabled × test_status` (instead of `enabled` alone) is deferred — a UX scope decision, not a contract drift, and the existing `test_status: 'untested' | 'success' | 'failed'` field is already on the TS interface for whoever picks up that work. Real Agent metadata fields (capabilities advertised at heartbeat time, operator-applied tags) are deferred — D-2 removed the false UI affordance; if/when the product wants real fields, re-introduce in `AgentDetailPage` in the same commit that ships the Go-side change. The `DiscoveredCertificate.pem_data` LIST-response performance optimization (gate emission on the per-id detail path, since pem_data is kilobytes per row) is deferred as a separate backend change — D-2 only closed the contract drift.
|
||||
|
||||
### B-1: Orphan-CRUD client functions + RenewalPolicy GUI gap — closed end-to-end
|
||||
|
||||
> The 2026-04-24 coverage-gap audit flagged a cluster of operator-blocking GUI omissions: six client.ts `update*` functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, plus the full `*RenewalPolicy` CRUD trio) had backend handlers, OpenAPI operations, and exported TypeScript fetchers — but zero page consumers. Operators wanting to fix a typo in an owner's email, rename a team, retarget an agent group's match rules, or edit a renewal-policy field were forced to either delete-and-recreate (losing FK history and audit-trail continuity) or open a `psql` session against the production database directly. The audit's blunt summary: "every backend feature ships with its GUI surface" — a load-bearing CLAUDE.md invariant — was being violated for five operator-facing entities. B-1 closes that violation by wiring per-page Edit modals onto five existing pages, adding a brand-new `RenewalPoliciesPage` for the rp-* CRUD surface, and deleting one dead duplicate (`exportCertificatePEM`) so the public client surface area stops growing without consumers.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None. All five existing pages keep their Create + Delete affordances unchanged; Edit is purely additive. `RenewalPoliciesPage` is a new route at `/renewal-policies` and a new sidebar nav item slotted between Policies and Profiles. The `exportCertificatePEM` helper had zero consumers in `web/`, MCP, CLI, and tests at the time of removal — operators using `downloadCertificatePEM` (the actual call site in `CertificateDetailPage`) are unaffected.
|
||||
|
||||
### Added
|
||||
|
||||
- **`web/src/pages/RenewalPoliciesPage.tsx`** — a new full-CRUD page for the `rp-*` renewal-policy table. Surfaces a 7-column DataTable (Policy / Renewal Window / Auto / Retries / Alert Thresholds / Created / Actions) with Create, Edit, and Delete affordances. A shared `PolicyFormModal` powers both Create and Edit (the form shape is identical) covering the full domain field set: `name`, `renewal_window_days`, `auto_renew`, `max_retries`, `retry_interval_seconds`, `alert_thresholds_days[]`. The thresholds input parses comma-separated integers (`30, 14, 7, 0`) into the array shape the backend expects. Delete surfaces `repository.ErrRenewalPolicyInUse` (409 from the backend when a policy still has `managed_certificates.renewal_policy_id` references) via an explicit alert so the operator can re-target the dependent certs to a different policy before deletion. Wired into `web/src/main.tsx` routing and `web/src/components/Layout.tsx` sidebar nav.
|
||||
- **EditOwnerModal** in `web/src/pages/OwnersPage.tsx` — pre-populates from the editing owner via `useEffect`, calls `updateOwner(id, {name, email, team_id})`, mirrors the Create modal's TanStack-Query mutation/invalidation pattern.
|
||||
- **EditTeamModal** in `web/src/pages/TeamsPage.tsx` — same shape, fields `name`/`description`.
|
||||
- **EditAgentGroupModal** in `web/src/pages/AgentGroupsPage.tsx` — covers the full match-rule set (`name`, `description`, `match_os`, `match_architecture`, `match_ip_cidr`, `match_version`, `enabled`).
|
||||
- **EditIssuerModal** in `web/src/pages/IssuersPage.tsx` — deliberately rename-only. The `type` field is shown but disabled, the existing `config` blob (which includes credentials for ACME, ADCS, ZeroSSL, etc.) is forwarded untouched, and only `name` is editable. Footer note: "To change issuer type or rotate credentials, delete and recreate." This trades scope for safety — the audit's destructive-rename complaint is closed without surfacing a credential-edit attack surface that has not been threat-modeled.
|
||||
- **EditProfileModal** in `web/src/pages/ProfilesPage.tsx` — same rename-only shape. Forwards full `Partial<CertificateProfile>` with policy fields (`allowed_key_algorithms`, `max_ttl_seconds`, `allowed_ekus`, etc.) preserved untouched. Footer note about deferred policy-field editing.
|
||||
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) — grep-fails the build if any of the eight previously-orphan client functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, `createRenewalPolicy`, `updateRenewalPolicy`, `deleteRenewalPolicy`) loses its non-test consumer under `web/src/pages/`. Also blocks resurrection of the deleted `exportCertificatePEM` function. Verified locally on the post-fix tree (passes — all 8 fns have ≥2 consumers); fires against synthetic regressions (delete the Edit modal → guardrail fires the next CI run).
|
||||
|
||||
### Removed
|
||||
|
||||
- `web/src/api/client.ts::exportCertificatePEM` — closes `cat-b-9b97ffb35ef7`. The function returned `{cert_pem, chain_pem, full_pem}` JSON but had zero consumers across `web/`, MCP, CLI, and tests; `downloadCertificatePEM` (the blob-download path consumed by `CertificateDetailPage`) covers all real call sites. Test references in `web/src/api/client.test.ts` and `client.error.test.ts` were also removed. The CI guardrail blocks resurrection without an accompanying page consumer.
|
||||
|
||||
### Audit findings closed
|
||||
|
||||
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan)
|
||||
- `cat-b-7a34f893a8f9` (P1, `updateIssuer`/`updateProfile` orphan, rename-only closure)
|
||||
- `cat-b-4631ca092bee` (P1, RenewalPolicy CRUD orphan — new RenewalPoliciesPage)
|
||||
- `cat-b-9b97ffb35ef7` (P3, `exportCertificatePEM` dead duplicate)
|
||||
|
||||
### Known follow-ups (deferred from B-1 scope)
|
||||
|
||||
A fuller `EditIssuerModal` with explicit credential-rotation flow is deferred — that needs an explicit threat model (rotation reuse window, audit-trail granularity, in-flight CSR cancellation), and the audit's destructive-rename complaint is closed by rename-only Edit alone. Likewise an `EditProfileModal` with policy-field editing (max-TTL, allowed EKUs, allowed key algorithms) is deferred because policy edits affect the `enforce_certificate_policy` evaluator's semantics for already-issued certs and warrant their own scope. Per-page Vitest coverage for the new Edit modals is deferred — the CI grep guardrail catches the same regression vector ("page lost its `update*` fn consumer") at lower cost than five new test files.
|
||||
|
||||
### L-1: Client-side bulk-action loops — closed end-to-end
|
||||
|
||||
> The certctl dashboard's busiest screen (`CertificatesPage.tsx`) had two bulk-action workflows that looped per-cert HTTP calls. Selecting 100 certs and clicking "Renew" issued 100 sequential `POST /api/v1/certificates/{id}/renew` requests; "Reassign owner" issued 100 sequential `PUT /api/v1/certificates/{id}` requests. Each round-trip carried ~50–200 ms of Auth → audit-log → handler → service → repo → DB → audit-write → response, so a 100-cert bulk action was a 5–20-second wedge during which the operator stared at a progress bar. The bulk-revoke endpoint (`POST /api/v1/certificates/bulk-revoke`) already shipped in v2.0.x as the canonical pattern for this; L-1 ports that exact shape to bulk-renew (P1) and bulk-reassign (P2). One backend round-trip; one audit event for the entire operation; per-cert success/skip/error counts in a single response envelope. Bundled with two new MCP tools and an OpenAPI spec update so non-GUI callers (CLI / MCP / blackbox probes) can use the same endpoints.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None. Both endpoints are additive; the per-cert `POST /certificates/{id}/renew` and `PUT /certificates/{id}` paths remain available and unchanged. The frontend implementation switches from looping to single-call, but operators with custom GUIs hitting the per-cert endpoints continue to work.
|
||||
|
||||
### Added
|
||||
|
||||
- **`POST /api/v1/certificates/bulk-renew`** — enqueues a renewal job for every matching managed certificate. Supports criteria-mode (`{profile_id, owner_id, agent_id, issuer_id, team_id}`) and explicit-IDs mode (`{certificate_ids}`). Mirrors `BulkRevokeCriteria` field-for-field (sans the RFC-5280 reason code). Returns `{total_matched, total_enqueued, total_skipped, total_failed, enqueued_jobs[], errors[]}`. NOT admin-gated — bulk renewal is non-destructive (worst case it kicks off some redundant ACME orders). Status filter: certs in `Archived/Revoked/Expired/RenewalInProgress` are silent-skipped (TotalSkipped++) rather than returned as errors. Implementation: `internal/domain/bulk_renewal.go`, `internal/service/bulk_renewal.go`, `internal/api/handler/bulk_renewal.go`.
|
||||
- **`POST /api/v1/certificates/bulk-reassign`** — updates `owner_id` (required) and `team_id` (optional) on every cert in `certificate_ids`. Skips certs already owned by the target (silent no-op surfaced as `total_skipped`). Validates the target `owner_id` upfront — a non-existent owner returns 400 (via the typed `service.ErrBulkReassignOwnerNotFound` sentinel) before any cert is touched. NOT admin-gated. Implementation: `internal/domain/bulk_reassignment.go`, `internal/service/bulk_reassignment.go`, `internal/api/handler/bulk_reassignment.go`.
|
||||
- **MCP tools `certctl_bulk_renew_certificates` and `certctl_bulk_reassign_certificates`** in `internal/mcp/tools.go` + `internal/mcp/types.go`. Mirror the existing `certctl_bulk_revoke_certificates` shape so MCP consumers have a uniform bulk-action surface.
|
||||
- **OpenAPI schemas** `BulkRenewRequest`, `BulkRenewResult`, `BulkEnqueuedJob`, `BulkReassignRequest`, `BulkReassignResult` plus the two new operations with shared envelope semantics.
|
||||
- **Frontend client functions** `bulkRenewCertificates(criteria)` and `bulkReassignCertificates(request)` in `web/src/api/client.ts` with full TS types for both request and response envelopes.
|
||||
- **Service-layer regression tests** for both new services (`internal/service/bulk_renewal_test.go` + `internal/service/bulk_reassignment_test.go`): happy path, criteria-mode, status-skip semantics (RenewalInProgress / Revoked / Archived for renew; already-owned for reassign), empty-criteria rejection, partial-failure tolerance, single-bulk-audit-event contract.
|
||||
- **Handler-layer regression tests** (`internal/api/handler/bulk_renewal_handler_test.go` + `internal/api/handler/bulk_reassignment_handler_test.go`): happy path, empty-body 400, wrong-method 405, actor attribution from `middleware.GetUser`, owner-not-found-sentinel-→-400 mapping for reassign, generic-service-error-→-500.
|
||||
- **Domain-layer JSON-shape tests** pinning the wire contract for `BulkRenewalResult` / `BulkReassignmentResult` / `BulkOperationError`.
|
||||
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden client-side bulk-action loop regression guard (L-1)`) — grep-fails the build if `for(...) await triggerRenewal(...)` or `for(...) await updateCertificate(...)` reappears in `web/src/pages/CertificatesPage.tsx`. Verified: passes against the post-fix tree, fires against synthetic regressions.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`web/src/pages/CertificatesPage.tsx::handleBulkRenewal`** — rewritten from N-call loop to a single `bulkRenewCertificates({ certificate_ids })` call. Result envelope drives the progress UI (matched / enqueued / skipped / failed counts).
|
||||
- **`web/src/pages/CertificatesPage.tsx::handleReassign`** (in the reassign modal) — same shape: single `bulkReassignCertificates({ certificate_ids, owner_id })` call. First-error message surfaced when `total_failed > 0`.
|
||||
- **`internal/api/router/router.go`** — three bulk-* routes (revoke / renew / reassign) registered together as a block before the per-cert `{id}` routes; `HandlerRegistry` gains `BulkRenewal` and `BulkReassignment` fields.
|
||||
- **`cmd/server/main.go`** — constructs `BulkRenewalService` (threads `cfg.Keygen.Mode` so bulk-renew jobs land in the same initial status as single-cert `TriggerRenewal`) and `BulkReassignmentService` alongside the existing `BulkRevocationService`.
|
||||
|
||||
### Performance impact
|
||||
|
||||
100-cert bulk-renew workflow goes from ~10 s of sequential per-cert HTTP (worst case) to a single ~100 ms call — roughly 99% latency reduction on the canonical operator workflow. Server-side resource use also drops: one Auth pass, one audit event, one criteria-resolution query, instead of N of each.
|
||||
|
||||
### Closed audit findings
|
||||
|
||||
- `cat-l-fa0c1ac07ab5` (P1, primary) — bulk renew client-side sequential loop
|
||||
- `cat-l-8a1fb258a38a` (P2) — bulk owner-reassign client-side sequential loop
|
||||
|
||||
### Known follow-ups (deferred from L-1 scope)
|
||||
|
||||
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan) — different shape; the fix is "wire up the existing PUT endpoints to the GUI", not "add a bulk endpoint".
|
||||
- `cat-k-e85d1099b2d7` (P2, CertificatesPage no pagination UI) — same page; criteria-mode bulk-renew (`{owner_id: 'o-alice'}`) means an operator can already "renew all of Alice's certs" without paginating, but pagination is still wanted for the table view.
|
||||
- `cat-i-b0924b6675f8` (P1, MCP missing `claim`/`dismiss`/`acknowledge`) — L-1 added two new MCP tools but does NOT close that finding.
|
||||
|
||||
### D-1: StatusBadge enum drift + Certificate phantom fields — closed end-to-end
|
||||
|
||||
> The dashboard silently lied in five places. Agents in the `Degraded` state (the only Go-side AgentStatus that means "needs operator attention") rendered as default neutral grey because StatusBadge mapped `Stale` (a key Go has never emitted) to yellow and let the real `Degraded` value fall through to the dictionary default. Dead-letter notifications (`status: 'dead'`, retries exhausted) rendered as default neutral, visually equated with `read` (operator-acknowledged). The Certificate badge map carried a `PendingIssuance` key that no Go enum value ever emits — dead key, latent confusion vector. CertificateDetailPage's Key Algorithm and Key Size rows always rendered `—` even when the data was a single fetch away, because the lookup went through `cert.key_algorithm` directly — and the underlying `Certificate` TypeScript interface declared five optional fields (`serial_number`, `fingerprint_sha256`, `key_algorithm`, `key_size`, `issued_at`) that Go's `ManagedCertificate` has never carried (those values live on `CertificateVersion`). Five findings, two files, one frontend rebuild. Pre-D-1 the only reason this didn't trip a regression suite was that the regression suite never asserted "every Go-emitted enum value gets a non-default StatusBadge class" — D-1 fixes the visual lies and adds a 38-case Vitest property test that walks every Go enum and pins the contract.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **`Certificate` TypeScript interface no longer declares `serial_number?`, `fingerprint_sha256?`, `key_algorithm?`, `key_size?`, or `issued_at?`.** The Go `ManagedCertificate` (`internal/domain/certificate.go`) has never emitted these fields on list responses; they live on `CertificateVersion` and are reachable via `getCertificateVersions(id)`. Pre-D-5 (the cat-f phantom-fields finding) the optional declarations made `cert.X` always-undefined on lists, and downstream consumers silently rendered `—` for every cert. Post-D-5 a `cert.X` access for any of the five fields is a TypeScript compile error, forcing every consumer to acknowledge the version-fallback pattern. The OpenAPI `ManagedCertificate` schema was already correct — only the TS type was drifted.
|
||||
- **StatusBadge no longer maps `Stale` (Agent) or `PendingIssuance` (Certificate).** Both were dead keys — no Go enum value emits them. Operators with custom CSS hooked off `.badge-warning` for `Stale` will see the same color come back via the new `Degraded` mapping (same class), but JS/TS code that switches on the literal `'Stale'` will need to switch on `'Degraded'` instead. The `PendingIssuance` deletion has no documented downstream consumer.
|
||||
|
||||
### Added
|
||||
|
||||
- **`web/src/components/StatusBadge.tsx`: `Degraded` (Agent) → `badge-warning` and `dead` (Notification) → `badge-danger`.** First mappings restore the color contract for the two real Go-side values that previously fell through to the dictionary default. The `Degraded` mapping cross-references `internal/domain/connector.go::AgentStatusDegraded`; the `dead` mapping cross-references `internal/domain/notification.go::NotificationStatusDead`.
|
||||
- **`web/src/components/StatusBadge.test.tsx`: 38-case Vitest property test.** Iterates every Go-side enum value (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) plus the two frontend-synthesized `Enabled`/`Disabled` labels, asserts every value gets a non-default class (or, for the five intentionally-neutral terminal values like `Archived`/`Cancelled`/`read`, an explicit `badge badge-neutral`). Includes negative assertions on the deleted `Stale` and `PendingIssuance` keys (must fall through to neutral) and specific UX-correctness assertions on the operator-attention semantics (`dead` → danger, `Degraded` → warning).
|
||||
- **`web/src/api/types.test.ts`: D-5 Certificate phantom-fields trim regression.** A `Certificate` literal construction pinned post-trim, plus a sibling `CertificateVersion` literal pinning that the trimmed fields still live on the version envelope. The `tsc --noEmit` gate in CI is the primary enforcement; the test is the documentation of intent.
|
||||
- **CI regression guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + Certificate phantom-field regression guard (D-1)`).** Two grep blocks: (1) catches `Stale: 'badge-...'` or `PendingIssuance: 'badge-...'` in `web/src/components/StatusBadge.tsx`; (2) uses an awk-scoped window over the `export interface Certificate {` block in `web/src/api/types.ts` to catch any of the five phantom fields reappearing — explicitly excludes the `CertificateVersion` block which legitimately carries them. Verified locally on the post-fix tree (passes) and against synthetic regressions (each fires the guardrail).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`web/src/pages/CertificateDetailPage.tsx`: Key Algorithm and Key Size rows now read from `latestVersion?.key_algorithm` / `latestVersion?.key_size`.** Mirrors the existing `latestVersion` fallback used for `serial_number` and `fingerprint_sha256` earlier in the same file. Pre-D-4 these rows accessed `cert.key_algorithm` and `cert.key_size` directly — both phantom fields per D-5 — so the rows always rendered `—`. The same file's `serial_number` / `fingerprint_sha256` / `issued_at` derivations were also simplified to drop the now-impossible `cert.X || latestVersion?.X` cert-side leg.
|
||||
- **`web/src/components/StatusBadge.tsx` adds a leading docblock** naming the Go-side source-of-truth file for every status family it maps (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) and pointing at the property test as the regression vector for future enum changes.
|
||||
- **`api/openapi.yaml::ManagedCertificate`** gets a leading comment cross-referencing the D-5 closure and explaining why per-issuance fields legitimately don't appear here (they live on `CertificateVersion`). Schema property list unchanged — the OpenAPI spec was already correct.
|
||||
|
||||
### Closed audit findings
|
||||
|
||||
- `cat-d-359e92c20cbf` (P1 primary) — Agent: `Stale` dead key + `Degraded` neutral fallthrough
|
||||
- `cat-d-9f4c8e4a91f1` (P2) — Notification: `dead` missing
|
||||
- `cat-d-1447e04732e7` (P3) — Certificate: `PendingIssuance` dead key
|
||||
- `cat-f-cert_detail_page_key_render_fallback` (P2) — render-site uses `cert.key_algorithm` directly
|
||||
- `cat-f-ae0d06b6588f` (P2) — Certificate TS phantom fields (root cause)
|
||||
|
||||
### Known follow-ups (deferred from D-1 scope)
|
||||
|
||||
The audit's broader type-drift cluster (`diff-05x06-7cdf4e78ae24` Agent TS, `diff-05x06-2044a46f4dd0` DeploymentTarget TS, `diff-05x06-caba9eb3620e` Notification TS, `diff-05x06-85ab6b98a2f7` DiscoveredCertificate TS, `diff-05x06-97fab8783a5c` Issuer TS) is out of D-1 scope. Recon for those is per-type field-by-field diff Go ↔ TS — codegen-shaped, not edit-shaped — and warrants its own D-2 master prompt.
|
||||
|
||||
### U-3: GitHub #10 reopened — fresh-clone first-up postgres init failure (P1) — closed end-to-end
|
||||
|
||||
> Operator `mikeakasully` cloned v2.0.50 fresh, ran the canonical quickstart `docker compose -f deploy/docker-compose.yml up -d --build`, and postgres reported `unhealthy` indefinitely; dependent containers (certctl-server, certctl-agent) never started. Root cause: the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/`. Postgres applied them at initdb time. Once `seed.sql` referenced columns added by migrations *after* the mounted cutoff (e.g., `policy_rules.severity` from migration 000013, which the mount list never included), initdb crashed mid-seed and the container loop wedged. Two sources of truth — the mount list and the in-tree migration ladder — diverged the moment a seed-touching migration shipped, and the only thing that fixed it was hand-editing the compose file every release. The U-3 closure removes the dual source: postgres now boots empty and the server applies the entire migration ladder + seed at startup via `RunMigrations` + `RunSeed`. Same pattern Helm has used since day one. Bundled with four ride-along audit findings whose fixes are in adjacent code (column rename, missing column, dropped orphan columns, new build-identity endpoint) so operators take the schema-change pain only once.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **`deploy/docker-compose.yml` postgres no longer initdb-mounts the migration files or `seed.sql`.** Operators running on a populated `postgres_data` volume from a pre-U-3 release see no behavioral change (the schema is already in place; `RunMigrations` is `IF NOT EXISTS` and `RunSeed` is `ON CONFLICT DO NOTHING`). Operators running on a *fresh* clone now rely on the server to apply both — which is the bug fix. There is no rollback path other than re-introducing the dual-source-of-truth hazard. See `internal/repository/postgres/db.go::RunSeed` for the runtime contract.
|
||||
- **`migrations/000017_db_coupling_cleanup.up.sql` renames `renewal_policies.retry_interval_minutes` → `retry_interval_seconds`.** The column always held seconds; the column name lied (`cat-o-retry_interval_unit_mismatch`). Operators running raw SQL against the old name need to update their queries. The Go layer (`internal/repository/postgres/renewal_policy.go`) is updated in lockstep so the in-tree code path is unaffected.
|
||||
- **`migrations/000017_db_coupling_cleanup.up.sql` drops `network_scan_targets.health_check_enabled` and `network_scan_targets.health_check_interval_seconds`.** These columns were declared by a long-ago migration but never wired into Go code (`cat-o-health_check_column_orphans`) — schema noise that confused operators reading raw SQL. Anyone with custom dashboards selecting those columns will break.
|
||||
- **The compose demo overlay (`deploy/docker-compose.demo.yml`) no longer initdb-mounts `seed_demo.sql`.** It now sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed at boot via `RunDemoSeed` after baseline migrations + seed.sql are in place. Same single-source-of-truth pattern as the production path.
|
||||
|
||||
### Added
|
||||
|
||||
- **Migration `000017_db_coupling_cleanup`** (up + down). Bundles three schema changes in idempotent SQL: (1) rename `renewal_policies.retry_interval_minutes` → `retry_interval_seconds` (DO $$ guard so re-application is safe), (2) add `notification_events.created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, (3) drop the orphan `network_scan_targets.health_check_*` columns. Reduces operator-visible "schema-change releases" from four to one.
|
||||
- **`internal/repository/postgres.RunSeed`** — runtime equivalent of the deleted initdb mount for `seed.sql`. Called from `cmd/server/main.go` immediately after `RunMigrations`. Idempotent (every INSERT in the shipped seed uses `ON CONFLICT (id) DO NOTHING`); missing-file is a no-op so operators with custom packaging that strips the seed don't break.
|
||||
- **`internal/repository/postgres.RunDemoSeed`** + **`config.DatabaseConfig.DemoSeed`** + **`CERTCTL_DEMO_SEED` env var.** Replaces the deleted `seed_demo.sql` initdb mount. The compose demo overlay sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed after baseline. Same idempotency contract as the baseline path. Default-off so a vanilla deploy never lands fake-history rows.
|
||||
- **`GET /api/v1/version` endpoint** + **`internal/api/handler.VersionHandler`**. Returns `{version, commit, modified, build_time, go_version}` from `runtime/debug.ReadBuildInfo()` with ldflags-supplied `Version` taking priority. Wired through the no-auth dispatch in `cmd/server/main.go` so probes and rollout systems can read build identity without Bearer credentials. Audit middleware excludes the path so rollout polls don't dominate the audit trail. Closes `cat-u-no_version_endpoint`.
|
||||
- **`notification_events.created_at` column** is now populated by `NotificationRepository.Create` (with a `time.Now()` fallback when the caller leaves it zero) and read back by `scanNotification`. Pre-U-3 the JSON API serialised `0001-01-01T00:00:00Z` — closes `cat-o-notification_created_at_dead_field`.
|
||||
- **Five regression tests** for the U-3 contract: `TestRunSeed_AppliesIdempotently`, `TestRunSeed_MissingFileIsNoOp`, `TestRunDemoSeed_AppliesIdempotently`, `TestMigration000017_RetryIntervalRename`, `TestMigration000017_NotificationCreatedAt`, `TestMigration000017_HealthCheckOrphansDropped`, plus `TestNotificationRepository_CreatedAt_IsPersisted` / `TestNotificationRepository_CreatedAt_DefaultsToNow` for the round-trip. All testcontainers-gated (skipped under `-short`). Three handler-layer unit tests pin `/api/v1/version` (`TestVersion_ReturnsBuildInfo`, `TestVersion_RejectsNonGet`, `TestVersion_LdflagsOverride`).
|
||||
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden migration mount in compose initdb (U-3)`) — grep-fails the build if any `migrations/.*\.sql` or `seed.*\.sql` file is re-mounted into `/docker-entrypoint-initdb.d` in any compose file. Catches future drift before a fresh-clone operator hits it.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`deploy/docker-compose.yml`** + **`deploy/docker-compose.test.yml`** — postgres `volumes:` no longer mount migrations or seed files; postgres healthcheck gains `start_period: 30s`; certctl-server healthcheck gains `start_period: 30s` to absorb the runtime migration + seed application window on first boot.
|
||||
- **`deploy/docker-compose.demo.yml`** — replaces the `seed_demo.sql` initdb mount with the `CERTCTL_DEMO_SEED=true` env var on `certctl-server`.
|
||||
- **`migrations/seed.sql`** — `INSERT INTO renewal_policies` updated to use the new `retry_interval_seconds` column name (lockstep with migration 000017).
|
||||
- **`internal/repository/postgres/renewal_policy.go`** — column references updated to `retry_interval_seconds` across SELECT, INSERT, and UPDATE sites (lockstep with migration 000017).
|
||||
|
||||
### Closed audit findings
|
||||
|
||||
- `cat-u-seed_initdb_schema_drift` (P1, primary U-3 finding)
|
||||
- `cat-o-retry_interval_unit_mismatch` (P1)
|
||||
- `cat-o-notification_created_at_dead_field` (P2)
|
||||
- `cat-o-health_check_column_orphans` (P1)
|
||||
- `cat-u-no_version_endpoint` (P2)
|
||||
|
||||
### G-1: JWT silent auth downgrade — closed end-to-end
|
||||
|
||||
> Pre-G-1 the config validator accepted `CERTCTL_AUTH_TYPE=jwt` and the startup log faithfully echoed `"authentication enabled" "type"="jwt"`. Reasonable people read that and concluded JWT was on. It wasn't. The auth-middleware wiring at `cmd/server/main.go` unconditionally routed every request through the api-key bearer middleware regardless of `cfg.Auth.Type`. So `CERTCTL_AUTH_TYPE=jwt` quietly compared incoming `Authorization: Bearer <something>` against whatever string the operator put in `CERTCTL_AUTH_SECRET` — real JWT clients got 401, and operators who treated `CERTCTL_AUTH_SECRET` as a *signing* secret (because they thought they were configuring JWT) had effectively handed an attacker an api-key. A security finding masquerading as a config option. We chose to remove the option rather than ship JWT middleware — the audit-recommended structural fix that closes the hazard. Operators who actually need JWT/OIDC front certctl with an authenticating gateway (oauth2-proxy / Envoy `ext_authz` / Traefik `ForwardAuth` / Pomerium / Authelia) and run the upstream certctl with `CERTCTL_AUTH_TYPE=none`. The same pattern works on docker-compose and Helm.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **`CERTCTL_AUTH_TYPE=jwt` is no longer accepted.** Pre-G-1 the value was silently downgraded to api-key middleware. Post-G-1 the server fails at startup with a dedicated diagnostic naming the authenticating-gateway pattern. Operators with this in their env block must either switch to `api-key` (if they were de facto using api-key auth all along — same Bearer token continues to work) or switch to `none` and front certctl with an oauth2-proxy / Envoy / Traefik / Pomerium gateway. See [`docs/upgrade-to-v2-jwt-removal.md`](docs/upgrade-to-v2-jwt-removal.md).
|
||||
- **Helm chart `server.auth.type=jwt` now fails at `helm install` / `helm upgrade` template time.** New `certctl.validateAuthType` template helper runs on every template that depends on `.Values.server.auth.type` (`server-deployment.yaml`, `server-configmap.yaml`, `server-secret.yaml`) and fails the render with a pointer at the gateway-fronting pattern.
|
||||
- **OpenAPI spec `auth_type` enum no longer includes `jwt`.** API consumers checking `/api/v1/auth/info` against the spec will see a smaller enum.
|
||||
|
||||
### Removed
|
||||
|
||||
- Documented references to JWT in the certctl auth surface (config docblocks, middleware/health-handler comments, `.env.example`, `docs/architecture.md` middleware-stack bullet). Connector-level JWT references (Google OAuth2 service-account JWT in `internal/connector/discovery/gcpsm/`, `internal/connector/issuer/googlecas/`; step-ca's provisioner one-time-token JWT in `internal/connector/issuer/stepca/`) are unrelated and untouched — those are external-protocol uses, not certctl's own auth shape.
|
||||
|
||||
### Added
|
||||
|
||||
- **`config.AuthType` typed alias** with `AuthTypeAPIKey` / `AuthTypeNone` exported constants. Single source of truth for the allowed set across the validator, the runtime defense-in-depth switch in `main.go`, and the helm chart's `validateAuthType` helper.
|
||||
- **`config.ValidAuthTypes()`** helper returning the complete allowed set; pinned by a property test (`TestValidAuthTypesDoesNotContainJWT`) that fails the build if `"jwt"` is ever re-added to the slice.
|
||||
- **Defense-in-depth runtime guard** in `cmd/server/main.go` immediately after `config.Load()` — a `switch config.AuthType(cfg.Auth.Type)` that exits 1 if the validator was bypassed (test harness, alt config loader, env-var rebinding).
|
||||
- **`certctl.validateAuthType` Helm template helper** mirroring the existing `certctl.tls.required` pattern. Fails template render on any `server.auth.type` outside `{api-key, none}`.
|
||||
- **`docs/architecture.md` "Authenticating-gateway pattern (JWT, OIDC, mTLS)"** section explaining the design rationale for the narrow in-process auth surface and listing oauth2-proxy / Envoy `ext_authz` / Traefik `ForwardAuth` / Pomerium / Authelia / Caddy `forward_auth` / Apache `mod_auth_openidc` / nginx `auth_request` as the standard fronting options.
|
||||
- **`docs/upgrade-to-v2-jwt-removal.md`** migration guide. Same shape as `docs/upgrade-to-tls.md`. Walks through the dedicated startup error, both recovery paths (`api-key` vs gateway-fronting), a complete docker-compose oauth2-proxy walkthrough, Traefik ForwardAuth and Envoy `ext_authz` patterns, and rollback posture.
|
||||
- **`deploy/helm/certctl/README.md`** "JWT / OIDC via authenticating gateway" section with a Kubernetes-flavored oauth2-proxy + certctl walkthrough.
|
||||
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden auth-type literal regression guard (G-1)`) — grep-fails the build if `"jwt"` appears as an auth-type literal in production code or spec. Connector packages exempt (legitimate external-protocol uses).
|
||||
- **Negative test coverage** in `internal/config/config_test.go`: `TestValidate_JWTAuth_RejectedDedicated` (two table rows pinning that the dedicated G-1 error fires regardless of whether `Secret` is set), `TestValidAuthTypesDoesNotContainJWT` (property-level guard), `TestValidAuthTypesIsExactly_APIKey_None` (allowed-set contract), `TestValidate_GenericInvalidAuthType` (pins that other invalid values still surface the generic invalid-auth-type error, so the dedicated G-1 path doesn't accidentally swallow non-jwt typos).
|
||||
|
||||
### Changed
|
||||
|
||||
- `internal/api/middleware/middleware.go::AuthConfig.Type` field comment now references the typed `config.AuthType` constants instead of an inline string enumeration.
|
||||
- `internal/api/handler/health.go::HealthHandler.AuthType` field comment same treatment.
|
||||
- `internal/api/handler/health_test.go` — the prior `TestAuthInfo_ReturnsAuthType_JWT` (which asserted the handler echoed `"jwt"`, baking the silent-downgrade lie into the regression suite) is removed; the pre-existing `TestAuthInfo_ReturnsAuthType_APIKey` continues to cover the api-key happy path.
|
||||
- Auth-disabled startup log in `main.go` now points operators at the authenticating-gateway pattern explicitly.
|
||||
|
||||
### U-2: Dockerfile HEALTHCHECK protocol mismatch — closed end-to-end
|
||||
|
||||
> Pre-U-2 the published `ghcr.io/shankar0123/certctl-server` image shipped with `HEALTHCHECK CMD curl -f http://localhost:8443/health`. The server has been HTTPS-only since the v2.2 HTTPS-Everywhere milestone (`cmd/server/main.go::ListenAndServeTLS`, no plaintext fallback, TLS 1.3 pinned), so the probe failed every interval and Docker marked the container `unhealthy` indefinitely. Operators inside docker-compose / Helm / the example stacks were unaffected — compose overrides the HEALTHCHECK with `--cacert + https://`, Helm uses explicit `httpGet` probes that ignore Docker's HEALTHCHECK, and every example compose file overrides with `curl -sfk https://localhost:8443/health`. But anyone running bare `docker run` / Docker Swarm / Nomad / ECS — exactly the "I just pulled the published image" path — saw permanent `unhealthy` status and (depending on orchestrator policy) a restart-loop. Recon for U-2 also surfaced two adjacent bugs from the same v2.2 milestone gap: the Helm chart's `readinessProbe.httpGet.path` pointed at `/readyz`, a route the server doesn't register (only `/health` and `/ready` are wired and bypass the auth middleware), so K8s readiness probes were getting 404/auth-rejection and pods stayed `NotReady`; and the agent image had no HEALTHCHECK at all (the compose override called `pgrep -f certctl-agent` against an image that didn't ship `procps` — latent always-fail). All three are closed in this commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`Dockerfile` HEALTHCHECK now speaks HTTPS.** Bare `docker run` / Swarm / Nomad / ECS users no longer see `unhealthy` forever. The probe uses `curl -fsk https://localhost:8443/health` — `-k` (insecure) is acceptable because the probe is localhost-to-localhost: the same process serving the cert is being probed; the probe never traverses a network. Compose / Helm / examples already perform full cert-chain validation and are unaffected.
|
||||
- **Helm `server.readinessProbe.httpGet.path` corrected from `/readyz` to `/ready`.** The `/readyz` path was never registered as a no-auth route (see `internal/api/router/router.go:81` and `cmd/server/main.go:920`), so K8s readiness probes received 401 (api-key auth rejection) or 404 (when auth was disabled). Pods previously failed to report Ready under most realistic Helm deployments. Liveness probe path (`/health`) was already correct and is unchanged.
|
||||
- **`docs/connectors.md` curl examples** (15 sites) updated from `http://localhost:8443/...` to `https://localhost:8443/...` with a one-time `--cacert "$CA"` extraction note matching the existing pattern in `docs/quickstart.md`. Pre-U-2 these examples silently failed against the HTTPS listener.
|
||||
|
||||
### Added
|
||||
|
||||
- **`Dockerfile.agent` HEALTHCHECK** — `pgrep -f certctl-agent` process-presence check (the agent has no HTTP listener; presence is the right primitive). Bare-`docker run` agents now report health-status the same way compose-managed ones do. Also adds `procps` to the runtime image so `pgrep` is actually available — pre-U-2 the docker-compose override at `deploy/docker-compose.yml:173` called `pgrep -f certctl-agent` against an image that lacked it (latent always-fail; container was reported unhealthy in compose too, just rarely noticed because nothing acted on the signal).
|
||||
- **`deploy/test/healthcheck_test.go`** (`//go:build integration`) — image-level integration tests. `TestPublishedServerImage_HealthcheckSpecUsesHTTPS` builds the server image, inspects `Config.Healthcheck.Test` via `docker inspect`, and asserts the array contains `https://localhost:8443/health` and `-k`, and does NOT contain `http://localhost:8443/health` (negative regression contract). `TestPublishedAgentImage_HealthcheckSpecExists` builds the agent image and asserts the HEALTHCHECK uses `pgrep` against `certctl-agent`. Both tests `t.Skip` cleanly when docker isn't available (sandbox / CI without docker-in-docker). A third runtime test (`TestPublishedServerImage_HealthcheckTransitionsToHealthy`) is a `t.Skip` placeholder until the harness wires a sidecar postgres for image-level smoke — documented honestly so the next refactor adopts it instead of rediscovering the gap.
|
||||
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden plaintext HEALTHCHECK regression guard (U-2)`) — grep-fails the build if any `Dockerfile*` carries `HEALTHCHECK.*http://` or `curl -f http://localhost:8443/health`. Comments exempt; the `docs/upgrade-to-tls.md:182` post-cutover invariant string (which deliberately documents the expected-failure shape) is out of the guardrail's scope because the guardrail only scans Dockerfiles.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Dockerfile` final-stage HEALTHCHECK lines now carry a long-form docblock explaining the `-k` design choice, the published-image vs compose vs Helm vs examples coverage matrix, and cross-references to the audit closure + the integration test.
|
||||
- `Dockerfile.agent` runtime stage adds `procps` to the apk install so the new HEALTHCHECK and the existing compose override both have a working `pgrep`.
|
||||
- `deploy/helm/certctl/values.yaml` server probes block now carries an explanatory comment naming the registered probe routes (`/health`, `/ready`) and the U-2 closure rationale for the `/readyz` → `/ready` correction.
|
||||
|
||||
## [2.2.0] — 2026-04-19
|
||||
|
||||
### HTTPS Everywhere — The Irony
|
||||
|
||||
> 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.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **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.
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
- 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.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
Read `docs/upgrade-to-tls.md` before upgrading. The short version:
|
||||
|
||||
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.
|
||||
|
||||
There is no backward-compat bridge. There is no dual-listener mode. The cutover is one step.
|
||||
+58
-5
@@ -3,17 +3,43 @@
|
||||
# Stage 1: Build 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`/
|
||||
# `NO_PROXY` are forwarded via `docker build --build-arg` (or compose
|
||||
# `build.args`), they are re-exported as ENV with both upper- and lower-case
|
||||
# names because npm/apk/curl read the lowercase variants while Go, Node, and
|
||||
# most HTTP libraries read the uppercase ones.
|
||||
ARG HTTP_PROXY=
|
||||
ARG HTTPS_PROXY=
|
||||
ARG NO_PROXY=
|
||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
HTTPS_PROXY=${HTTPS_PROXY} \
|
||||
NO_PROXY=${NO_PROXY} \
|
||||
http_proxy=${HTTP_PROXY} \
|
||||
https_proxy=${HTTPS_PROXY} \
|
||||
no_proxy=${NO_PROXY}
|
||||
|
||||
WORKDIR /app/web
|
||||
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY web/ .
|
||||
RUN npm run build
|
||||
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-alpine AS builder
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — see Stage 1 rationale.
|
||||
ARG HTTP_PROXY=
|
||||
ARG HTTPS_PROXY=
|
||||
ARG NO_PROXY=
|
||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
HTTPS_PROXY=${HTTPS_PROXY} \
|
||||
NO_PROXY=${NO_PROXY} \
|
||||
http_proxy=${HTTP_PROXY} \
|
||||
https_proxy=${HTTPS_PROXY} \
|
||||
no_proxy=${NO_PROXY}
|
||||
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
@@ -50,7 +76,34 @@ 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 -f http://localhost:8443/health || exit 1
|
||||
CMD curl -fsk https://localhost:8443/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/server"]
|
||||
|
||||
+39
-1
@@ -2,6 +2,22 @@
|
||||
# Stage 1: Build
|
||||
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`/
|
||||
# `NO_PROXY` are forwarded via `docker build --build-arg` (or compose
|
||||
# `build.args`), they are re-exported as ENV with both upper- and lower-case
|
||||
# names because apk and curl read the lowercase variants while Go reads the
|
||||
# uppercase ones.
|
||||
ARG HTTP_PROXY=
|
||||
ARG HTTPS_PROXY=
|
||||
ARG NO_PROXY=
|
||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
HTTPS_PROXY=${HTTPS_PROXY} \
|
||||
NO_PROXY=${NO_PROXY} \
|
||||
http_proxy=${HTTP_PROXY} \
|
||||
https_proxy=${HTTPS_PROXY} \
|
||||
no_proxy=${NO_PROXY}
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
@@ -20,7 +36,14 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
# 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 addgroup -g 1000 certctl && \
|
||||
adduser -D -u 1000 -G certctl certctl
|
||||
@@ -35,4 +58,19 @@ 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"]
|
||||
|
||||
@@ -6,13 +6,20 @@ Licensor: Shankar Reddy
|
||||
Licensed Work: certctl
|
||||
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 Certificate
|
||||
Management Service. A "Certificate Management Service"
|
||||
is a commercial offering that allows third parties
|
||||
(other than your employees and contractors acting on
|
||||
your behalf) to access and/or use the Licensed Work's
|
||||
certificate lifecycle management functionality as part
|
||||
of a hosted or managed service.
|
||||
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, 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.
|
||||
|
||||
Change Date: March 14, 2033
|
||||
|
||||
|
||||
@@ -7,108 +7,130 @@
|
||||
|
||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||
|
||||
```mermaid
|
||||
timeline
|
||||
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
||||
2015 : 5 years
|
||||
2018 : 825 days
|
||||
2020 : 398 days
|
||||
March 2026 : 200 days
|
||||
March 2027 : 100 days
|
||||
March 2029 : 47 days
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||
[](https://github.com/shankar0123/certctl/releases)
|
||||
[](https://github.com/shankar0123/certctl/stargazers)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
**Ready to try it?** Jump to the [Quick Start](#quick-start) — you'll have a running dashboard in under 5 minutes.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Why certctl?](docs/why-certctl.md) | Competitive positioning — how certctl compares to open-source and enterprise certificate management platforms |
|
||||
| [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) | Get running in 5 minutes — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||
| [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 V2 capabilities, API endpoints, and configuration |
|
||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
||||
| [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/Let's Encrypt cron jobs |
|
||||
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
|
||||
| [certctl for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure |
|
||||
|
||||
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,471 Go tests + 193 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
|
||||
|
||||
## Why certctl Exists
|
||||
|
||||
Certificate lifecycle tooling today falls into two camps: expensive enterprise platforms (Venafi, Keyfactor, Sectigo) that cost six figures and take months to deploy, or single-purpose tools (cert-manager, certbot) that handle one slice of the problem. If you run a mixed infrastructure — some NGINX, some Apache, a few HAProxy nodes, maybe an F5 — and you need to manage certificates from multiple CAs, there's nothing self-hosted that covers the full lifecycle without vendor lock-in.
|
||||
|
||||
certctl fills that gap. It's **CA-agnostic** — the issuer connector interface means you can plug in any certificate authority: a self-signed local CA for dev, Let's Encrypt via ACME for public certs, Smallstep step-ca for your private PKI, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. You're never locked to a single CA vendor, and you can run multiple issuers simultaneously for different certificate types.
|
||||
|
||||
It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, and Caddy — all using the same pluggable connector model for any server that accepts cert files. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venafi, Keyfactor), see [Why certctl?](docs/why-certctl.md)
|
||||
|
||||
## What It Does
|
||||
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||
|
||||
- **Web dashboard** — 22 operational pages: certificate inventory, deployment timeline with TLS verification, bulk operations (renew/revoke/reassign), discovery triage, network scan management, approval workflows, audit trail with CSV/JSON export, agent fleet overview with OS/arch grouping, short-lived credential monitoring, digest email preview
|
||||
- **REST API** — 99 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters
|
||||
- **Agents** — generate private keys locally (ECDSA P-256), discover existing certs on disk (PEM/DER), submit CSRs only (private keys never leave your servers)
|
||||
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents, concurrent scanning with configurable timeouts
|
||||
- **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included
|
||||
- **S/MIME + EKU support** — issue certificates with emailProtection, codeSigning, timeStamping, clientAuth EKUs; email SAN routing for S/MIME
|
||||
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||
- **Post-deployment verification** — agent-side TLS probe confirms the target serves the correct certificate by SHA-256 fingerprint match
|
||||
- **Approval workflows** — require human sign-off on renewals before deployment
|
||||
- **Background scheduler** — 7 automated loops: renewal checks, job processing, agent health, notifications, short-lived cert expiry, network scanning, and scheduled certificate digest emails
|
||||
- **ACME Renewal Information (ARI, RFC 9702)** — CA-directed renewal timing; certctl asks the CA when to renew instead of using fixed thresholds
|
||||
- **Scheduled certificate digest emails** — HTML digest with certificate stats, expiration timeline, and job health; optional daily briefing via SMTP
|
||||
- **Helm chart** — Production-ready Kubernetes deployment with server, PostgreSQL, and agent DaemonSet
|
||||
|
||||
For the full capability breakdown — revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
|
||||
| [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 |
|
||||
|
||||
## Supported Integrations
|
||||
|
||||
### Certificate Issuers
|
||||
| Issuer | Status | Type |
|
||||
|--------|--------|------|
|
||||
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01 + DNS-PERSIST-01) | `ACME` |
|
||||
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
||||
| step-ca | Implemented | `StepCA` |
|
||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||
| Vault PKI | Future | — |
|
||||
| DigiCert | Future | — |
|
||||
|
||||
**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 today via the OpenSSL/Custom CA connector.
|
||||
| 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 | Status | Type |
|
||||
|--------|--------|------|
|
||||
| NGINX | Implemented | `NGINX` |
|
||||
| Apache httpd | Implemented | `Apache` |
|
||||
| HAProxy | Implemented | `HAProxy` |
|
||||
| Traefik | Implemented | `Traefik` |
|
||||
| Caddy | Implemented | `Caddy` |
|
||||
| F5 BIG-IP | Interface only | `F5` |
|
||||
| Microsoft IIS | Interface only | `IIS` |
|
||||
|
||||
| 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 | Status | Type |
|
||||
|----------|--------|------|
|
||||
| Email (SMTP) | Implemented | `Email` |
|
||||
| Webhooks | Implemented | `Webhook` |
|
||||
| Slack | Implemented | `Slack` |
|
||||
| Microsoft Teams | Implemented | `Teams` |
|
||||
| PagerDuty | Implemented | `PagerDuty` |
|
||||
| OpsGenie | Implemented | `OpsGenie` |
|
||||
|
||||
| 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).
|
||||
|
||||
@@ -116,43 +138,57 @@ All connectors are pluggable — build your own by implementing the [connector i
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-dashboard.png"><img src="docs/screenshots/v2-dashboard.png" width="270" alt="Dashboard"></a><br><b>Dashboard</b><br><sub>Stats, expiration heatmap, renewal trends</sub></td>
|
||||
<td><a href="docs/screenshots/v2-certificates.png"><img src="docs/screenshots/v2-certificates.png" width="270" alt="Certificates"></a><br><b>Certificates</b><br><sub>Inventory with status, owner, team filters</sub></td>
|
||||
<td><a href="docs/screenshots/v2-agents.png"><img src="docs/screenshots/v2-agents.png" width="270" alt="Agents"></a><br><b>Agents</b><br><sub>Fleet health, OS/arch, IP, version</sub></td>
|
||||
<td><a href="docs/screenshots/v2-dashboard.png"><img src="docs/screenshots/v2-dashboard.png" width="400" alt="Dashboard"></a><br><b>Dashboard</b><br><sub>Stats, expiration heatmap, renewal trends, issuance rate</sub></td>
|
||||
<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-fleet.png"><img src="docs/screenshots/v2-fleet.png" width="270" alt="Fleet Overview"></a><br><b>Fleet Overview</b><br><sub>OS distribution, status breakdown</sub></td>
|
||||
<td><a href="docs/screenshots/v2-jobs.png"><img src="docs/screenshots/v2-jobs.png" width="270" alt="Jobs"></a><br><b>Jobs</b><br><sub>Issuance, renewal, deployment queue</sub></td>
|
||||
<td><a href="docs/screenshots/v2-notifications.png"><img src="docs/screenshots/v2-notifications.png" width="270" alt="Notifications"></a><br><b>Notifications</b><br><sub>Expiration warnings, renewal results</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
|
||||
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
|
||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
|
||||
<td><a href="docs/screenshots/v2-owners.png"><img src="docs/screenshots/v2-owners.png" width="270" alt="Owners"></a><br><b>Owners</b><br><sub>Cert ownership with team assignment</sub></td>
|
||||
<td><a href="docs/screenshots/v2-teams.png"><img src="docs/screenshots/v2-teams.png" width="270" alt="Teams"></a><br><b>Teams</b><br><sub>Org grouping for notification routing</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-agent-groups.png"><img src="docs/screenshots/v2-agent-groups.png" width="270" alt="Agent Groups"></a><br><b>Agent Groups</b><br><sub>Dynamic grouping by OS, arch, CIDR</sub></td>
|
||||
<td><a href="docs/screenshots/v2-audit-trail.png"><img src="docs/screenshots/v2-audit-trail.png" width="270" alt="Audit Trail"></a><br><b>Audit Trail</b><br><sub>Immutable log, CSV/JSON export</sub></td>
|
||||
<td><a href="docs/screenshots/v2-short-lived.png"><img src="docs/screenshots/v2-short-lived.png" width="270" alt="Short-Lived"></a><br><b>Short-Lived Creds</b><br><sub>Ephemeral certs with live TTL countdown</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>
|
||||
|
||||
> **22 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (with approval workflow), notifications, policies, profiles, issuers, targets (wizard with NGINX/Apache/HAProxy/Traefik/Caddy/F5/IIS), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, and network scan management.
|
||||
**[See all screenshots →](docs/screenshots/)**
|
||||
|
||||
## Why certctl
|
||||
|
||||
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–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)
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
## What It Does
|
||||
|
||||
**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.
|
||||
|
||||
**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 Pull
|
||||
|
||||
```bash
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||
```
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
@@ -161,19 +197,23 @@ cd certctl
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
||||
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.
|
||||
|
||||
The dashboard comes pre-loaded with 35 demo certificates across 5 issuers, 8 agents, 90 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
|
||||
**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:
|
||||
|
||||
Verify the API:
|
||||
```bash
|
||||
curl http://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||
# 35
|
||||
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 (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)
|
||||
|
||||
```bash
|
||||
@@ -182,353 +222,94 @@ curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-a
|
||||
|
||||
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.
|
||||
|
||||
### Manual Build
|
||||
### Helm Chart (Kubernetes)
|
||||
|
||||
```bash
|
||||
# Prerequisites: Go 1.25+, PostgreSQL 16+, Docker (for testcontainers-go)
|
||||
go mod download
|
||||
make build
|
||||
|
||||
# Set up database
|
||||
export CERTCTL_DATABASE_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
|
||||
export CERTCTL_AUTH_TYPE=none
|
||||
make migrate-up
|
||||
|
||||
# Start server
|
||||
./bin/server
|
||||
|
||||
# Start agent (separate terminal)
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=change-me-in-production
|
||||
export CERTCTL_AGENT_NAME=local-agent
|
||||
export CERTCTL_AGENT_ID=agent-local-01
|
||||
./bin/agent --agent-id=agent-local-01
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--set server.apiKey=your-api-key \
|
||||
--set postgres.password=your-db-password
|
||||
```
|
||||
|
||||
## Architecture
|
||||
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.
|
||||
|
||||
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 7 loops: renewal checks (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 and data flow.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Private keys isolated from the control plane.** Agents generate ECDSA P-256 keys locally and submit CSRs (public key only). The server signs the CSR and returns the certificate — private keys never touch the control plane. Server-side keygen is available via `CERTCTL_KEYGEN_MODE=server` for demo/development only.
|
||||
- **TEXT primary keys, not UUIDs.** IDs are human-readable prefixed strings (`mc-api-prod`, `t-platform`, `o-alice`) so you can identify resource types at a glance in logs and queries.
|
||||
- **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons.
|
||||
- **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution.
|
||||
|
||||
PostgreSQL 16 with 21 tables covering certificates, versions, policies, issuers, targets, agents, jobs, teams, owners, profiles, agent groups, revocations, discovery, network scans, and audit events. See the [Architecture Guide](docs/architecture.md) for the full schema.
|
||||
|
||||
## Configuration
|
||||
|
||||
All environment variables use the `CERTCTL_` prefix. Full reference below (39 variables across server, agent, and connector config).
|
||||
|
||||
### Server — Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
||||
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port (1–65535) |
|
||||
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string (required) |
|
||||
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | PostgreSQL connection pool size (min 1) |
|
||||
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to migration SQL files |
|
||||
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max HTTP request body in bytes (default 1MB) |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` (structured) or `text` (human-readable) |
|
||||
|
||||
### Server — Auth, CORS, Rate Limiting
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` (demo only) |
|
||||
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
||||
| `CERTCTL_CORS_ORIGINS` | *(empty = deny all)* | Comma-separated allowed origins, or `*` for dev |
|
||||
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable token bucket rate limiting |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second per client |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Max burst size |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
|
||||
|
||||
### Server — Scheduler
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often to check expiring certs (min 1m) |
|
||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often to process pending jobs (min 1s) |
|
||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | Agent heartbeat check frequency (min 1s) |
|
||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | Notification send frequency (min 1s) |
|
||||
|
||||
### Server — Sub-CA Mode
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_CA_CERT_PATH` | — | PEM-encoded CA certificate for sub-CA mode |
|
||||
| `CERTCTL_CA_KEY_PATH` | — | PEM-encoded CA private key (RSA, ECDSA, PKCS#8) |
|
||||
|
||||
### Server — Feature Flags
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST enrollment endpoints |
|
||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||
| `CERTCTL_EST_PROFILE_ID` | — | Constrain EST to a specific certificate profile |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side TLS network scanning |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often scheduled scans run |
|
||||
| `CERTCTL_VERIFY_DEPLOYMENT` | `true` | TLS verification after certificate deployment |
|
||||
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||
| `CERTCTL_VERIFY_DELAY` | `2s` | Delay before verification probe |
|
||||
|
||||
### Server — Notification Connectors
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL (enables Slack) |
|
||||
| `CERTCTL_SLACK_CHANNEL` | — | Override default webhook channel |
|
||||
| `CERTCTL_SLACK_USERNAME` | `certctl` | Bot display name |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams webhook URL (enables Teams) |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 key (enables PagerDuty) |
|
||||
| `CERTCTL_PAGERDUTY_SEVERITY` | `warning` | Event severity: `info`, `warning`, `error`, `critical` |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key (enables OpsGenie) |
|
||||
| `CERTCTL_OPSGENIE_PRIORITY` | `P3` | Alert priority: `P1`–`P5` |
|
||||
|
||||
### Agent
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
||||
| `CERTCTL_API_KEY` | — | Agent API key for authentication |
|
||||
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory (0600 perms) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
|
||||
|
||||
Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
|
||||
|
||||
## Development
|
||||
### Docker Pull
|
||||
|
||||
```bash
|
||||
# Install dev tools (golangci-lint, migrate CLI, air)
|
||||
make install-tools
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run tests with race detection (same as CI)
|
||||
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/...
|
||||
|
||||
# Run with coverage
|
||||
make test-coverage
|
||||
|
||||
# Lint (runs golangci-lint with project config)
|
||||
make lint
|
||||
|
||||
# Vulnerability scan
|
||||
govulncheck ./...
|
||||
|
||||
# Format
|
||||
make fmt
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||
```
|
||||
|
||||
### CI Pipeline
|
||||
## Verifying this release
|
||||
|
||||
Every push and PR runs: `go vet`, `go test -race` (race detection), `golangci-lint` (11 linters including gosec and bodyclose), `govulncheck` (dependency CVE scanning), and per-layer coverage thresholds (service 60%, handler 60%, domain 40%, middleware 50%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build. See `.github/workflows/ci.yml` for details.
|
||||
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.
|
||||
|
||||
### Docker Compose
|
||||
All signatures use Cosign keyless OIDC; the signing identity is the
|
||||
release workflow running on a signed tag.
|
||||
|
||||
**1. Verify SHA-256 checksums:**
|
||||
|
||||
```bash
|
||||
make docker-up # Start stack (server + postgres + agent)
|
||||
make docker-down # Stop stack
|
||||
make docker-logs-server # Server logs
|
||||
make docker-logs-agent # Agent logs
|
||||
make docker-clean # Stop + remove volumes
|
||||
sha256sum -c checksums.txt
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Private Key Management
|
||||
- **Agent keygen mode (default)**: Agents generate ECDSA P-256 keys locally and store them with 0600 permissions in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`). Only the CSR (public key) is sent to the control plane. Private keys never leave agent infrastructure.
|
||||
- **Server keygen mode (demo only)**: Set `CERTCTL_KEYGEN_MODE=server` for development/demo with Local CA. The control plane generates RSA-2048 keys server-side. A log warning is emitted at startup.
|
||||
|
||||
### Authentication
|
||||
- Agent-to-server: API key (registered at agent creation)
|
||||
- API key and JWT auth types supported; `none` for demo/development
|
||||
- Auth type and secret configured via `CERTCTL_AUTH_TYPE` and `CERTCTL_AUTH_SECRET`
|
||||
|
||||
### CORS
|
||||
- **Deny-by-default**: Empty `CERTCTL_CORS_ORIGINS` blocks all cross-origin requests. Operators must explicitly list allowed origins (comma-separated) or set `*` for development.
|
||||
|
||||
### Input Validation
|
||||
- Shell command injection prevention on all connector scripts (strict character whitelist, no metacharacters)
|
||||
- RFC 1123 domain name validation, base64url ACME token validation
|
||||
- SSRF protection in network scanner (loopback, link-local, multicast, broadcast ranges filtered)
|
||||
|
||||
### Concurrency Safety
|
||||
- Scheduler loops protected by `sync/atomic.Bool` idempotency guards — duplicate ticks are skipped
|
||||
- Graceful shutdown waits up to 30 seconds for in-flight work before database close
|
||||
|
||||
### Audit Trail
|
||||
- Immutable append-only log in PostgreSQL (`audit_events` table)
|
||||
- Every lifecycle action attributed to an actor with timestamp and resource reference
|
||||
- No update or delete operations on audit records
|
||||
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency
|
||||
|
||||
## API Overview
|
||||
|
||||
99 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
|
||||
### Key Endpoints
|
||||
```
|
||||
# Certificate lifecycle
|
||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/certificates/{id}/export/pem Export PEM (JSON or file download)
|
||||
POST /api/v1/certificates/{id}/export/pkcs12 Export PKCS#12 bundle (no private key)
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
|
||||
# Agent operations
|
||||
POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
||||
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
||||
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
||||
|
||||
# Discovery & network scanning
|
||||
GET /api/v1/discovered-certificates List discovered certs (?agent_id, ?status)
|
||||
POST /api/v1/discovered-certificates/{id}/claim Link to managed cert
|
||||
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate TLS scan
|
||||
|
||||
# Jobs & approval
|
||||
POST /api/v1/jobs/{id}/approve Approve interactive renewal
|
||||
POST /api/v1/jobs/{id}/reject Reject interactive renewal
|
||||
|
||||
# Post-deployment verification
|
||||
POST /api/v1/jobs/{id}/verify Submit TLS verification result
|
||||
GET /api/v1/jobs/{id}/verification Get verification status
|
||||
|
||||
# Observability
|
||||
GET /api/v1/metrics/prometheus Prometheus exposition format
|
||||
GET /api/v1/stats/summary Dashboard summary
|
||||
|
||||
# Digest emails (scheduled briefing)
|
||||
GET /api/v1/digest/preview HTML email preview
|
||||
POST /api/v1/digest/send Send digest immediately
|
||||
|
||||
# EST enrollment (RFC 7030)
|
||||
POST /.well-known/est/simpleenroll Device certificate enrollment
|
||||
GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
|
||||
```
|
||||
|
||||
Full CRUD is available for certificates, agents, issuers, targets, teams, owners, policies, profiles, agent groups, notifications, and audit events. See the [OpenAPI spec](api/openapi.yaml) or [Feature Inventory](docs/features.md) for the complete endpoint reference.
|
||||
|
||||
## CLI
|
||||
**2. Verify the Cosign signature on `checksums.txt`:**
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/shankar0123/certctl/cmd/cli@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
|
||||
# Certificate commands
|
||||
certctl-cli certs list # List all certificates
|
||||
certctl-cli certs get mc-api-prod # Get certificate details
|
||||
certctl-cli certs renew mc-api-prod # Trigger renewal
|
||||
certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
||||
|
||||
# Agent and job commands
|
||||
certctl-cli agents list # List registered agents
|
||||
certctl-cli jobs list # List jobs
|
||||
certctl-cli jobs cancel job-123 # Cancel a pending job
|
||||
|
||||
# Operations
|
||||
certctl-cli status # Server health + summary stats
|
||||
certctl-cli import certs.pem # Bulk import from PEM file
|
||||
|
||||
# Output formats
|
||||
certctl-cli certs list --format json # JSON output (default: table)
|
||||
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
|
||||
```
|
||||
|
||||
## MCP Server (AI Integration)
|
||||
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.
|
||||
|
||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||
**3. Verify SLSA Level 3 provenance on a binary:**
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
|
||||
# Run (stdio transport — add to your AI client config)
|
||||
mcp-server
|
||||
slsa-verifier verify-artifact \
|
||||
--provenance-path multiple.intoto.jsonl \
|
||||
--source-uri github.com/shankar0123/certctl \
|
||||
--source-tag v2.1.0 \
|
||||
certctl-agent-linux-amd64
|
||||
```
|
||||
|
||||
**Claude Desktop** (`claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"certctl": {
|
||||
"command": "mcp-server",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
**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"
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0)
|
||||
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
|
||||
|
||||
30 milestones complete, 1500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
|
||||
**What shipped (all ✅):**
|
||||
|
||||
- **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing), ACME ARI (RFC 9702, CA-directed renewal timing)
|
||||
- **Revocation** — RFC 5280 reason codes, DER-encoded X.509 CRL, embedded OCSP responder, short-lived cert exemption
|
||||
- **Profiles + Ownership** — certificate profiles (key types, max TTL, crypto constraints), ownership tracking (owners + teams), dynamic agent groups, interactive renewal approval
|
||||
- **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
|
||||
- **Discovery** — filesystem scanning (PEM/DER) + network TLS scanning (CIDR ranges), triage workflow (claim/dismiss), network scan target management
|
||||
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
||||
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
|
||||
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
|
||||
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
|
||||
- **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
|
||||
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
|
||||
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
|
||||
|
||||
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match, verification status visible in deployment timeline
|
||||
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based), both in target wizard GUI
|
||||
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail, GUI export buttons
|
||||
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing, EKU badges in GUI
|
||||
- **ACME ARI (RFC 9702)** — CA-directed renewal timing: the CA tells certctl the optimal renewal window, gracefully degrading to fixed thresholds when ARI is unavailable
|
||||
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; configurable daily/hourly/weekly briefings via SMTP
|
||||
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress
|
||||
|
||||
**Coming in v2.1.0:**
|
||||
- Vault PKI issuer connector (HashiCorp Vault /sign API)
|
||||
- DigiCert CertCentral issuer connector (enterprise CA)
|
||||
- Dynamic issuer and target configuration via GUI (no env var restarts)
|
||||
- Issuer catalog page (see all supported CAs, configure from dashboard)
|
||||
- First-run onboarding wizard
|
||||
- Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer)
|
||||
- Migration guides (Certbot, acme.sh, cert-manager complement)
|
||||
- One-line agent install script with cross-compiled binaries
|
||||
|
||||
### V3: certctl Pro
|
||||
|
||||
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views.
|
||||
|
||||
### V4+: Cloud, Scale & Passive Discovery
|
||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Google CAS, EJBCA, Sectigo), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||
|
||||
## Examples
|
||||
|
||||
Turnkey Docker Compose configurations for common scenarios — pick the one 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 |
|
||||
|---------|----------|
|
||||
@@ -540,9 +321,91 @@ Turnkey Docker Compose configurations for common scenarios — pick the one clos
|
||||
|
||||
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
|
||||
|
||||
## CLI
|
||||
|
||||
```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 (11 linters)
|
||||
govulncheck ./... # Vulnerability scan
|
||||
make docker-up # Start Docker Compose stack
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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 offer certctl as a managed/hosted certificate management service to third parties. The BSL 1.1 license converts automatically to Apache 2.0 on March 1, 2033, providing perpetual freedom.
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
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).
|
||||
|
||||
+1696
-55
File diff suppressed because it is too large
Load Diff
+881
-20
File diff suppressed because it is too large
Load Diff
+319
-22
@@ -8,28 +8,38 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"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"
|
||||
@@ -38,15 +48,27 @@ import (
|
||||
|
||||
// AgentConfig represents the agent-side configuration.
|
||||
type AgentConfig struct {
|
||||
ServerURL string // Control plane server URL (e.g., http://localhost:8443)
|
||||
APIKey string // Agent API key for authentication
|
||||
AgentName string // Agent name for identification
|
||||
AgentID string // Agent ID for API calls (set after registration or from env)
|
||||
Hostname string // Server hostname
|
||||
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
|
||||
DiscoveryDirs []string // Directories to scan for certificates (comma-separated via env)
|
||||
ServerURL string // Control plane server URL (e.g., https://localhost:8443) — must be https:// scheme
|
||||
APIKey string // Agent API key for authentication
|
||||
AgentName string // Agent name for identification
|
||||
AgentID string // Agent ID for API calls (set after registration or from env)
|
||||
Hostname string // Server hostname
|
||||
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
|
||||
DiscoveryDirs []string // Directories to scan for certificates (comma-separated via env)
|
||||
CABundlePath string // Optional path to a PEM-encoded CA bundle that signed the server's cert (empty = system roots)
|
||||
InsecureSkipVerify bool // Dev-only: skip TLS certificate verification. Never enable in production. See docs/tls.md.
|
||||
}
|
||||
|
||||
// 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 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
|
||||
// the process. Do not wrap this error — main() matches it with errors.Is.
|
||||
var ErrAgentRetired = fmt.Errorf("agent retired by control plane")
|
||||
|
||||
// Agent represents the local agent that runs on target servers.
|
||||
// It periodically sends heartbeats, polls for work, executes deployment and CSR jobs,
|
||||
// and scans configured directories for existing certificates.
|
||||
@@ -62,6 +84,17 @@ type Agent struct {
|
||||
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
|
||||
// observes HTTP 410 Gone. The Run() select loop picks up the close and
|
||||
// returns ErrAgentRetired, unwinding the goroutine cleanly so main() can
|
||||
// log + exit(0). Using a channel + sync.Once (rather than an atomic bool
|
||||
// + polling) lets us fall through the select statement immediately instead
|
||||
// of waiting for the next ticker; the zero-allocation close is safe to
|
||||
// race with ctx.Done() and other cases.
|
||||
retiredOnce sync.Once
|
||||
retiredSignal chan struct{}
|
||||
}
|
||||
|
||||
// WorkResponse represents the response from the work polling endpoint.
|
||||
@@ -84,15 +117,78 @@ type JobItem struct {
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent instance.
|
||||
func NewAgent(cfg *AgentConfig, logger *slog.Logger) *Agent {
|
||||
//
|
||||
// The returned HTTP client enforces HTTPS-only control-plane access per the
|
||||
// HTTPS-Everywhere milestone (see docs/tls.md). TLS 1.3 is required; the
|
||||
// optional CABundlePath loads a PEM bundle into RootCAs so the agent can
|
||||
// trust internal / self-signed server certs without touching system trust
|
||||
// stores. InsecureSkipVerify is a dev-only escape hatch — callers must log a
|
||||
// loud warning when it's set; never enable in production (see §2.4 of the
|
||||
// milestone spec and docs/upgrade-to-tls.md).
|
||||
//
|
||||
// Returns an error if CABundlePath is set but unreadable or malformed — fail
|
||||
// loud at startup rather than silently fall back to system roots, which would
|
||||
// turn a misconfigured bundle path into a cryptic "x509: certificate signed
|
||||
// by unknown authority" on the first heartbeat.
|
||||
func NewAgent(cfg *AgentConfig, logger *slog.Logger) (*Agent, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // opt-in dev escape hatch, documented in docs/tls.md
|
||||
}
|
||||
if cfg.CABundlePath != "" {
|
||||
pemBytes, err := os.ReadFile(cfg.CABundlePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading CA bundle at %q: %w", cfg.CABundlePath, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pemBytes) {
|
||||
return nil, fmt.Errorf("CA bundle at %q contains no valid PEM-encoded certificates", cfg.CABundlePath)
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
client: httpClient,
|
||||
heartbeatInterval: 60 * time.Second,
|
||||
pollInterval: 30 * time.Second,
|
||||
discoveryInterval: 6 * time.Hour, // scan for certs every 6 hours
|
||||
}
|
||||
retiredSignal: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// markRetired records that the control plane has declared this agent retired
|
||||
// (HTTP 410 Gone on heartbeat or work poll). Idempotent via sync.Once — if
|
||||
// both the heartbeat and work-poll paths observe 410 in the same tick, only
|
||||
// the first close() runs and we avoid a runtime panic. Emits an ERROR-level
|
||||
// log line so init-system journaling captures it prominently, and includes
|
||||
// the source (heartbeat/work_poll), response body, and status code so the
|
||||
// operator can verify it's a genuine retirement signal rather than a
|
||||
// misrouted request. After this returns, the select-loop case in Run()
|
||||
// observes the closed channel on its next iteration and returns
|
||||
// ErrAgentRetired.
|
||||
func (a *Agent) markRetired(source string, statusCode int, body string) {
|
||||
a.retiredOnce.Do(func() {
|
||||
a.logger.Error("agent has been retired by control plane — shutting down",
|
||||
"source", source,
|
||||
"status", statusCode,
|
||||
"body", body,
|
||||
"agent_id", a.config.AgentID)
|
||||
close(a.retiredSignal)
|
||||
})
|
||||
}
|
||||
|
||||
// Run starts the agent's main loop.
|
||||
@@ -148,6 +244,19 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
a.logger.Info("agent shutting down", "reason", ctx.Err())
|
||||
return ctx.Err()
|
||||
|
||||
// I-004: retiredSignal is closed exactly once (via markRetired's
|
||||
// sync.Once) when either sendHeartbeat or pollForWork observes HTTP 410
|
||||
// Gone from the control plane. Falling through this case immediately
|
||||
// (rather than waiting for the next ticker) lets the agent shut down
|
||||
// quickly once retirement is confirmed — every extra heartbeat against a
|
||||
// retired row is wasted work and noise in the audit trail. Returning
|
||||
// ErrAgentRetired propagates up to main(), which matches it with
|
||||
// errors.Is and exits(0) so systemd/launchd do not respawn the process.
|
||||
case <-a.retiredSignal:
|
||||
a.logger.Info("agent retired signal received — exiting event loop",
|
||||
"agent_id", a.config.AgentID)
|
||||
return ErrAgentRetired
|
||||
|
||||
case <-heartbeatTicker.C:
|
||||
a.sendHeartbeat(ctx)
|
||||
|
||||
@@ -160,7 +269,14 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
a.logger.Warn("backing off due to consecutive failures",
|
||||
"failures", a.consecutiveFailures,
|
||||
"backoff", backoff.String())
|
||||
time.Sleep(backoff)
|
||||
// F-003: ctx-aware wait so graceful shutdown does not stall on
|
||||
// a long backoff. If ctx cancels mid-backoff, return to the
|
||||
// outer loop so the <-ctx.Done() case can trigger clean exit.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
continue
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
a.pollForWork(ctx)
|
||||
|
||||
@@ -203,6 +319,22 @@ func (a *Agent) sendHeartbeat(ctx context.Context) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// I-004: HTTP 410 Gone is the terminal signal from the control plane that
|
||||
// this agent's row has been soft-retired (see internal/api/handler/agent.go
|
||||
// heartbeat path + AgentRetirementService). Treat it separately from the
|
||||
// generic non-200 error branch: record the event to markRetired (which closes
|
||||
// retiredSignal exactly once via sync.Once) and return without bumping
|
||||
// consecutiveFailures — this is not a transient failure, it's a clean
|
||||
// shutdown. The Run() select loop picks up the closed channel on its next
|
||||
// iteration and returns ErrAgentRetired, which main() translates into an
|
||||
// exit(0) so systemd/launchd don't respawn the process into another 410
|
||||
// loop.
|
||||
if resp.StatusCode == http.StatusGone {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.markRetired("heartbeat", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("heartbeat rejected",
|
||||
@@ -231,6 +363,19 @@ func (a *Agent) pollForWork(ctx context.Context) {
|
||||
}
|
||||
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",
|
||||
@@ -583,7 +728,11 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
return nil, fmt.Errorf("invalid F5 config: %w", err)
|
||||
}
|
||||
}
|
||||
return f5.New(&cfg, a.logger), nil
|
||||
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
|
||||
@@ -592,7 +741,7 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
return nil, fmt.Errorf("invalid IIS config: %w", err)
|
||||
}
|
||||
}
|
||||
return iis.New(&cfg, a.logger), nil
|
||||
return iis.New(&cfg, a.logger)
|
||||
|
||||
case "Traefik":
|
||||
var cfg traefik.Config
|
||||
@@ -612,6 +761,71 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -956,12 +1170,14 @@ func certKeyInfo(cert *x509.Certificate) (string, int) {
|
||||
|
||||
func main() {
|
||||
// Parse command-line flags (with env var fallbacks for Docker deployment)
|
||||
serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "http://localhost:8443"), "Control plane server URL")
|
||||
serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "https://localhost:8443"), "Control plane server URL (must be https://)")
|
||||
apiKey := flag.String("api-key", getEnvDefault("CERTCTL_API_KEY", ""), "Agent API key")
|
||||
agentName := flag.String("name", getEnvDefault("CERTCTL_AGENT_NAME", "certctl-agent"), "Agent name")
|
||||
agentID := flag.String("agent-id", getEnvDefault("CERTCTL_AGENT_ID", ""), "Agent ID (from registration)")
|
||||
keyDir := flag.String("key-dir", getEnvDefault("CERTCTL_KEY_DIR", "/var/lib/certctl/keys"), "Directory for storing private keys")
|
||||
discoveryDirsStr := flag.String("discovery-dirs", getEnvDefault("CERTCTL_DISCOVERY_DIRS", ""), "Comma-separated directories to scan for certificates")
|
||||
caBundlePath := flag.String("ca-bundle", getEnvDefault("CERTCTL_SERVER_CA_BUNDLE_PATH", ""), "Path to a PEM-encoded CA bundle that signed the server's TLS cert (optional; falls back to system roots)")
|
||||
insecureSkipVerify := flag.Bool("insecure-skip-verify", getEnvBoolDefault("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY", false), "Dev-only: skip TLS certificate verification. Never enable in production. See docs/tls.md.")
|
||||
flag.Parse()
|
||||
|
||||
if *apiKey == "" {
|
||||
@@ -975,6 +1191,18 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Pre-flight URL-scheme validation — reject plaintext http:// before any
|
||||
// network call. The HTTPS-Everywhere milestone (§2.4, §7) mandates that
|
||||
// mis-configured agents fail loudly at startup with a diagnostic pointing
|
||||
// at the upgrade guide, rather than producing a TCP-refused or
|
||||
// TLS-handshake-error that obscures the actual cause.
|
||||
if err := validateHTTPSScheme(*serverURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
|
||||
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set up structured logging
|
||||
logLevel := slog.LevelInfo
|
||||
if getEnvDefault("CERTCTL_LOG_LEVEL", "info") == "debug" {
|
||||
@@ -1003,17 +1231,27 @@ func main() {
|
||||
|
||||
// Create agent configuration
|
||||
agentCfg := &AgentConfig{
|
||||
ServerURL: *serverURL,
|
||||
APIKey: *apiKey,
|
||||
AgentName: *agentName,
|
||||
AgentID: *agentID,
|
||||
Hostname: hostname,
|
||||
KeyDir: *keyDir,
|
||||
DiscoveryDirs: discoveryDirs,
|
||||
ServerURL: *serverURL,
|
||||
APIKey: *apiKey,
|
||||
AgentName: *agentName,
|
||||
AgentID: *agentID,
|
||||
Hostname: hostname,
|
||||
KeyDir: *keyDir,
|
||||
DiscoveryDirs: discoveryDirs,
|
||||
CABundlePath: *caBundlePath,
|
||||
InsecureSkipVerify: *insecureSkipVerify,
|
||||
}
|
||||
|
||||
if agentCfg.InsecureSkipVerify {
|
||||
logger.Warn("TLS certificate verification is disabled (CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true) — never enable this in production")
|
||||
}
|
||||
|
||||
// Create and start agent
|
||||
agent := NewAgent(agentCfg, logger)
|
||||
agent, err := NewAgent(agentCfg, logger)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to initialize agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create context with cancellation for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -1042,6 +1280,19 @@ func main() {
|
||||
cancel()
|
||||
<-errChan
|
||||
case err := <-errChan:
|
||||
// I-004: ErrAgentRetired is a terminal, *clean* shutdown — the control
|
||||
// plane responded HTTP 410 Gone on heartbeat/work-poll, meaning this
|
||||
// agent's row has been soft-retired and will never be reachable again.
|
||||
// Exit 0 so systemd's Restart=on-failure and launchd's KeepAlive do NOT
|
||||
// respawn the process into another 410 loop (which would wedge the host
|
||||
// and spam the control plane). Operators can observe the retirement via
|
||||
// audit_events or the AgentsPage retired tab; the terminal log line on
|
||||
// the way out is enough for post-mortem forensics.
|
||||
if errors.Is(err, ErrAgentRetired) {
|
||||
logger.Info("agent retired by control plane — exiting without restart",
|
||||
"agent_id", agentCfg.AgentID)
|
||||
return
|
||||
}
|
||||
if err != context.Canceled {
|
||||
logger.Error("agent error", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -1058,3 +1309,49 @@ func getEnvDefault(key, defaultValue string) string {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvBoolDefault parses an environment variable as a boolean. Accepts "1",
|
||||
// "t", "true", "T", "TRUE", "True" as true; anything else (including empty)
|
||||
// returns the provided default. Kept permissive on purpose so operators can
|
||||
// flip the dev-only TLS skip-verify toggle with any common truthy spelling
|
||||
// without having to remember exactly what we parse.
|
||||
func getEnvBoolDefault(key string, defaultValue bool) bool {
|
||||
raw := os.Getenv(key)
|
||||
if raw == "" {
|
||||
return defaultValue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "t", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "f", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// validateHTTPSScheme enforces the HTTPS-Everywhere milestone's §7 acceptance
|
||||
// criterion: "Agent with CERTCTL_SERVER_URL=http://... fails at startup with
|
||||
// a fail-loud diagnostic pointing at docs/upgrade-to-tls.md. Not TCP-refused,
|
||||
// not TLS-handshake-error — a pre-flight config validation failure before any
|
||||
// network call." Returns a descriptive error; the caller prints the upgrade
|
||||
// guide pointer and exits non-zero.
|
||||
func validateHTTPSScheme(serverURL string) error {
|
||||
if serverURL == "" {
|
||||
return fmt.Errorf("CERTCTL_SERVER_URL is empty — set it to an https:// URL (e.g., https://certctl-server:8443)")
|
||||
}
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CERTCTL_SERVER_URL %q is not a valid URL: %w", serverURL, err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
return fmt.Errorf("CERTCTL_SERVER_URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
|
||||
case "":
|
||||
return fmt.Errorf("CERTCTL_SERVER_URL %q is missing a scheme — expected https://", serverURL)
|
||||
default:
|
||||
return fmt.Errorf("CERTCTL_SERVER_URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
agent := NewAgent(cfg, nil)
|
||||
agent, _ := NewAgent(cfg, nil)
|
||||
|
||||
result := &VerificationResult{
|
||||
ExpectedFingerprint: "abc123",
|
||||
@@ -244,7 +244,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReportVerificationResult_MissingFields(t *testing.T) {
|
||||
agent := NewAgent(&AgentConfig{}, nil)
|
||||
agent, _ := NewAgent(&AgentConfig{}, nil)
|
||||
|
||||
result := &VerificationResult{
|
||||
Verified: true,
|
||||
@@ -343,7 +343,7 @@ func TestReportVerificationResult_ServerError(t *testing.T) {
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
agent := NewAgent(cfg, nil)
|
||||
agent, _ := NewAgent(cfg, nil)
|
||||
|
||||
result := &VerificationResult{
|
||||
ExpectedFingerprint: "abc123",
|
||||
|
||||
+86
-10
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/cli"
|
||||
)
|
||||
@@ -27,35 +29,50 @@ Commands:
|
||||
certs renew ID Trigger certificate renewal
|
||||
certs revoke ID Revoke a certificate
|
||||
|
||||
agents list List agents
|
||||
agents get ID Get agent details
|
||||
agents list List agents (add --retired to list soft-retired agents)
|
||||
agents get ID Get agent details
|
||||
agents retire ID Soft-retire an agent (add --force --reason "…" to cascade)
|
||||
|
||||
jobs list List jobs
|
||||
jobs get ID Get job details
|
||||
jobs cancel ID Cancel a pending job
|
||||
|
||||
import FILE Bulk import certificates from PEM file(s)
|
||||
Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id
|
||||
Optional: --name-template (default {cn}), --environment (default imported)
|
||||
|
||||
status Show server health + summary stats
|
||||
version Show CLI version
|
||||
|
||||
Examples:
|
||||
certctl-cli --server http://localhost:8443 --api-key mykey certs list
|
||||
certctl-cli --server https://localhost:8443 --api-key mykey certs list
|
||||
certctl-cli certs renew mc-prod --format json
|
||||
certctl-cli import certs.pem
|
||||
`)
|
||||
}
|
||||
|
||||
serverURL := fs.String("server", os.Getenv("CERTCTL_SERVER_URL"), "certctl server URL (env: CERTCTL_SERVER_URL)")
|
||||
if *serverURL == "" {
|
||||
*serverURL = "http://localhost:8443"
|
||||
// HTTPS-Everywhere (v2.2): the server is HTTPS-only. The default URL uses
|
||||
// https://; plaintext http:// is rejected by validateHTTPSScheme below.
|
||||
defaultServer := os.Getenv("CERTCTL_SERVER_URL")
|
||||
if defaultServer == "" {
|
||||
defaultServer = "https://localhost:8443"
|
||||
}
|
||||
serverURL := fs.String("server", defaultServer, "certctl server URL — must be https:// (env: CERTCTL_SERVER_URL)")
|
||||
|
||||
apiKey := fs.String("api-key", os.Getenv("CERTCTL_API_KEY"), "API key for authentication (env: CERTCTL_API_KEY)")
|
||||
format := fs.String("format", "table", "Output format: table, json")
|
||||
caBundlePath := fs.String("ca-bundle", os.Getenv("CERTCTL_SERVER_CA_BUNDLE_PATH"), "Path to a PEM-encoded CA bundle that signed the server cert (env: CERTCTL_SERVER_CA_BUNDLE_PATH)")
|
||||
insecure := fs.Bool("insecure", strings.EqualFold(os.Getenv("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY"), "true"), "Skip TLS certificate verification — dev only, never set in production (env: CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY)")
|
||||
|
||||
fs.Parse(os.Args[1:])
|
||||
|
||||
if err := validateHTTPSScheme(*serverURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
|
||||
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
args := fs.Args()
|
||||
if len(args) == 0 {
|
||||
fs.Usage()
|
||||
@@ -63,13 +80,16 @@ Examples:
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := cli.NewClient(*serverURL, *apiKey, *format)
|
||||
client, err := cli.NewClient(*serverURL, *apiKey, *format, *caBundlePath, *insecure)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Dispatch to appropriate command
|
||||
command := args[0]
|
||||
cmdArgs := args[1:]
|
||||
|
||||
var err error
|
||||
switch command {
|
||||
case "certs":
|
||||
err = handleCerts(client, cmdArgs)
|
||||
@@ -130,15 +150,27 @@ func handleCerts(client *cli.Client, args []string) error {
|
||||
reason = subArgs[2]
|
||||
}
|
||||
return client.RevokeCertificate(id, reason)
|
||||
case "bulk-revoke":
|
||||
return client.BulkRevokeCertificates(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: certs %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgents dispatches the `agents` subcommands.
|
||||
//
|
||||
// I-004 additions:
|
||||
//
|
||||
// agents list --retired — hit the opt-in /agents/retired endpoint
|
||||
// instead of the default listing (which
|
||||
// filters retired rows out).
|
||||
// agents retire <id> — soft-retire an agent (DELETE /agents/{id}).
|
||||
// --force cascades; --reason is required with
|
||||
// --force (mirrors ErrForceReasonRequired).
|
||||
func handleAgents(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents <list|get> [options]\n")
|
||||
fmt.Fprintf(os.Stderr, "usage: agents <list|get|retire> [options]\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,13 +179,34 @@ func handleAgents(client *cli.Client, args []string) error {
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
return client.ListAgents(subArgs)
|
||||
// --retired flag splits to a separate endpoint. We intercept it
|
||||
// client-side and strip it before delegating, so both code paths
|
||||
// share the --page/--per-page flag parsing inside the client.
|
||||
retired := false
|
||||
rest := make([]string, 0, len(subArgs))
|
||||
for _, a := range subArgs {
|
||||
if a == "--retired" {
|
||||
retired = true
|
||||
continue
|
||||
}
|
||||
rest = append(rest, a)
|
||||
}
|
||||
if retired {
|
||||
return client.ListRetiredAgents(rest)
|
||||
}
|
||||
return client.ListAgents(rest)
|
||||
case "get":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.GetAgent(subArgs[0])
|
||||
case "retire":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents retire <id> [--force] [--reason <reason>]\n")
|
||||
return nil
|
||||
}
|
||||
return client.RetireAgent(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: agents %s\n", subcommand)
|
||||
return nil
|
||||
@@ -201,3 +254,26 @@ func handleImport(client *cli.Client, args []string) error {
|
||||
func handleStatus(client *cli.Client) error {
|
||||
return client.GetStatus()
|
||||
}
|
||||
|
||||
// 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.
|
||||
func validateHTTPSScheme(serverURL string) error {
|
||||
if serverURL == "" {
|
||||
return fmt.Errorf("server URL is empty — set --server (or CERTCTL_SERVER_URL) to an https:// URL (e.g., https://certctl-server:8443)")
|
||||
}
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server URL %q is not a valid URL: %w", serverURL, err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
return fmt.Errorf("server URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
|
||||
case "":
|
||||
return fmt.Errorf("server URL %q is missing a scheme — expected https://", serverURL)
|
||||
default:
|
||||
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
|
||||
// HTTPS-Everywhere milestone (v2.2, §3.2) requires on the certctl-cli binary
|
||||
// startup path. The CLI's diagnostic is distinct from the agent and MCP server
|
||||
// because it surfaces the --server flag alongside CERTCTL_SERVER_URL — so the
|
||||
// empty-URL case pins that flag-name substring separately. Every other case
|
||||
// mirrors the dispatch arms in cmd/cli/main.go:validateHTTPSScheme; drifting
|
||||
// the substrings is what this test is here to catch.
|
||||
func TestValidateHTTPSScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverURL string
|
||||
wantErr bool
|
||||
wantErrSub string // substring that MUST appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "https URL passes",
|
||||
serverURL: "https://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https URL with path passes",
|
||||
serverURL: "https://certctl.example.com/api/v1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
|
||||
serverURL: "HTTPS://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty URL rejected mentions --server flag",
|
||||
serverURL: "",
|
||||
wantErr: true,
|
||||
wantErrSub: "--server",
|
||||
},
|
||||
{
|
||||
name: "empty URL rejected also mentions CERTCTL_SERVER_URL",
|
||||
serverURL: "",
|
||||
wantErr: true,
|
||||
wantErrSub: "CERTCTL_SERVER_URL",
|
||||
},
|
||||
{
|
||||
name: "plaintext http rejected",
|
||||
serverURL: "http://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "plaintext http://",
|
||||
},
|
||||
{
|
||||
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.
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "path-only URL rejected",
|
||||
serverURL: "//certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "missing a scheme",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme rejected",
|
||||
serverURL: "ftp://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "ws scheme rejected",
|
||||
serverURL: "ws://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateHTTPSScheme(tt.serverURL)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
|
||||
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
|
||||
tt.serverURL, err.Error(), tt.wantErrSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
@@ -16,14 +18,33 @@ import (
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
// HTTPS-Everywhere (v2.2): the server is HTTPS-only. The default URL
|
||||
// uses https://; plaintext http:// is rejected by validateHTTPSScheme
|
||||
// below with a fail-loud pre-flight diagnostic pointing at
|
||||
// docs/upgrade-to-tls.md, so operators never get a TCP-refused or
|
||||
// TLS-handshake-error downstream. See docs/tls.md for CA bundle and
|
||||
// insecure-skip-verify guidance.
|
||||
serverURL := os.Getenv("CERTCTL_SERVER_URL")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://localhost:8443"
|
||||
serverURL = "https://localhost:8443"
|
||||
}
|
||||
|
||||
if err := validateHTTPSScheme(serverURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
|
||||
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("CERTCTL_API_KEY")
|
||||
caBundlePath := os.Getenv("CERTCTL_SERVER_CA_BUNDLE_PATH")
|
||||
insecure := strings.EqualFold(os.Getenv("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY"), "true")
|
||||
|
||||
client := mcp.NewClient(serverURL, apiKey)
|
||||
client, err := mcp.NewClient(serverURL, apiKey, caBundlePath, insecure)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server := gomcp.NewServer(&gomcp.Implementation{
|
||||
Name: "certctl",
|
||||
@@ -41,3 +62,26 @@ func main() {
|
||||
log.Fatalf("MCP server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func validateHTTPSScheme(serverURL string) error {
|
||||
if serverURL == "" {
|
||||
return fmt.Errorf("server URL is empty — set CERTCTL_SERVER_URL to an https:// URL (e.g., https://certctl-server:8443)")
|
||||
}
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server URL %q is not a valid URL: %w", serverURL, err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
return fmt.Errorf("server URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
|
||||
case "":
|
||||
return fmt.Errorf("server URL %q is missing a scheme — expected https://", serverURL)
|
||||
default:
|
||||
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
|
||||
// HTTPS-Everywhere milestone (v2.2, §3.2) requires on the MCP server binary
|
||||
// startup path. The whole point is to fail loud with a diagnostic that points
|
||||
// at docs/upgrade-to-tls.md *before* any network call — not a cryptic
|
||||
// TCP-refused or TLS-handshake-error two ticks later. Every case here mirrors
|
||||
// the dispatch arms in cmd/mcp-server/main.go:validateHTTPSScheme; drifting
|
||||
// the error-message substrings is what this test is here to catch.
|
||||
func TestValidateHTTPSScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverURL string
|
||||
wantErr bool
|
||||
wantErrSub string // substring that MUST appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "https URL passes",
|
||||
serverURL: "https://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https URL with path passes",
|
||||
serverURL: "https://certctl.example.com/api/v1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
|
||||
serverURL: "HTTPS://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty URL rejected",
|
||||
serverURL: "",
|
||||
wantErr: true,
|
||||
wantErrSub: "server URL is empty",
|
||||
},
|
||||
{
|
||||
name: "plaintext http rejected",
|
||||
serverURL: "http://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "plaintext http://",
|
||||
},
|
||||
{
|
||||
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.
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "path-only URL rejected",
|
||||
serverURL: "//certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "missing a scheme",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme rejected",
|
||||
serverURL: "ftp://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "ws scheme rejected",
|
||||
serverURL: "ws://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateHTTPSScheme(tt.serverURL)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
|
||||
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
|
||||
tt.serverURL, err.Error(), tt.wantErrSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildFinalHandler_Dispatch is the M-001 regression harness for the outer
|
||||
// HTTP dispatch layer. It pins which path prefixes ride the no-auth middleware
|
||||
// chain (EST, SCEP, /.well-known/pki, health/ready, /api/v1/auth/info) versus
|
||||
// the authenticated chain (/api/v1/*).
|
||||
//
|
||||
// The concern under test is ONLY the dispatch in buildFinalHandler — the
|
||||
// handlers themselves are mocked as marker handlers that stamp "AUTH" or
|
||||
// "NOAUTH" into the response body. Service-layer concerns (SCEP password
|
||||
// validation, EST CSR validation, API auth enforcement) are covered by their
|
||||
// respective test suites.
|
||||
//
|
||||
// Case (i) is the central guard: EST with NO client cert / NO Bearer token
|
||||
// MUST reach the no-auth handler (pre-M-001 it was 401'd by the Auth
|
||||
// middleware, blocking enrollment for every real-world EST client).
|
||||
func TestBuildFinalHandler_Dispatch(t *testing.T) {
|
||||
// Marker handlers — each stamps a unique body so tests can verify which
|
||||
// chain the request traversed.
|
||||
authHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Chain", "auth")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("AUTH"))
|
||||
})
|
||||
noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Chain", "noauth")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("NOAUTH"))
|
||||
})
|
||||
|
||||
// Dashboard directory with index.html + assets/ for SPA fallback and
|
||||
// static-asset tests. Cleaned up by t.TempDir.
|
||||
webDir := t.TempDir()
|
||||
indexHTML := []byte("<!doctype html><html><body>certctl dashboard</body></html>")
|
||||
if err := os.WriteFile(filepath.Join(webDir, "index.html"), indexHTML, 0o644); err != nil {
|
||||
t.Fatalf("write index.html: %v", err)
|
||||
}
|
||||
assetsDir := filepath.Join(webDir, "assets")
|
||||
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir assets: %v", err)
|
||||
}
|
||||
assetJS := []byte("console.log('certctl');")
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), assetJS, 0o644); err != nil {
|
||||
t.Fatalf("write app.js: %v", err)
|
||||
}
|
||||
|
||||
handler := buildFinalHandler(authHandler, noAuthHandler, webDir, true /* dashboardEnabled */)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantBody string // "AUTH" | "NOAUTH" | "" (== substring match against response body)
|
||||
wantBodyPrefix string
|
||||
wantStatus int
|
||||
description string
|
||||
}{
|
||||
// ---- Case (i): M-001 central regression guard ----
|
||||
{
|
||||
name: "est_cacerts_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/est/cacerts",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "EST clients cannot present Bearer tokens — must NOT be 401'd before reaching the handler (RFC 7030 §4.1.1)",
|
||||
},
|
||||
{
|
||||
name: "est_simpleenroll_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodPost,
|
||||
path: "/.well-known/est/simpleenroll",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 7030 §4.2 simpleenroll served from no-auth chain (option D)",
|
||||
},
|
||||
{
|
||||
name: "est_simplereenroll_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodPost,
|
||||
path: "/.well-known/est/simplereenroll",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 7030 §4.2.2 simplereenroll also on no-auth chain",
|
||||
},
|
||||
{
|
||||
name: "est_csrattrs_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/est/csrattrs",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 7030 §4.5 csrattrs also on no-auth chain",
|
||||
},
|
||||
|
||||
// ---- Cases (ii) + (iii): SCEP dispatch ----
|
||||
// The actual challengePassword validation lives in the service layer
|
||||
// (internal/service/scep.go). This test pins that ALL /scep* requests
|
||||
// reach the no-auth chain — the service layer is then responsible for
|
||||
// rejecting or accepting based on password contents.
|
||||
{
|
||||
name: "scep_exact_path_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/scep",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "SCEP clients authenticate via CSR challengePassword, not Bearer (RFC 8894 §3.2)",
|
||||
},
|
||||
{
|
||||
name: "scep_subpath_reaches_noauth_handler",
|
||||
method: http.MethodPost,
|
||||
path: "/scep/",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Trailing-slash variant must also ride no-auth chain",
|
||||
},
|
||||
{
|
||||
name: "scep_query_string_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/scep?operation=GetCACaps",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Query string does not affect dispatch — operation dispatch is handler-internal",
|
||||
},
|
||||
// Defensive: /scepxyz MUST NOT match the SCEP prefix (guards against
|
||||
// over-broad matching that would leak non-SCEP paths into no-auth).
|
||||
{
|
||||
name: "scepxyz_does_not_match_scep_prefix",
|
||||
method: http.MethodGet,
|
||||
path: "/scepxyz",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "SPA fallback — /scepxyz must not be confused with /scep or /scep/",
|
||||
},
|
||||
|
||||
// ---- Case (iv): RFC 5280 CRL + RFC 6960 OCSP ----
|
||||
{
|
||||
name: "pki_crl_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/pki/crl/abc123",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 5280 CRL distribution point must be served without auth",
|
||||
},
|
||||
{
|
||||
name: "pki_ocsp_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/pki/ocsp/abc123/serial",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 6960 OCSP responder must be served without auth",
|
||||
},
|
||||
|
||||
// ---- Case (v): Authenticated API routes ----
|
||||
{
|
||||
name: "api_v1_certificates_goes_through_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/certificates",
|
||||
wantBody: "AUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Primary API surface must still require Bearer token",
|
||||
},
|
||||
{
|
||||
name: "api_v1_auth_check_goes_through_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/auth/check",
|
||||
wantBody: "AUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "auth/check validates the caller's Bearer — auth chain required",
|
||||
},
|
||||
{
|
||||
name: "api_v1_jobs_goes_through_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/jobs",
|
||||
wantBody: "AUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Jobs API is part of the privileged surface",
|
||||
},
|
||||
|
||||
// ---- Health probes bypass auth ----
|
||||
{
|
||||
name: "health_bypasses_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/health",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Docker/K8s health probes cannot carry Bearer tokens",
|
||||
},
|
||||
{
|
||||
name: "ready_bypasses_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/ready",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Readiness probe also unauthenticated",
|
||||
},
|
||||
{
|
||||
name: "auth_info_bypasses_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/auth/info",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "React app calls auth/info BEFORE login to discover auth mode",
|
||||
},
|
||||
|
||||
// ---- Static assets served by file server ----
|
||||
{
|
||||
name: "static_asset_served_by_file_server",
|
||||
method: http.MethodGet,
|
||||
path: "/assets/app.js",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "console.log('certctl');",
|
||||
description: "Built Vite assets served directly without auth",
|
||||
},
|
||||
|
||||
// ---- SPA fallback ----
|
||||
{
|
||||
name: "spa_fallback_serves_index_html",
|
||||
method: http.MethodGet,
|
||||
path: "/",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "Root path serves SPA entry point",
|
||||
},
|
||||
{
|
||||
name: "spa_fallback_for_unknown_route",
|
||||
method: http.MethodGet,
|
||||
path: "/certificates",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "React Router routes fall through to index.html",
|
||||
},
|
||||
{
|
||||
name: "spa_fallback_deep_route",
|
||||
method: http.MethodGet,
|
||||
path: "/certificates/mc-api-prod/detail",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "Deep React Router routes also fall through to SPA",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d (%s)", w.Code, tc.wantStatus, tc.description)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if tc.wantBody != "" && !strings.Contains(body, tc.wantBody) {
|
||||
t.Errorf("body %q does not contain %q (%s)", body, tc.wantBody, tc.description)
|
||||
}
|
||||
if tc.wantBodyPrefix != "" && !strings.HasPrefix(body, tc.wantBodyPrefix) {
|
||||
t.Errorf("body %q does not start with %q (%s)", body, tc.wantBodyPrefix, tc.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFinalHandler_NoDashboard pins the API-only (dashboard-absent)
|
||||
// dispatch behavior. When web/dist/index.html is missing, everything that's
|
||||
// not a no-auth bypass route falls through to the authenticated apiHandler
|
||||
// (pre-M-001 behavior for headless deployments). EST/SCEP/PKI still ride the
|
||||
// no-auth chain.
|
||||
func TestBuildFinalHandler_NoDashboard(t *testing.T) {
|
||||
authHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("AUTH"))
|
||||
})
|
||||
noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("NOAUTH"))
|
||||
})
|
||||
|
||||
handler := buildFinalHandler(authHandler, noAuthHandler, "/nonexistent", false /* dashboardEnabled */)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantBody string
|
||||
}{
|
||||
{"est_still_no_auth", "/.well-known/est/cacerts", "NOAUTH"},
|
||||
{"scep_still_no_auth", "/scep", "NOAUTH"},
|
||||
{"pki_still_no_auth", "/.well-known/pki/crl/x", "NOAUTH"},
|
||||
{"health_still_no_auth", "/health", "NOAUTH"},
|
||||
{"api_still_auth", "/api/v1/certificates", "AUTH"},
|
||||
// The difference: non-API, non-special paths go through auth chain when
|
||||
// there's no dashboard to serve (preserves legacy headless behavior).
|
||||
{"unknown_path_falls_through_to_auth", "/", "AUTH"},
|
||||
{"unknown_deep_path_falls_through_to_auth", "/random/path", "AUTH"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if got := w.Body.String(); !strings.Contains(got, tc.wantBody) {
|
||||
t.Errorf("body = %q, want to contain %q", got, tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+661
-140
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -16,16 +17,15 @@ import (
|
||||
"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/domain"
|
||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm"
|
||||
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv"
|
||||
discoverygcpsm "github.com/shankar0123/certctl/internal/connector/discovery/gcpsm"
|
||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
||||
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
|
||||
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository/postgres"
|
||||
"github.com/shankar0123/certctl/internal/scheduler"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
@@ -39,6 +39,26 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Defense-in-depth runtime guard for the auth-type discriminator.
|
||||
//
|
||||
// G-1 (P1): config.Load() already runs Validate() which rejects "jwt"
|
||||
// and any value outside config.ValidAuthTypes() with a dedicated
|
||||
// diagnostic. This switch is belt-and-braces — if a future refactor
|
||||
// bypasses the validator (test harness, alt config loader, env-var
|
||||
// rebinding after Load) the server must not silently boot with an
|
||||
// unsupported auth shape. The error path uses fmt.Fprintf because
|
||||
// the slog logger is constructed from cfg below this point; we want
|
||||
// the failure to be visible regardless of log-level configuration.
|
||||
switch config.AuthType(cfg.Auth.Type) {
|
||||
case config.AuthTypeAPIKey, config.AuthTypeNone:
|
||||
// ok — fall through
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"unsupported auth type at runtime: %q (valid: %v) — config validation should have caught this; refusing to start\n",
|
||||
cfg.Auth.Type, config.ValidAuthTypes())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set up structured logging
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: cfg.GetLogLevel(),
|
||||
@@ -66,6 +86,41 @@ func main() {
|
||||
}
|
||||
logger.Info("migrations completed")
|
||||
|
||||
// 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.
|
||||
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")
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Initialize repositories with real PostgreSQL connection
|
||||
auditRepo := postgres.NewAuditRepository(db)
|
||||
certificateRepo := postgres.NewCertificateRepository(db)
|
||||
@@ -81,71 +136,64 @@ func main() {
|
||||
ownerRepo := postgres.NewOwnerRepository(db)
|
||||
logger.Info("initialized all repositories")
|
||||
|
||||
// Initialize Local CA issuer connector.
|
||||
// In sub-CA mode (CERTCTL_CA_CERT_PATH + CERTCTL_CA_KEY_PATH set), loads a pre-signed
|
||||
// CA cert+key from disk. All issued certs chain to the upstream root (e.g., ADCS).
|
||||
// Otherwise, generates an ephemeral self-signed CA for development/demo.
|
||||
localCAConfig := &local.Config{}
|
||||
if cfg.CA.CertPath != "" && cfg.CA.KeyPath != "" {
|
||||
localCAConfig.CACertPath = cfg.CA.CertPath
|
||||
localCAConfig.CAKeyPath = cfg.CA.KeyPath
|
||||
logger.Info("Local CA configured in sub-CA mode",
|
||||
"cert_path", cfg.CA.CertPath,
|
||||
"key_path", cfg.CA.KeyPath)
|
||||
// Initialize dynamic issuer registry.
|
||||
// Issuers are loaded from the database (with AES-256-GCM encrypted config).
|
||||
// On first boot with an empty database, env var issuers are seeded automatically.
|
||||
//
|
||||
// M-8 (CWE-916 / CWE-329): the encryption passphrase is passed as a raw
|
||||
// string into IssuerService / TargetService / IssuerRegistry. Each call to
|
||||
// crypto.EncryptIfKeySet generates a fresh 16-byte PBKDF2 salt and emits a
|
||||
// v2 blob (magic 0x02 || salt || nonce || sealed). Decryption auto-detects
|
||||
// v1 legacy blobs (no magic) and falls back to the fixed v1 salt for
|
||||
// backward compatibility; v1 blobs transparently upgrade to v2 on next
|
||||
// write. DO NOT pre-derive the key here with crypto.DeriveKey — that was
|
||||
// the v1 fixed-salt behaviour that M-8 removes.
|
||||
encryptionKey := cfg.Encryption.ConfigEncryptionKey
|
||||
if encryptionKey != "" {
|
||||
logger.Info("config encryption enabled (AES-256-GCM, per-ciphertext PBKDF2 salt)")
|
||||
} else {
|
||||
logger.Info("Local CA configured in self-signed mode (ephemeral)")
|
||||
// C-2 fix: fail closed at startup when database-sourced issuer or target
|
||||
// rows exist without a configured encryption key. Previously the server
|
||||
// would emit a one-line warning and silently persist new GUI-created
|
||||
// configs as plaintext (CWE-311). Refuse to start instead: the operator
|
||||
// must either configure CERTCTL_CONFIG_ENCRYPTION_KEY or remove the
|
||||
// vulnerable rows before the control plane can boot.
|
||||
ctx := context.Background()
|
||||
dbIssuers, ierr := issuerRepo.List(ctx)
|
||||
if ierr != nil {
|
||||
logger.Error("startup check: failed to list issuers", "error", ierr)
|
||||
os.Exit(1)
|
||||
}
|
||||
dbTargets, terr := targetRepo.List(ctx)
|
||||
if terr != nil {
|
||||
logger.Error("startup check: failed to list targets", "error", terr)
|
||||
os.Exit(1)
|
||||
}
|
||||
var dbIssuerCount, dbTargetCount int
|
||||
for _, iss := range dbIssuers {
|
||||
if iss != nil && iss.Source == "database" {
|
||||
dbIssuerCount++
|
||||
}
|
||||
}
|
||||
for _, tgt := range dbTargets {
|
||||
if tgt != nil && tgt.Source == "database" {
|
||||
dbTargetCount++
|
||||
}
|
||||
}
|
||||
if dbIssuerCount > 0 || dbTargetCount > 0 {
|
||||
logger.Error(
|
||||
"startup refused: CERTCTL_CONFIG_ENCRYPTION_KEY is not set but database-sourced configs exist "+
|
||||
"(would expose sensitive fields as plaintext, CWE-311). "+
|
||||
"Set the encryption key or remove the affected rows before restarting.",
|
||||
"database_sourced_issuers", dbIssuerCount,
|
||||
"database_sourced_targets", dbTargetCount,
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Warn("CERTCTL_CONFIG_ENCRYPTION_KEY not set — env-seeded issuers will be stored in plaintext; GUI-created issuers and targets will be rejected until a key is configured")
|
||||
}
|
||||
localCA := local.New(localCAConfig, logger)
|
||||
logger.Info("initialized Local CA issuer connector")
|
||||
|
||||
// Initialize ACME issuer connector (for Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, etc.)
|
||||
// Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types.
|
||||
// EAB (External Account Binding) required by ZeroSSL, Google Trust Services, SSL.com.
|
||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||
EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"),
|
||||
EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"),
|
||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
||||
}, logger)
|
||||
logger.Info("initialized ACME issuer connector")
|
||||
|
||||
// Initialize step-ca issuer connector (for Smallstep private CA).
|
||||
// Uses the native /sign API with JWK provisioner authentication.
|
||||
stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{
|
||||
CAURL: os.Getenv("CERTCTL_STEPCA_URL"),
|
||||
ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"),
|
||||
ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"),
|
||||
ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"),
|
||||
}, logger)
|
||||
logger.Info("initialized step-ca issuer connector")
|
||||
|
||||
// Initialize OpenSSL/Custom CA issuer connector (for script-based CA integrations).
|
||||
// Delegates certificate signing to user-provided scripts.
|
||||
opensslConnector := opensslissuer.New(&opensslissuer.Config{
|
||||
SignScript: os.Getenv("CERTCTL_OPENSSL_SIGN_SCRIPT"),
|
||||
RevokeScript: os.Getenv("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
|
||||
CRLScript: os.Getenv("CERTCTL_OPENSSL_CRL_SCRIPT"),
|
||||
TimeoutSeconds: getEnvIntDefault(os.Getenv("CERTCTL_OPENSSL_TIMEOUT_SECONDS"), 30),
|
||||
}, logger)
|
||||
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
||||
|
||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
||||
// "iss-stepca" is the step-ca private CA connector.
|
||||
// "iss-openssl" is the custom CA/OpenSSL connector.
|
||||
issuerRegistry := map[string]service.IssuerConnector{
|
||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
||||
"iss-acme-staging": service.NewIssuerConnectorAdapter(acmeConnector),
|
||||
"iss-acme-prod": service.NewIssuerConnectorAdapter(acmeConnector),
|
||||
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
||||
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
||||
}
|
||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||
|
||||
// Initialize revocation repository
|
||||
revocationRepo := postgres.NewRevocationRepository(db)
|
||||
@@ -153,6 +201,12 @@ func main() {
|
||||
// Initialize services (following the dependency graph)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
|
||||
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
|
||||
// Drives /api/v1/renewal-policies CRUD; the service layer owns slugify + validation,
|
||||
// the repo layer owns sentinel translation for 23505 (name UNIQUE) and 23503
|
||||
// (FK-RESTRICT against managed_certificates.renewal_policy_id).
|
||||
renewalPolicyService := service.NewRenewalPolicyService(renewalPolicyRepo)
|
||||
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
|
||||
notifierRegistry := make(map[string]service.Notifier)
|
||||
|
||||
@@ -225,13 +279,26 @@ func main() {
|
||||
certificateService.SetRevocationSvc(revocationSvc)
|
||||
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||
certificateService.SetTargetRepo(targetRepo)
|
||||
certificateService.SetJobRepo(jobRepo)
|
||||
certificateService.SetKeygenMode(cfg.Keygen.Mode)
|
||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||
renewalService.SetTargetRepo(targetRepo)
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
jobService := service.NewJobService(jobRepo, certificateRepo, ownerRepo, renewalService, deploymentService, logger)
|
||||
// I-001: emit "job_retry" audit events when the scheduler resets Failed→Pending.
|
||||
// SetAuditService is optional — JobService falls back to nil-guarded no-op if unwired.
|
||||
jobService.SetAuditService(auditService)
|
||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
agentService.SetProfileRepo(profileRepo)
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||
targetService := service.NewTargetService(targetRepo, auditService)
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
|
||||
|
||||
// Seed issuers from env vars on first boot (empty database only), then build registry
|
||||
issuerService.SeedFromEnvVars(context.Background(), cfg)
|
||||
if err := issuerService.BuildRegistry(context.Background()); err != nil {
|
||||
logger.Error("failed to build issuer registry from database", "error", err)
|
||||
}
|
||||
logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len())
|
||||
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
|
||||
profileService := service.NewProfileService(profileRepo, auditService)
|
||||
teamService := service.NewTeamService(teamRepo, auditService)
|
||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||
@@ -251,16 +318,115 @@ func main() {
|
||||
Name: "Network Scanner (Server-Side)",
|
||||
Status: domain.AgentStatusOnline,
|
||||
}
|
||||
if err := agentRepo.Create(context.Background(), sentinelAgent); err != nil {
|
||||
// Ignore duplicate key errors (agent already exists)
|
||||
logger.Debug("sentinel agent creation", "status", "exists or created", "id", service.SentinelAgentID)
|
||||
// M-6: use CreateIfNotExists so duplicate rows on restart/upgrade are
|
||||
// idempotent without swallowing unrelated DB failures (CWE-662).
|
||||
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelAgent)
|
||||
if err != nil {
|
||||
logger.Error("sentinel agent creation failed", "id", service.SentinelAgentID, "error", err)
|
||||
} else if created {
|
||||
logger.Info("sentinel agent created", "id", service.SentinelAgentID)
|
||||
} else {
|
||||
logger.Debug("sentinel agent already exists", "id", service.SentinelAgentID)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize cloud discovery sources (M50)
|
||||
var cloudDiscoveryService *service.CloudDiscoveryService
|
||||
if cfg.CloudDiscovery.Enabled {
|
||||
cloudDiscoveryService = service.NewCloudDiscoveryService(discoveryService, logger)
|
||||
|
||||
// AWS Secrets Manager
|
||||
if cfg.CloudDiscovery.AWSSM.Enabled {
|
||||
awsSource := discoveryawssm.New(&cfg.CloudDiscovery.AWSSM, logger)
|
||||
cloudDiscoveryService.RegisterSource(awsSource)
|
||||
// Create sentinel agent for AWS SM
|
||||
sentinelAWS := &domain.Agent{
|
||||
ID: service.SentinelAWSSecretsMgr,
|
||||
Name: "AWS Secrets Manager Discovery",
|
||||
Status: domain.AgentStatusOnline,
|
||||
}
|
||||
// M-6: idempotent create (CWE-662).
|
||||
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelAWS)
|
||||
if err != nil {
|
||||
logger.Error("sentinel agent creation failed", "id", service.SentinelAWSSecretsMgr, "error", err)
|
||||
} else if created {
|
||||
logger.Info("sentinel agent created", "id", service.SentinelAWSSecretsMgr)
|
||||
} else {
|
||||
logger.Debug("sentinel agent already exists", "id", service.SentinelAWSSecretsMgr)
|
||||
}
|
||||
}
|
||||
|
||||
// Azure Key Vault
|
||||
if cfg.CloudDiscovery.AzureKV.Enabled {
|
||||
azureSource := discoveryazurekv.New(discoveryazurekv.Config{
|
||||
VaultURL: cfg.CloudDiscovery.AzureKV.VaultURL,
|
||||
TenantID: cfg.CloudDiscovery.AzureKV.TenantID,
|
||||
ClientID: cfg.CloudDiscovery.AzureKV.ClientID,
|
||||
ClientSecret: cfg.CloudDiscovery.AzureKV.ClientSecret,
|
||||
}, logger)
|
||||
cloudDiscoveryService.RegisterSource(azureSource)
|
||||
sentinelAzure := &domain.Agent{
|
||||
ID: service.SentinelAzureKeyVault,
|
||||
Name: "Azure Key Vault Discovery",
|
||||
Status: domain.AgentStatusOnline,
|
||||
}
|
||||
// M-6: idempotent create (CWE-662).
|
||||
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelAzure)
|
||||
if err != nil {
|
||||
logger.Error("sentinel agent creation failed", "id", service.SentinelAzureKeyVault, "error", err)
|
||||
} else if created {
|
||||
logger.Info("sentinel agent created", "id", service.SentinelAzureKeyVault)
|
||||
} else {
|
||||
logger.Debug("sentinel agent already exists", "id", service.SentinelAzureKeyVault)
|
||||
}
|
||||
}
|
||||
|
||||
// GCP Secret Manager
|
||||
if cfg.CloudDiscovery.GCPSM.Enabled {
|
||||
gcpSource := discoverygcpsm.New(&cfg.CloudDiscovery.GCPSM, logger)
|
||||
cloudDiscoveryService.RegisterSource(gcpSource)
|
||||
sentinelGCP := &domain.Agent{
|
||||
ID: service.SentinelGCPSecretMgr,
|
||||
Name: "GCP Secret Manager Discovery",
|
||||
Status: domain.AgentStatusOnline,
|
||||
}
|
||||
// M-6: idempotent create (CWE-662).
|
||||
created, err := agentRepo.CreateIfNotExists(context.Background(), sentinelGCP)
|
||||
if err != nil {
|
||||
logger.Error("sentinel agent creation failed", "id", service.SentinelGCPSecretMgr, "error", err)
|
||||
} else if created {
|
||||
logger.Info("sentinel agent created", "id", service.SentinelGCPSecretMgr)
|
||||
} else {
|
||||
logger.Debug("sentinel agent already exists", "id", service.SentinelGCPSecretMgr)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("cloud discovery enabled",
|
||||
"sources", cloudDiscoveryService.SourceCount(),
|
||||
"interval", cfg.CloudDiscovery.Interval.String())
|
||||
}
|
||||
|
||||
logger.Info("initialized all services")
|
||||
|
||||
// Initialize bulk revocation service
|
||||
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
|
||||
|
||||
// L-1 master (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a): bulk-renew
|
||||
// and bulk-reassign services. Mirror BulkRevocationService wiring so
|
||||
// the construction site is co-located with the existing bulk endpoint.
|
||||
// keygenMode is threaded so bulk-renew jobs land in the same initial
|
||||
// status (AwaitingCSR vs Pending) as single-cert TriggerRenewal.
|
||||
bulkRenewalService := service.NewBulkRenewalService(certificateRepo, jobRepo, auditService, logger, cfg.Keygen.Mode)
|
||||
bulkReassignmentService := service.NewBulkReassignmentService(certificateRepo, ownerRepo, auditService, logger)
|
||||
|
||||
// Initialize stats and metrics services
|
||||
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
|
||||
// I-005: wire the notification repository so DashboardSummary.NotificationsDead
|
||||
// is populated, which in turn drives the Prometheus counter
|
||||
// certctl_notification_dead_total in GetPrometheusMetrics. Setter
|
||||
// pattern keeps NewStatsService's nine call sites (main.go + stats_test.go
|
||||
// + 8 digest_test.go sites) untouched.
|
||||
statsService.SetNotifRepo(notificationRepo)
|
||||
logger.Info("initialized stats service")
|
||||
|
||||
// Initialize API handlers
|
||||
@@ -270,6 +436,10 @@ func main() {
|
||||
agentHandler := handler.NewAgentHandler(agentService)
|
||||
jobHandler := handler.NewJobHandler(jobService)
|
||||
policyHandler := handler.NewPolicyHandler(policyService)
|
||||
// G-1: RenewalPolicyHandler — /api/v1/renewal-policies CRUD. Value-returning
|
||||
// constructor matches the house pattern (PolicyHandler, IssuerHandler etc.);
|
||||
// the registry stores it by value in HandlerRegistry.RenewalPolicies.
|
||||
renewalPolicyHandler := handler.NewRenewalPolicyHandler(renewalPolicyService)
|
||||
profileHandler := handler.NewProfileHandler(profileService)
|
||||
teamHandler := handler.NewTeamHandler(teamService)
|
||||
ownerHandler := handler.NewOwnerHandler(ownerService)
|
||||
@@ -279,6 +449,13 @@ func main() {
|
||||
statsHandler := handler.NewStatsHandler(statsService)
|
||||
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
|
||||
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
||||
// U-3 ride-along (cat-u-no_version_endpoint, P2): the version handler
|
||||
// answers GET /api/v1/version with build identity (ldflags Version,
|
||||
// VCS commit/dirty/timestamp, Go runtime version). Wired through the
|
||||
// no-auth dispatch + audit ExcludePaths below so probes and rollout
|
||||
// systems can read it without Bearer credentials and without flooding
|
||||
// the audit trail.
|
||||
versionHandler := handler.NewVersionHandler()
|
||||
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
|
||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
||||
@@ -286,6 +463,13 @@ func main() {
|
||||
exportService := service.NewExportService(certificateRepo, auditService)
|
||||
exportHandler := handler.NewExportHandler(exportService)
|
||||
|
||||
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
|
||||
// L-1 master closure: handlers for the new bulk-renew + bulk-reassign
|
||||
// endpoints. Both registered via HandlerRegistry below; dispatched
|
||||
// through the standard authed middleware chain (no admin gate).
|
||||
bulkRenewalHandler := handler.NewBulkRenewalHandler(bulkRenewalService)
|
||||
bulkReassignmentHandler := handler.NewBulkReassignmentHandler(bulkReassignmentService)
|
||||
|
||||
// Initialize digest service (requires email notifier)
|
||||
var digestService *service.DigestService
|
||||
var digestHandler *handler.DigestHandler
|
||||
@@ -305,6 +489,29 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize health check service (M48)
|
||||
var healthCheckService *service.HealthCheckService
|
||||
var healthCheckHandler *handler.HealthCheckHandler
|
||||
if cfg.HealthCheck.Enabled {
|
||||
healthCheckRepo := postgres.NewHealthCheckRepository(db)
|
||||
healthCheckService = service.NewHealthCheckService(
|
||||
healthCheckRepo,
|
||||
auditService,
|
||||
logger,
|
||||
cfg.HealthCheck.MaxConcurrent,
|
||||
time.Duration(cfg.HealthCheck.DefaultTimeout)*time.Millisecond,
|
||||
cfg.HealthCheck.HistoryRetention,
|
||||
cfg.HealthCheck.AutoCreate,
|
||||
)
|
||||
healthCheckHandler = handler.NewHealthCheckHandler(healthCheckService)
|
||||
logger.Info("health check service enabled",
|
||||
"interval", cfg.HealthCheck.CheckInterval.String(),
|
||||
"max_concurrent", cfg.HealthCheck.MaxConcurrent)
|
||||
} else {
|
||||
// Create a no-op health check handler for route registration
|
||||
healthCheckHandler = handler.NewHealthCheckHandler(nil)
|
||||
}
|
||||
|
||||
logger.Info("initialized all handlers")
|
||||
|
||||
// Create context with cancellation
|
||||
@@ -324,8 +531,30 @@ func main() {
|
||||
// Configure scheduler intervals from config
|
||||
sched.SetRenewalCheckInterval(cfg.Scheduler.RenewalCheckInterval)
|
||||
sched.SetJobProcessorInterval(cfg.Scheduler.JobProcessorInterval)
|
||||
// I-001: drive the failed-job retry loop. Runs on start + every RetryInterval
|
||||
// (default 5m, CERTCTL_SCHEDULER_RETRY_INTERVAL). Kept adjacent to the job
|
||||
// processor setter because they share the JobServicer dependency.
|
||||
sched.SetJobRetryInterval(cfg.Scheduler.RetryInterval)
|
||||
sched.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval)
|
||||
sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval)
|
||||
// I-005: drive the failed-notification retry sweep. Runs every
|
||||
// NotificationRetryInterval (default 2m, CERTCTL_NOTIFICATION_RETRY_INTERVAL)
|
||||
// and transitions eligible Failed notifications whose next_retry_at has
|
||||
// arrived back to Pending so the notification processor picks them up on
|
||||
// its next tick. Kept adjacent to the notification processor setter
|
||||
// because they share the NotificationServicer dependency (same placement
|
||||
// pattern as I-001's SetJobRetryInterval above).
|
||||
sched.SetNotificationRetryInterval(cfg.Scheduler.NotificationRetryInterval)
|
||||
// C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388): pre-C-1
|
||||
// the SetShortLivedExpiryCheckInterval setter was defined + tested but
|
||||
// never called from main.go, so the 30-second hardcoded default in
|
||||
// scheduler.NewScheduler was effectively the only value. Operators
|
||||
// running short-lived cert workloads with high churn (or low-churn
|
||||
// workloads wanting to relax the cadence) had no working knob despite
|
||||
// CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL being documented. Wire it
|
||||
// here alongside the other scheduler-interval setters so the
|
||||
// documented env var actually takes effect.
|
||||
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
|
||||
if cfg.NetworkScan.Enabled {
|
||||
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
|
||||
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
|
||||
@@ -335,6 +564,28 @@ func main() {
|
||||
sched.SetDigestInterval(cfg.Digest.Interval)
|
||||
logger.Info("digest scheduler enabled", "interval", cfg.Digest.Interval.String())
|
||||
}
|
||||
if healthCheckService != nil {
|
||||
sched.SetHealthCheckService(healthCheckService)
|
||||
sched.SetHealthCheckInterval(cfg.HealthCheck.CheckInterval)
|
||||
logger.Info("health check scheduler enabled", "interval", cfg.HealthCheck.CheckInterval.String())
|
||||
}
|
||||
if cloudDiscoveryService != nil && cloudDiscoveryService.SourceCount() > 0 {
|
||||
sched.SetCloudDiscoveryService(cloudDiscoveryService)
|
||||
sched.SetCloudDiscoveryInterval(cfg.CloudDiscovery.Interval)
|
||||
logger.Info("cloud discovery scheduler enabled",
|
||||
"interval", cfg.CloudDiscovery.Interval.String(),
|
||||
"sources", cloudDiscoveryService.SourceCount())
|
||||
}
|
||||
|
||||
// Wire job timeout reaper (I-003)
|
||||
sched.SetJobReaperService(jobService)
|
||||
sched.SetJobTimeoutInterval(cfg.Scheduler.JobTimeoutInterval)
|
||||
sched.SetAwaitingCSRTimeout(cfg.Scheduler.AwaitingCSRTimeout)
|
||||
sched.SetAwaitingApprovalTimeout(cfg.Scheduler.AwaitingApprovalTimeout)
|
||||
logger.Info("job timeout reaper enabled",
|
||||
"interval", cfg.Scheduler.JobTimeoutInterval.String(),
|
||||
"csr_timeout", cfg.Scheduler.AwaitingCSRTimeout.String(),
|
||||
"approval_timeout", cfg.Scheduler.AwaitingApprovalTimeout.String())
|
||||
|
||||
// Start scheduler
|
||||
logger.Info("starting scheduler")
|
||||
@@ -345,35 +596,42 @@ func main() {
|
||||
// Build the API router with all handlers
|
||||
apiRouter := router.New()
|
||||
apiRouter.RegisterHandlers(router.HandlerRegistry{
|
||||
Certificates: certificateHandler,
|
||||
Issuers: issuerHandler,
|
||||
Targets: targetHandler,
|
||||
Agents: agentHandler,
|
||||
Jobs: jobHandler,
|
||||
Policies: policyHandler,
|
||||
Profiles: profileHandler,
|
||||
Teams: teamHandler,
|
||||
Owners: ownerHandler,
|
||||
AgentGroups: agentGroupHandler,
|
||||
Audit: auditHandler,
|
||||
Notifications: notificationHandler,
|
||||
Stats: statsHandler,
|
||||
Metrics: metricsHandler,
|
||||
Health: healthHandler,
|
||||
Discovery: discoveryHandler,
|
||||
NetworkScan: networkScanHandler,
|
||||
Verification: verificationHandler,
|
||||
Export: exportHandler,
|
||||
Digest: *digestHandler,
|
||||
Certificates: certificateHandler,
|
||||
Issuers: issuerHandler,
|
||||
Targets: targetHandler,
|
||||
Agents: agentHandler,
|
||||
Jobs: jobHandler,
|
||||
Policies: policyHandler,
|
||||
RenewalPolicies: renewalPolicyHandler,
|
||||
Profiles: profileHandler,
|
||||
Teams: teamHandler,
|
||||
Owners: ownerHandler,
|
||||
AgentGroups: agentGroupHandler,
|
||||
Audit: auditHandler,
|
||||
Notifications: notificationHandler,
|
||||
Stats: statsHandler,
|
||||
Metrics: metricsHandler,
|
||||
Health: healthHandler,
|
||||
Discovery: discoveryHandler,
|
||||
NetworkScan: networkScanHandler,
|
||||
Verification: verificationHandler,
|
||||
Export: exportHandler,
|
||||
Digest: *digestHandler,
|
||||
HealthChecks: healthCheckHandler,
|
||||
BulkRevocation: bulkRevocationHandler,
|
||||
BulkRenewal: bulkRenewalHandler,
|
||||
BulkReassignment: bulkReassignmentHandler,
|
||||
Version: versionHandler,
|
||||
})
|
||||
// Register EST (RFC 7030) handlers if enabled
|
||||
if cfg.EST.Enabled {
|
||||
issuerConn, ok := issuerRegistry[cfg.EST.IssuerID]
|
||||
issuerConn, ok := issuerRegistry.Get(cfg.EST.IssuerID)
|
||||
if !ok {
|
||||
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
||||
os.Exit(1)
|
||||
}
|
||||
estService := service.NewESTService(cfg.EST.IssuerID, issuerConn, auditService, logger)
|
||||
estService.SetProfileRepo(profileRepo)
|
||||
if cfg.EST.ProfileID != "" {
|
||||
estService.SetProfileID(cfg.EST.ProfileID)
|
||||
}
|
||||
@@ -385,13 +643,102 @@ func main() {
|
||||
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
|
||||
}
|
||||
|
||||
// Register SCEP (RFC 8894) handlers if enabled
|
||||
if cfg.SCEP.Enabled {
|
||||
// H-2 fix: fail closed at startup when SCEP is enabled without a
|
||||
// challenge password configured. Previously the service-layer guard
|
||||
// at internal/service/scep.go:72-79 skipped the password check when
|
||||
// s.challengePassword == "", meaning any client that could reach the
|
||||
// /scep endpoint could enroll an arbitrary CSR against the configured
|
||||
// issuer (CWE-306, missing authentication for a critical function).
|
||||
// Refuse to start instead: the operator must set
|
||||
// CERTCTL_SCEP_CHALLENGE_PASSWORD (or disable SCEP) before the control
|
||||
// plane can boot.
|
||||
if err := preflightSCEPChallengePassword(cfg.SCEP.Enabled, cfg.SCEP.ChallengePassword); err != nil {
|
||||
logger.Error(
|
||||
"startup refused: SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is not set "+
|
||||
"(would allow unauthenticated certificate enrollment, CWE-306). "+
|
||||
"Set a non-empty challenge password or disable SCEP before restarting.",
|
||||
"error", err,
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID)
|
||||
if !ok {
|
||||
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
|
||||
os.Exit(1)
|
||||
}
|
||||
scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword)
|
||||
scepService.SetProfileRepo(profileRepo)
|
||||
if cfg.SCEP.ProfileID != "" {
|
||||
scepService.SetProfileID(cfg.SCEP.ProfileID)
|
||||
}
|
||||
scepHandler := handler.NewSCEPHandler(scepService)
|
||||
apiRouter.RegisterSCEPHandlers(scepHandler)
|
||||
logger.Info("SCEP server enabled",
|
||||
"issuer_id", cfg.SCEP.IssuerID,
|
||||
"profile_id", cfg.SCEP.ProfileID,
|
||||
"challenge_password_set", cfg.SCEP.ChallengePassword != "",
|
||||
"endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}")
|
||||
}
|
||||
|
||||
// Register RFC 5280 CRL and RFC 6960 OCSP handlers under /.well-known/pki/.
|
||||
// These are always enabled (no config gate) — revocation data must be
|
||||
// reachable to relying parties for any cert certctl issues. The finalHandler
|
||||
// routing gate below strips auth middleware for this prefix so browsers,
|
||||
// OpenSSL, OCSP stapling sidecars, and mTLS clients can fetch without
|
||||
// presenting certctl Bearer tokens.
|
||||
apiRouter.RegisterPKIHandlers(certificateHandler)
|
||||
logger.Info("PKI endpoints registered",
|
||||
"endpoints", "/.well-known/pki/{crl/{issuer_id},ocsp/{issuer_id}/{serial}}")
|
||||
|
||||
logger.Info("registered all API handlers")
|
||||
|
||||
// Build middleware stack
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: cfg.Auth.Type,
|
||||
Secret: cfg.Auth.Secret,
|
||||
})
|
||||
// Build middleware stack.
|
||||
//
|
||||
// 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.
|
||||
var namedKeys []middleware.NamedAPIKey
|
||||
if config.AuthType(cfg.Auth.Type) != config.AuthTypeNone {
|
||||
// Translate typed config.NamedAPIKey -> middleware.NamedAPIKey. The
|
||||
// two structs are field-compatible but live in different packages to
|
||||
// preserve the config→middleware dependency direction.
|
||||
for _, nk := range cfg.Auth.NamedKeys {
|
||||
namedKeys = append(namedKeys, middleware.NamedAPIKey{
|
||||
Name: nk.Name,
|
||||
Key: nk.Key,
|
||||
Admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
// Back-compat: if no named keys but legacy Secret is configured,
|
||||
// synthesize named entries so the audit trail still attributes the
|
||||
// action (instead of falling back to "api-key-user" / "anonymous").
|
||||
if len(namedKeys) == 0 && cfg.Auth.Secret != "" {
|
||||
parts := strings.Split(cfg.Auth.Secret, ",")
|
||||
idx := 0
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
namedKeys = append(namedKeys, middleware.NamedAPIKey{
|
||||
Name: fmt.Sprintf("legacy-key-%d", idx),
|
||||
Key: p,
|
||||
Admin: false,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
if len(namedKeys) > 0 {
|
||||
logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating",
|
||||
"synthesized_keys", len(namedKeys))
|
||||
}
|
||||
}
|
||||
}
|
||||
authMiddleware := middleware.NewAuthWithNamedKeys(namedKeys)
|
||||
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
})
|
||||
@@ -404,6 +751,17 @@ func main() {
|
||||
})
|
||||
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
|
||||
|
||||
// Security headers middleware — applies HSTS, X-Frame-Options,
|
||||
// X-Content-Type-Options, Referrer-Policy, and a conservative CSP
|
||||
// on every response. H-1 closure (cat-s11-missing_security_headers):
|
||||
// pre-H-1 the server emitted zero security headers; an attacker
|
||||
// could clickjack the dashboard, sniff MIME types on JSON/PEM
|
||||
// responses, or load resources from arbitrary origins via inline
|
||||
// scripts. Defaults are conservative — see internal/api/middleware/
|
||||
// securityheaders.go::SecurityHeadersDefaults() for the rationale
|
||||
// per header.
|
||||
securityHeadersMiddleware := middleware.SecurityHeaders(middleware.SecurityHeadersDefaults())
|
||||
|
||||
// API audit log middleware — records every API call to the audit trail
|
||||
auditAdapter := middleware.NewAuditServiceAdapter(
|
||||
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
||||
@@ -411,19 +769,25 @@ func main() {
|
||||
},
|
||||
)
|
||||
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
|
||||
ExcludePaths: []string{"/health", "/ready"},
|
||||
// /api/v1/version is excluded for the same reason /health and /ready
|
||||
// are: rollout systems and blackbox probes hammer it on a tight
|
||||
// interval, and the audit trail's value comes from rare,
|
||||
// operator-authored mutations — not from sub-second readonly polls.
|
||||
// U-3 ride-along (cat-u-no_version_endpoint, P2).
|
||||
ExcludePaths: []string{"/health", "/ready", "/api/v1/version"},
|
||||
Logger: logger,
|
||||
})
|
||||
logger.Info("API audit logging enabled (excluding /health, /ready)")
|
||||
logger.Info("API audit logging enabled (excluding /health, /ready, /api/v1/version)")
|
||||
|
||||
middlewareStack := []func(http.Handler) http.Handler{
|
||||
middleware.RequestID,
|
||||
structuredLogger,
|
||||
middleware.Recovery,
|
||||
bodyLimitMiddleware,
|
||||
securityHeadersMiddleware,
|
||||
corsMiddleware,
|
||||
authMiddleware,
|
||||
auditMiddleware,
|
||||
auditMiddleware.Middleware,
|
||||
}
|
||||
|
||||
// Add rate limiter if enabled
|
||||
@@ -440,13 +804,13 @@ func main() {
|
||||
rateLimiter,
|
||||
corsMiddleware,
|
||||
authMiddleware,
|
||||
auditMiddleware,
|
||||
auditMiddleware.Middleware,
|
||||
}
|
||||
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
|
||||
}
|
||||
|
||||
if cfg.Auth.Type == "none" {
|
||||
logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production")
|
||||
if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone {
|
||||
logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production except behind an authenticating gateway (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium)")
|
||||
} else {
|
||||
logger.Info("authentication enabled", "type", cfg.Auth.Type)
|
||||
}
|
||||
@@ -467,47 +831,90 @@ func main() {
|
||||
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
||||
webDir = "./web"
|
||||
}
|
||||
// Health/ready routes + EST/SCEP/PKI unauth surface bypass the full
|
||||
// middleware stack (no auth required). These are registered on the
|
||||
// inner router without auth, but the outer middleware chain wraps
|
||||
// everything. Route them directly to the inner router.
|
||||
//
|
||||
// H-1 closure (cat-s5-4936a1cf0118): pre-H-1 the noAuthHandler chain
|
||||
// was RequestID → structuredLogger → Recovery only — missing
|
||||
// bodyLimitMiddleware that the authed apiHandler chain has. The
|
||||
// unauth surface includes EST simpleenroll/simplereenroll (RFC 7030),
|
||||
// SCEP, PKI CRL/OCSP (/.well-known/pki/*), and /health|/ready —
|
||||
// every one of which accepts a request body. Without a body-size
|
||||
// cap, an unauthenticated client can send arbitrary-size payloads
|
||||
// (CSRs, CRL/OCSP requests) and trigger memory pressure on the
|
||||
// server before the handler ever rejects the input. Post-H-1 the
|
||||
// same bodyLimitMiddleware that wraps the authed surface also wraps
|
||||
// the unauth surface — same default cap (CERTCTL_MAX_BODY_SIZE,
|
||||
// default 1MB), same 413 response on overflow.
|
||||
noAuthHandler := middleware.Chain(apiRouter,
|
||||
middleware.RequestID,
|
||||
structuredLogger,
|
||||
middleware.Recovery,
|
||||
bodyLimitMiddleware,
|
||||
securityHeadersMiddleware,
|
||||
)
|
||||
|
||||
dashboardEnabled := false
|
||||
if _, err := os.Stat(webDir + "/index.html"); err == nil {
|
||||
fileServer := http.FileServer(http.Dir(webDir))
|
||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
// API, health, and EST routes go to the API handler
|
||||
if path == "/health" || path == "/ready" ||
|
||||
(len(path) >= 8 && path[:8] == "/api/v1/") ||
|
||||
(len(path) >= 16 && path[:16] == "/.well-known/est") {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Try to serve static files (JS, CSS, assets)
|
||||
if len(path) > 8 && path[:8] == "/assets/" {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SPA fallback: serve index.html for all other routes
|
||||
http.ServeFile(w, r, webDir+"/index.html")
|
||||
})
|
||||
dashboardEnabled = true
|
||||
}
|
||||
finalHandler = buildFinalHandler(apiHandler, noAuthHandler, webDir, dashboardEnabled)
|
||||
if dashboardEnabled {
|
||||
logger.Info("dashboard available at /", "web_dir", webDir)
|
||||
} else {
|
||||
finalHandler = apiHandler
|
||||
logger.Info("dashboard directory not found, serving API only")
|
||||
}
|
||||
|
||||
// HTTPS-everywhere milestone §2.1: fail-loud if the TLS configuration is
|
||||
// missing or malformed. Duplicates config.Validate() for defense in depth
|
||||
// (same pattern as preflightSCEPChallengePassword).
|
||||
if err := preflightServerTLS(cfg.Server.TLS.CertPath, cfg.Server.TLS.KeyPath); err != nil {
|
||||
logger.Error("startup refused: HTTPS cert unusable; control plane is HTTPS-only",
|
||||
"error", err,
|
||||
"cert_path", cfg.Server.TLS.CertPath,
|
||||
"key_path", cfg.Server.TLS.KeyPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load the cert+key into a SIGHUP-reloadable holder. Any subsequent
|
||||
// SIGHUP triggers a fresh read and atomic swap so rotations do not need
|
||||
// a restart. Reload failures keep the previous cert and log a warning.
|
||||
tlsCertHolder, err := newCertHolder(cfg.Server.TLS.CertPath, cfg.Server.TLS.KeyPath)
|
||||
if err != nil {
|
||||
logger.Error("startup refused: failed to load TLS cert holder",
|
||||
"error", err,
|
||||
"cert_path", cfg.Server.TLS.CertPath,
|
||||
"key_path", cfg.Server.TLS.KeyPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
stopTLSWatcher := tlsCertHolder.watchSIGHUP(logger)
|
||||
defer stopTLSWatcher()
|
||||
|
||||
// Server configuration
|
||||
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
TLSConfig: buildServerTLSConfig(tlsCertHolder),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server in background
|
||||
logger.Info("starting HTTP server", "address", addr)
|
||||
// Start HTTPS server in background. ListenAndServeTLS is called with
|
||||
// empty cert+key arguments because the cert is sourced through
|
||||
// TLSConfig.GetCertificate (the SIGHUP-reloadable holder). Passing file
|
||||
// paths here would pin the first-loaded cert and defeat hot reload.
|
||||
logger.Info("HTTPS server listening",
|
||||
"address", addr,
|
||||
"cert_path", cfg.Server.TLS.CertPath,
|
||||
"min_version", "TLS1.3")
|
||||
go func() {
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("HTTP server error", "error", err)
|
||||
if err := httpServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("HTTPS server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -530,9 +937,20 @@ func main() {
|
||||
logger.Warn("scheduler work did not complete in time", "error", err)
|
||||
}
|
||||
|
||||
logger.Info("shutting down HTTP server")
|
||||
logger.Info("shutting down HTTPS server")
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("HTTP server shutdown error", "error", err)
|
||||
logger.Error("HTTPS server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
// Drain in-flight audit-recording goroutines before closing the DB pool.
|
||||
// The audit middleware spawns one goroutine per non-excluded request; those
|
||||
// goroutines run detached from the request context and write to the
|
||||
// audit_events table via the same *sql.DB. Without this drain, SIGTERM
|
||||
// would close the DB pool while recordings were mid-flight, silently
|
||||
// dropping audit events (M-1, CWE-662 / CWE-400).
|
||||
logger.Info("flushing audit middleware in-flight recordings")
|
||||
if err := auditMiddleware.Flush(shutdownCtx); err != nil {
|
||||
logger.Warn("audit middleware flush did not complete in time", "error", err)
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
@@ -543,14 +961,117 @@ func main() {
|
||||
logger.Info("certctl server stopped")
|
||||
}
|
||||
|
||||
// getEnvIntDefault parses an integer from a string with a default fallback.
|
||||
func getEnvIntDefault(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
// 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
|
||||
}
|
||||
val, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
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 val
|
||||
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 + profile
|
||||
// policy, not HTTP Bearer. /.well-known/est/cacerts is explicitly
|
||||
// anonymous per RFC 7030 §4.1.1.
|
||||
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.
|
||||
if path == "/scep" || strings.HasPrefix(path, "/scep/") {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
// bypass auth middleware while protected API endpoints require auth.
|
||||
// This is the most critical test — it validates the core routing pattern used in main.go.
|
||||
func TestMain_HealthEndpointBypassesAuth(t *testing.T) {
|
||||
// Simulate the finalHandler logic from main.go with minimal setup
|
||||
// Create handler functions for health endpoints
|
||||
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
readyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ready"}`))
|
||||
})
|
||||
|
||||
authInfoHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"auth_type":"api-key"}`))
|
||||
})
|
||||
|
||||
// Protected API endpoint
|
||||
certHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`[]`))
|
||||
})
|
||||
|
||||
// Build the handler chain the same way main.go does
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-secret-key",
|
||||
})
|
||||
|
||||
// API handler with auth
|
||||
authHandler := middleware.Chain(certHandler,
|
||||
middleware.RequestID,
|
||||
middleware.Recovery,
|
||||
authMiddleware,
|
||||
)
|
||||
|
||||
// Create finalHandler matching main.go logic
|
||||
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
switch path {
|
||||
case "/health":
|
||||
healthHandler.ServeHTTP(w, r)
|
||||
case "/ready":
|
||||
readyHandler.ServeHTTP(w, r)
|
||||
case "/api/v1/auth/info":
|
||||
authInfoHandler.ServeHTTP(w, r)
|
||||
case "/api/v1/certificates":
|
||||
authHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
bypassesAuth bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "GET /health without auth",
|
||||
path: "/health",
|
||||
method: "GET",
|
||||
bypassesAuth: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GET /ready without auth",
|
||||
path: "/ready",
|
||||
method: "GET",
|
||||
bypassesAuth: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GET /api/v1/auth/info without auth",
|
||||
path: "/api/v1/auth/info",
|
||||
method: "GET",
|
||||
bypassesAuth: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GET /api/v1/certificates without auth (should fail)",
|
||||
path: "/api/v1/certificates",
|
||||
method: "GET",
|
||||
bypassesAuth: false,
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
finalHandler.ServeHTTP(w, req)
|
||||
|
||||
if tt.bypassesAuth && w.Code != tt.expectedStatus {
|
||||
t.Errorf("endpoint %s should bypass auth, got status %d, expected %d",
|
||||
tt.path, w.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
if !tt.bypassesAuth && w.Code != tt.expectedStatus {
|
||||
t.Logf("endpoint %s requires auth, got status %d, expected %d (auth middleware working)",
|
||||
tt.path, w.Code, tt.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_HealthHandlersRespond verifies health endpoints return correct responses.
|
||||
func TestMain_HealthHandlersRespond(t *testing.T) {
|
||||
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
healthHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if body := w.Body.String(); body != `{"status":"ok"}` {
|
||||
t.Errorf("expected body '{\"status\":\"ok\"}', got '%s'", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthMiddlewareRejectsUnauthorized verifies auth middleware works.
|
||||
func TestMain_AuthMiddlewareRejectsUnauthorized(t *testing.T) {
|
||||
// Create a protected endpoint
|
||||
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":"protected"}`))
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-secret-key",
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
// Request without auth should be rejected
|
||||
req := httptest.NewRequest("GET", "/api/v1/protected", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401 for unauthorized request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthMiddlewareAllowsWithValidKey verifies auth middleware allows valid keys.
|
||||
func TestMain_AuthMiddlewareAllowsWithValidKey(t *testing.T) {
|
||||
testKey := "test-secret-key"
|
||||
|
||||
// Create a protected endpoint
|
||||
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":"protected"}`))
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: testKey,
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
// Request with valid auth should be allowed
|
||||
req := httptest.NewRequest("GET", "/api/v1/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testKey)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200 for authorized request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ServerConfigFromEnvironment verifies config.Load() reads env vars correctly.
|
||||
func TestMain_ServerConfigFromEnvironment(t *testing.T) {
|
||||
// Save original env vars
|
||||
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
||||
oldServerHost := os.Getenv("CERTCTL_SERVER_HOST")
|
||||
oldServerPort := os.Getenv("CERTCTL_SERVER_PORT")
|
||||
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
defer func() {
|
||||
if oldAuthType != "" {
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AUTH_TYPE")
|
||||
}
|
||||
if oldServerHost != "" {
|
||||
os.Setenv("CERTCTL_SERVER_HOST", oldServerHost)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_HOST")
|
||||
}
|
||||
if oldServerPort != "" {
|
||||
os.Setenv("CERTCTL_SERVER_PORT", oldServerPort)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_PORT")
|
||||
}
|
||||
if oldTLSCert != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", oldTLSCert)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
}
|
||||
if oldTLSKey != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", oldTLSKey)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
}
|
||||
}()
|
||||
|
||||
// HTTPS-only control plane: Validate() refuses to pass without a readable
|
||||
// cert/key pair on disk. Materialize a throwaway ECDSA P-256 pair using the
|
||||
// same generator cmd/server/tls_test.go uses for the certHolder tests.
|
||||
dir := t.TempDir()
|
||||
certPath := dir + "/server.crt"
|
||||
keyPath := dir + "/server.key"
|
||||
generateTestCert(t, certPath, keyPath, "main-test-cn")
|
||||
|
||||
// Set test env vars
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", "none")
|
||||
os.Setenv("CERTCTL_SERVER_HOST", "127.0.0.1")
|
||||
os.Setenv("CERTCTL_SERVER_PORT", "8080")
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config from env vars: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Auth.Type != "none" {
|
||||
t.Errorf("Expected auth type 'none', got '%s'", cfg.Auth.Type)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Errorf("Expected server host '127.0.0.1', got '%s'", cfg.Server.Host)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Expected server port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthTypeConfiguration verifies auth type is read from config.
|
||||
func TestMain_AuthTypeConfiguration(t *testing.T) {
|
||||
// Save original env vars
|
||||
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
||||
oldAuthSecret := os.Getenv("CERTCTL_AUTH_SECRET")
|
||||
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
defer func() {
|
||||
if oldAuthType != "" {
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AUTH_TYPE")
|
||||
}
|
||||
if oldAuthSecret != "" {
|
||||
os.Setenv("CERTCTL_AUTH_SECRET", oldAuthSecret)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AUTH_SECRET")
|
||||
}
|
||||
if oldTLSCert != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", oldTLSCert)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
}
|
||||
if oldTLSKey != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", oldTLSKey)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
}
|
||||
}()
|
||||
|
||||
// HTTPS-only control plane: config.Load()→Validate() refuses to pass
|
||||
// without a readable cert/key pair. Mint one throwaway pair for the whole
|
||||
// sub-test cohort — auth type toggles don't care about the TLS surface.
|
||||
dir := t.TempDir()
|
||||
certPath := dir + "/server.crt"
|
||||
keyPath := dir + "/server.key"
|
||||
generateTestCert(t, certPath, keyPath, "main-test-cn")
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
|
||||
|
||||
// Set auth secret for api-key mode
|
||||
os.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||
|
||||
testCases := []string{"api-key", "none"}
|
||||
|
||||
for _, authType := range testCases {
|
||||
t.Run(fmt.Sprintf("auth_type_%s", authType), func(t *testing.T) {
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", authType)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Auth.Type != authType {
|
||||
t.Errorf("Expected auth type '%s', got '%s'", authType, cfg.Auth.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_MiddlewareChainConstruction tests that middleware can be properly chained.
|
||||
func TestMain_MiddlewareChainConstruction(t *testing.T) {
|
||||
// Test that the middleware.Chain function works as expected
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("success"))
|
||||
})
|
||||
|
||||
// Chain with RequestID and Recovery middleware
|
||||
chainedHandler := middleware.Chain(baseHandler,
|
||||
middleware.RequestID,
|
||||
middleware.Recovery,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if body := w.Body.String(); body != "success" {
|
||||
t.Errorf("expected body 'success', got '%s'", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RequestIDMiddleware verifies RequestID is added to responses.
|
||||
func TestMain_RequestIDMiddleware(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Wrap with RequestID middleware
|
||||
chainedHandler := middleware.Chain(baseHandler, middleware.RequestID)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// RequestID should be set in response header
|
||||
if rid := w.Header().Get("X-Request-ID"); rid == "" {
|
||||
t.Logf("X-Request-ID header not present (middleware may work differently)")
|
||||
} else {
|
||||
t.Logf("X-Request-ID header set: %s", rid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RecoveryMiddlewareHandlesPanic verifies recovery middleware works.
|
||||
func TestMain_RecoveryMiddlewareHandlesPanic(t *testing.T) {
|
||||
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("test panic")
|
||||
})
|
||||
|
||||
// Wrap with recovery middleware
|
||||
chainedHandler := middleware.Chain(panicHandler, middleware.Recovery)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Should not panic
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// Should return 500 error
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Logf("Expected 500 for panicked handler, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ServiceInitialization tests that services can be instantiated.
|
||||
// This validates the initialization pattern from main.go without needing a real DB.
|
||||
func TestMain_ServiceInitialization(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
// Create test issuer registry (same as main.go does)
|
||||
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||
|
||||
if issuerRegistry == nil {
|
||||
t.Fatal("issuer registry should not be nil")
|
||||
}
|
||||
|
||||
// Verify the registry has a Len() method (used in main.go)
|
||||
count := issuerRegistry.Len()
|
||||
if count < 0 {
|
||||
t.Errorf("issuer registry length should be >= 0, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_CORSMiddlewareSetHeaders verifies CORS headers are set.
|
||||
func TestMain_CORSMiddlewareSetHeaders(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(baseHandler, corsMiddleware)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// CORS middleware should set access control headers
|
||||
if acah := w.Header().Get("Access-Control-Allow-Origin"); acah == "" {
|
||||
t.Logf("Access-Control-Allow-Origin not set (may be by design)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthNoneMode verifies auth can be disabled.
|
||||
func TestMain_AuthNoneMode(t *testing.T) {
|
||||
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":"protected"}`))
|
||||
})
|
||||
|
||||
// Wrap with auth middleware in "none" mode
|
||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
||||
Type: "none",
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
// Request without auth should be allowed in "none" mode
|
||||
req := httptest.NewRequest("GET", "/api/v1/protected", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200 in 'none' auth mode, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RouterRegistration tests that router registration works.
|
||||
func TestMain_RouterRegistration(t *testing.T) {
|
||||
r := router.New()
|
||||
|
||||
// Register a test handler
|
||||
r.RegisterFunc("GET /test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test"))
|
||||
})
|
||||
|
||||
// Request the route
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Route should be registered and accessible
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Errorf("route not registered, got 404")
|
||||
} else if w.Code == http.StatusOK {
|
||||
t.Logf("route registered successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RateLimiterIntegration tests rate limiter middleware works.
|
||||
func TestMain_RateLimiterIntegration(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Create rate limiter with 10 RPS, 1 burst
|
||||
rateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
||||
RPS: 10,
|
||||
BurstSize: 1,
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(baseHandler, rateLimiter)
|
||||
|
||||
// First request should succeed
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code == http.StatusServiceUnavailable {
|
||||
t.Logf("rate limiter is active")
|
||||
} else {
|
||||
t.Logf("rate limiter allowed request (status %d)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ContentTypeMiddleware verifies content type is set correctly.
|
||||
func TestMain_ContentTypeMiddleware(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
// Wrap with middleware that sets Content-Type
|
||||
chainedHandler := middleware.Chain(baseHandler, middleware.ContentType)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// Verify response
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ContentType middleware should set header
|
||||
if ct := w.Header().Get("Content-Type"); ct != "" {
|
||||
t.Logf("Content-Type header set: %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ContextPropagation verifies context is propagated through middleware.
|
||||
func TestMain_ContextPropagation(t *testing.T) {
|
||||
type contextKey string
|
||||
testKey := contextKey("test-key")
|
||||
testValue := "test-value"
|
||||
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
val := r.Context().Value(testKey)
|
||||
if val == testValue {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(baseHandler, middleware.RequestID)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
// Add context value before request
|
||||
req = req.WithContext(context.WithValue(req.Context(), testKey, testValue))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Logf("Context value may not be propagated (status %d), this may be expected", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightSCEPChallengePassword is the H-2 regression guard for the
|
||||
// startup pre-flight check. The helper MUST return a non-nil error whenever
|
||||
// SCEP is enabled with an empty challenge password — that configuration
|
||||
// previously allowed unauthenticated certificate enrollment (CWE-306).
|
||||
// Disabled-SCEP and configured-password cases must pass cleanly.
|
||||
func TestPreflightSCEPChallengePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
challengePassword string
|
||||
wantErr bool
|
||||
wantErrSubstring string
|
||||
}{
|
||||
{
|
||||
name: "disabled_empty_password_ok",
|
||||
enabled: false,
|
||||
challengePassword: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "disabled_with_password_ok",
|
||||
enabled: false,
|
||||
challengePassword: "leftover-value",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "enabled_empty_password_rejected",
|
||||
enabled: true,
|
||||
challengePassword: "",
|
||||
wantErr: true,
|
||||
wantErrSubstring: "CERTCTL_SCEP_CHALLENGE_PASSWORD",
|
||||
},
|
||||
{
|
||||
name: "enabled_with_password_ok",
|
||||
enabled: true,
|
||||
challengePassword: "hunter2",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "enabled_single_char_password_ok",
|
||||
enabled: true,
|
||||
challengePassword: "x",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := preflightSCEPChallengePassword(tt.enabled, tt.challengePassword)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if tt.wantErrSubstring != "" && !strings.Contains(err.Error(), tt.wantErrSubstring) {
|
||||
t.Errorf("expected error to mention %q, got: %v", tt.wantErrSubstring, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CWE-306") {
|
||||
t.Errorf("expected error to cite CWE-306 for traceability, got: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// certHolder stores the server's TLS certificate under a mutex so it can be
|
||||
// swapped atomically by a SIGHUP handler without restarting the server. A
|
||||
// *tls.Config that wires GetCertificate → (*certHolder).GetCertificate reads
|
||||
// through the holder on every ClientHello, so a successful reload takes
|
||||
// effect on the next new connection immediately and without dropping
|
||||
// in-flight requests.
|
||||
//
|
||||
// Concurrency: GetCertificate is invoked from crypto/tls handshake goroutines
|
||||
// on every new inbound connection; Reload is invoked from the SIGHUP watcher
|
||||
// goroutine. sync.Mutex is sufficient — TLS handshakes are not an inner-loop
|
||||
// hot path and the critical section is a single pointer read.
|
||||
type certHolder struct {
|
||||
mu sync.Mutex
|
||||
cert *tls.Certificate
|
||||
certPath string
|
||||
keyPath string
|
||||
}
|
||||
|
||||
// newCertHolder loads the initial cert+key pair from disk and returns a
|
||||
// holder ready to serve handshakes. Returns a non-nil error if either file
|
||||
// is missing, unreadable, or the pair does not round-trip through
|
||||
// tls.LoadX509KeyPair (for example the key does not sign the cert). The
|
||||
// caller is expected to treat a non-nil error as a fail-loud startup gate
|
||||
// and os.Exit(1) — the HTTPS-everywhere milestone (§3 locked decisions)
|
||||
// prohibits plaintext HTTP fallback.
|
||||
func newCertHolder(certPath, keyPath string) (*certHolder, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load TLS cert/key (cert=%q key=%q): %w", certPath, keyPath, err)
|
||||
}
|
||||
return &certHolder{
|
||||
cert: &cert,
|
||||
certPath: certPath,
|
||||
keyPath: keyPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCertificate is the tls.Config.GetCertificate hook. Returns the current
|
||||
// cert under the holder's mutex. ClientHelloInfo is ignored — the control
|
||||
// plane does not multiplex by SNI.
|
||||
func (h *certHolder) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.cert, nil
|
||||
}
|
||||
|
||||
// Reload re-reads the cert+key pair from disk and swaps the holder
|
||||
// atomically on success. On failure the holder retains its previous cert
|
||||
// and the error is propagated to the caller — the SIGHUP watcher logs and
|
||||
// keeps serving the previous cert rather than crashing on a bad reload.
|
||||
// This is deliberately "fail-safe on reload, fail-loud on startup": an
|
||||
// operator rotating certs wants a recoverable error, not a restart loop.
|
||||
func (h *certHolder) Reload() error {
|
||||
cert, err := tls.LoadX509KeyPair(h.certPath, h.keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload TLS cert/key (cert=%q key=%q): %w", h.certPath, h.keyPath, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.cert = &cert
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchSIGHUP installs a signal handler that calls Reload() on each SIGHUP.
|
||||
// The returned stop function closes the internal done channel and stops
|
||||
// signal delivery so the goroutine can exit cleanly during shutdown. Errors
|
||||
// from Reload are logged but do not terminate the watcher — the operator
|
||||
// can fix the files and send another SIGHUP.
|
||||
//
|
||||
// Defensive design note: this deliberately does NOT panic on Reload error
|
||||
// even though HTTPS is mission-critical. A rotation that writes half-files
|
||||
// (operator overwrites cert.pem then key.pem as two separate copies) would
|
||||
// otherwise crash the server mid-rotation. Logging + retaining the old
|
||||
// cert gives the operator a bounded window to fix and re-SIGHUP.
|
||||
func (h *certHolder) watchSIGHUP(logger *slog.Logger) (stop func()) {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGHUP)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
if err := h.Reload(); err != nil {
|
||||
logger.Error("TLS cert reload failed; continuing with previous cert",
|
||||
"error", err,
|
||||
"cert_path", h.certPath,
|
||||
"key_path", h.keyPath)
|
||||
continue
|
||||
}
|
||||
logger.Info("TLS cert reloaded via SIGHUP",
|
||||
"cert_path", h.certPath,
|
||||
"key_path", h.keyPath)
|
||||
case <-done:
|
||||
signal.Stop(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
// buildServerTLSConfig returns the TLS 1.3-only *tls.Config for the HTTPS
|
||||
// server. Pinned per HTTPS-everywhere milestone §2.1 + §3 locked decisions:
|
||||
//
|
||||
// - MinVersion: TLS 1.3 (no TLS 1.2 escape hatch). Go 1.25's crypto/tls
|
||||
// automatically rejects older versions.
|
||||
// - CurvePreferences: explicit [X25519, P-256]. Explicit ordering keeps
|
||||
// the handshake deterministic and documents the accepted curves.
|
||||
// - No CipherSuites field: TLS 1.3 cipher suites are not negotiable in
|
||||
// the handshake (all three mandatory suites — AES-128-GCM-SHA256,
|
||||
// AES-256-GCM-SHA384, CHACHA20-POLY1305-SHA256 — are always offered).
|
||||
// Go's crypto/tls ignores CipherSuites for TLS 1.3.
|
||||
// - GetCertificate: reads through the holder so SIGHUP rotations take
|
||||
// effect on the next new connection without a restart. Setting
|
||||
// tls.Config.Certificates directly would pin the first-loaded cert
|
||||
// and defeat SIGHUP reload.
|
||||
func buildServerTLSConfig(holder *certHolder) *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
||||
GetCertificate: holder.GetCertificate,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// (HTTPS-everywhere §3 locked decisions: no plaintext HTTP fallback).
|
||||
//
|
||||
// Duplicates the emptiness + stat + parse checks in config.Validate() for
|
||||
// defense in depth, mirroring the pattern established by
|
||||
// preflightSCEPChallengePassword (which itself duplicates
|
||||
// config.Validate()'s SCEP check for CWE-306). Extracted into a separate
|
||||
// function so the gate is unit-testable without booting the full server.
|
||||
func preflightServerTLS(certPath, keyPath string) error {
|
||||
if certPath == "" {
|
||||
return fmt.Errorf("CERTCTL_SERVER_TLS_CERT_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
|
||||
}
|
||||
if keyPath == "" {
|
||||
return fmt.Errorf("CERTCTL_SERVER_TLS_KEY_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
|
||||
}
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
return fmt.Errorf("TLS cert file %q unreadable: %w (see docs/tls.md)", certPath, err)
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
return fmt.Errorf("TLS key file %q unreadable: %w (see docs/tls.md)", keyPath, err)
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(certPath, keyPath); err != nil {
|
||||
return fmt.Errorf("TLS cert/key pair invalid (cert=%q key=%q): %w (see docs/tls.md)", certPath, keyPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateTestCert writes a PEM-encoded self-signed leaf cert + ECDSA P-256
|
||||
// key pair to certPath/keyPath. The subject is derived from cn so tests can
|
||||
// tell reloaded certs apart from original certs by re-parsing the served
|
||||
// Certificate and comparing the CN.
|
||||
func generateTestCert(t *testing.T, certPath, keyPath, cn 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: cn},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalECPrivateKey: %v", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readCertCN returns the CommonName from the leaf cert currently held by the
|
||||
// holder, by exercising the same GetCertificate path the tls handshake would
|
||||
// take. Lets tests assert which generation of the cert is being served.
|
||||
func readCertCN(t *testing.T, h *certHolder) string {
|
||||
t.Helper()
|
||||
c, err := h.GetCertificate(&tls.ClientHelloInfo{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetCertificate: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(c.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
return leaf.Subject.CommonName
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func TestNewCertHolder_ValidPair_LoadsCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-initial")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
if got := readCertCN(t, h); got != "cn-initial" {
|
||||
t.Fatalf("CN mismatch: got %q want %q", got, "cn-initial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCertHolder_MissingFile_Fails(t *testing.T) {
|
||||
_, err := newCertHolder("/nonexistent/cert.pem", "/nonexistent/key.pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing files, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCertHolder_MalformedCert_Fails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "bad.crt")
|
||||
keyPath := filepath.Join(dir, "bad.key")
|
||||
if err := os.WriteFile(certPath, []byte("not a pem cert"), 0o600); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte("not a pem key"), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
_, err := newCertHolder(certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed PEM, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertHolder_Reload_SwapsCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-v1")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
if got := readCertCN(t, h); got != "cn-v1" {
|
||||
t.Fatalf("initial CN: got %q want cn-v1", got)
|
||||
}
|
||||
|
||||
// Rotate on disk and reload.
|
||||
generateTestCert(t, certPath, keyPath, "cn-v2")
|
||||
if err := h.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if got := readCertCN(t, h); got != "cn-v2" {
|
||||
t.Fatalf("post-reload CN: got %q want cn-v2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertHolder_Reload_FailureRetainsPreviousCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-v1")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the cert file and attempt reload.
|
||||
if err := os.WriteFile(certPath, []byte("garbage"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt cert: %v", err)
|
||||
}
|
||||
if err := h.Reload(); err == nil {
|
||||
t.Fatal("expected Reload error for corrupt file, got nil")
|
||||
}
|
||||
// Holder should still serve the v1 cert.
|
||||
if got := readCertCN(t, h); got != "cn-v1" {
|
||||
t.Fatalf("post-failed-reload CN: got %q want cn-v1 (reload must not clobber on failure)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertHolder_GetCertificate_Concurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-concurrent")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
|
||||
// 64 readers + 1 rotator for 500ms. Race detector catches any unsynchronized
|
||||
// swap of h.cert. Rotator writes fresh files + Reload, readers call
|
||||
// GetCertificate in a tight loop.
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
const readers = 64
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
if _, err := h.GetCertificate(&tls.ClientHelloInfo{}); err != nil {
|
||||
t.Errorf("GetCertificate: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
generateTestCert(t, certPath, keyPath, "cn-concurrent")
|
||||
_ = h.Reload()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCertHolder_WatchSIGHUP_ReloadsOnSignal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-before-sighup")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
stop := h.watchSIGHUP(silentLogger())
|
||||
defer stop()
|
||||
|
||||
// Rotate on disk, then fire SIGHUP to our own process and poll for the swap.
|
||||
generateTestCert(t, certPath, keyPath, "cn-after-sighup")
|
||||
if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil {
|
||||
t.Fatalf("SIGHUP: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if readCertCN(t, h) == "cn-after-sighup" {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("watcher did not reload cert within 2s (CN still %q)", readCertCN(t, h))
|
||||
}
|
||||
|
||||
func TestCertHolder_WatchSIGHUP_StopExits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-stop")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
stop := h.watchSIGHUP(silentLogger())
|
||||
|
||||
// Closing should be synchronous and safe; a subsequent SIGHUP must not
|
||||
// cause a reload (the watcher goroutine is gone).
|
||||
stop()
|
||||
time.Sleep(50 * time.Millisecond) // let goroutine exit
|
||||
|
||||
// After stop, the signal may still be delivered to the process but the
|
||||
// watcher has called signal.Stop so this channel is no longer receiving.
|
||||
// Simply assert that calling stop() twice does not panic — the goroutine
|
||||
// has already exited, so a second close would panic on the `done`
|
||||
// channel; we do NOT call stop twice. Instead verify no regression in
|
||||
// the held cert.
|
||||
if got := readCertCN(t, h); got != "cn-stop" {
|
||||
t.Fatalf("unexpected cert rotation after stop: got %q want cn-stop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerTLSConfig_IsTLS13Only(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-cfg")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
cfg := buildServerTLSConfig(h)
|
||||
if cfg.MinVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("MinVersion: got %#x want %#x (TLS 1.3)", cfg.MinVersion, tls.VersionTLS13)
|
||||
}
|
||||
wantCurves := []tls.CurveID{tls.X25519, tls.CurveP256}
|
||||
if len(cfg.CurvePreferences) != len(wantCurves) {
|
||||
t.Fatalf("CurvePreferences length: got %d want %d", len(cfg.CurvePreferences), len(wantCurves))
|
||||
}
|
||||
for i, c := range cfg.CurvePreferences {
|
||||
if c != wantCurves[i] {
|
||||
t.Fatalf("CurvePreferences[%d]: got %v want %v", i, c, wantCurves[i])
|
||||
}
|
||||
}
|
||||
if cfg.GetCertificate == nil {
|
||||
t.Fatal("GetCertificate: nil (holder not wired; SIGHUP reload would be broken)")
|
||||
}
|
||||
if len(cfg.Certificates) != 0 {
|
||||
t.Fatalf("Certificates: got %d want 0 (static cert would pin the first load and defeat reload)", len(cfg.Certificates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerTLSConfig_Handshake_TLS12Rejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-handshake")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
serverCfg := buildServerTLSConfig(h)
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("tls.Listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Server loop: accept and immediately close (we only care about the
|
||||
// handshake outcome).
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Force handshake so the server-side error surfaces.
|
||||
_ = conn.(*tls.Conn).Handshake()
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// TLS 1.3 client — should succeed.
|
||||
clientOK := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
c, err := tls.Dial("tcp", ln.Addr().String(), clientOK)
|
||||
if err != nil {
|
||||
t.Fatalf("TLS 1.3 dial failed (expected success): %v", err)
|
||||
}
|
||||
if c.ConnectionState().Version != tls.VersionTLS13 {
|
||||
t.Fatalf("negotiated version: got %#x want TLS 1.3 (%#x)", c.ConnectionState().Version, tls.VersionTLS13)
|
||||
}
|
||||
c.Close()
|
||||
|
||||
// TLS 1.2 client — must be rejected at handshake.
|
||||
clientOld := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
if _, err := tls.Dial("tcp", ln.Addr().String(), clientOld); err == nil {
|
||||
t.Fatal("TLS 1.2 dial succeeded; HTTPS-everywhere requires server to refuse TLS 1.2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_MissingCertPath(t *testing.T) {
|
||||
err := preflightServerTLS("", "/any/key.pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty cert path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_MissingKeyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-preflight")
|
||||
err := preflightServerTLS(certPath, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty key path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_CertFileNotReadable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
if err := os.WriteFile(keyPath, []byte("k"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := preflightServerTLS(filepath.Join(dir, "nope.crt"), keyPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreadable cert path, got nil")
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected os.ErrNotExist wrapped in error chain, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_InvalidKeyPair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
// Pair of valid cert + garbage key — files are readable but the pair
|
||||
// doesn't round-trip tls.LoadX509KeyPair.
|
||||
generateTestCert(t, certPath, keyPath, "cn-bad-pair")
|
||||
if err := os.WriteFile(keyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nBAD\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := preflightServerTLS(certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key pair, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_ValidPair_NoError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-ok")
|
||||
if err := preflightServerTLS(certPath, keyPath); err != nil {
|
||||
t.Fatalf("unexpected error for valid pair: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
# certctl Docker Compose Environments
|
||||
|
||||
This guide walks through every Docker Compose file in the `deploy/` directory. Each section explains what the environment does, when to use it, every service and environment variable, and the commands to run it. If you've never used Docker before, start with the [Prerequisites](#prerequisites) section. If you're experienced, skip to the environment you need.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [How Docker Compose Works (30-Second Version)](#how-docker-compose-works)
|
||||
3. [Base Environment (docker-compose.yml)](#base-environment)
|
||||
4. [Demo Overlay (docker-compose.demo.yml)](#demo-overlay)
|
||||
5. [Development Overlay (docker-compose.dev.yml)](#development-overlay)
|
||||
6. [Test Environment (docker-compose.test.yml)](#test-environment)
|
||||
7. [Environment Variable Reference](#environment-variable-reference)
|
||||
8. [Common Operations](#common-operations)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need two things: **Docker** (the container runtime) and **Docker Compose** (an orchestration tool that ships with Docker Desktop).
|
||||
|
||||
On macOS:
|
||||
```bash
|
||||
brew install --cask docker
|
||||
```
|
||||
|
||||
On Linux (Ubuntu/Debian):
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
```
|
||||
|
||||
Verify the install:
|
||||
```bash
|
||||
docker --version # Docker Engine 24+ recommended
|
||||
docker compose version # Docker Compose v2+ required (note: no hyphen)
|
||||
```
|
||||
|
||||
**What Docker actually does:** Docker packages an application and all its dependencies (OS libraries, runtimes, config files) into an isolated unit called a container. When you run `docker compose up`, Docker reads a YAML file that describes multiple containers, creates a private network between them, and starts everything in the right order. Each container sees only its own filesystem and network unless you explicitly share volumes or ports.
|
||||
|
||||
**Why this matters for certctl:** Instead of installing PostgreSQL, building Go binaries, configuring the agent, and wiring everything together by hand, one command gives you the complete platform. Each compose file targets a different use case.
|
||||
|
||||
---
|
||||
|
||||
## How Docker Compose Works
|
||||
|
||||
A compose file defines **services** (containers), **networks** (how they talk to each other), and **volumes** (persistent storage). The key concepts:
|
||||
|
||||
**Services** are named containers. `certctl-server` is the API and web dashboard. `postgres` is the database. `certctl-agent` polls the server for certificate work.
|
||||
|
||||
**Depends_on + healthchecks** control startup order. The server won't start until PostgreSQL reports healthy. The agent won't start until the server reports healthy. This prevents connection errors during boot.
|
||||
|
||||
**Volumes** persist data across restarts. `postgres_data` keeps your database between `docker compose down` and `docker compose up`. Adding `-v` to `down` deletes volumes for a clean slate.
|
||||
|
||||
**Overlay files** let you layer changes. Running `docker compose -f base.yml -f overlay.yml up` merges both files. The overlay can add services, change environment variables, or mount extra volumes without editing the base.
|
||||
|
||||
**Port mapping** (`"8443:8443"`) maps host port (left) to container port (right). After startup, `https://localhost:8443` on your machine reaches the certctl server inside its container (HTTPS-only as of v2.2; the `certctl-tls-init` init container bootstraps a self-signed cert into `deploy/test/certs/`).
|
||||
|
||||
---
|
||||
|
||||
## Base Environment
|
||||
|
||||
**File:** `docker-compose.yml`
|
||||
**When to use:** Production deployments, first-time setup, or any time you want a clean dashboard with the onboarding wizard.
|
||||
|
||||
### What it runs
|
||||
|
||||
Three services on a private bridge network:
|
||||
|
||||
| Service | Image | Purpose | Ports |
|
||||
|---------|-------|---------|-------|
|
||||
| `postgres` | `postgres:16-alpine` | Database. Stores certificates, agents, jobs, audit trail, policies, discovery results. | 5432 |
|
||||
| `certctl-server` | Built from `Dockerfile` | API server + web dashboard + background scheduler. | 8443 |
|
||||
| `certctl-agent` | Built from `Dockerfile.agent` | Polls server for work, generates keys, deploys certificates, discovers existing certs. | none |
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
`--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.
|
||||
|
||||
Wait about 30 seconds, then verify:
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml ps
|
||||
# All three services should show "Up (healthy)"
|
||||
|
||||
curl --cacert ./deploy/test/certs/ca.crt https://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
```
|
||||
|
||||
The control plane is HTTPS-only as of v2.2. The `certctl-tls-init` init container bootstraps a self-signed cert into `deploy/test/certs/` on first boot; pin it with `--cacert` (as above) or pass `-k` for one-off smoke tests (never in production).
|
||||
|
||||
Open **https://localhost:8443** in your browser. You'll see the onboarding wizard guiding you through: connecting a CA, deploying an agent, and adding your first certificate. Your browser will flag the self-signed cert as untrusted — accept the warning for local evaluation, or import `deploy/test/certs/ca.crt` into your OS trust store to make the warning go away.
|
||||
|
||||
### Service-by-service walkthrough
|
||||
|
||||
#### PostgreSQL
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-certctl}
|
||||
```
|
||||
|
||||
Alpine-based PostgreSQL 16. The `${POSTGRES_PASSWORD:-certctl}` syntax means: use the `POSTGRES_PASSWORD` environment variable from your shell if set, otherwise default to `certctl`. For production, create a `.env` file:
|
||||
|
||||
```bash
|
||||
echo 'POSTGRES_PASSWORD=your-secure-password-here' > deploy/.env
|
||||
```
|
||||
|
||||
The `volumes` section mounts 10 migration files into PostgreSQL's init directory (`/docker-entrypoint-initdb.d/`). PostgreSQL runs these SQL files in alphabetical order on first boot only. They create the schema (tables, indexes, constraints) and seed the base data (default issuer, default policy). If the `postgres_data` volume already exists with an initialized database, these scripts are skipped entirely.
|
||||
|
||||
**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
|
||||
certctl-server:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
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
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
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.
|
||||
|
||||
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: 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.
|
||||
|
||||
#### certctl Agent
|
||||
|
||||
```yaml
|
||||
certctl-agent:
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
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
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
```
|
||||
|
||||
The agent is a lightweight Go binary that polls the server for pending work (certificate deployments, CSR generation requests), executes that work locally, and reports results back. It also scans configured directories for existing certificates (filesystem discovery).
|
||||
|
||||
- `CERTCTL_SERVER_URL` uses the Docker internal hostname `certctl-server`. This resolves inside the Docker network only.
|
||||
- `CERTCTL_DISCOVERY_DIRS` tells the agent which directories to scan for existing certificates. The agent walks these directories recursively, parses PEM and DER files, and reports findings to the server for triage.
|
||||
- The `agent_keys` volume persists private keys generated by the agent across container restarts. Without this volume, keys would be lost when the container stops.
|
||||
|
||||
**Expert note:** The agent's healthcheck uses `pgrep` because the agent doesn't expose an HTTP endpoint. The `restart: unless-stopped` policy means Docker automatically restarts the agent on crashes but respects manual `docker compose stop` commands.
|
||||
|
||||
### Stopping and cleaning up
|
||||
|
||||
```bash
|
||||
# Stop containers but keep data
|
||||
docker compose -f deploy/docker-compose.yml down
|
||||
|
||||
# Stop and delete all data (database, keys, volumes)
|
||||
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 populated dashboard on first boot.
|
||||
|
||||
### What it adds
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `-f` flags are ordered: base first, overlay second. Docker merges them. The demo overlay adds the seed_demo.sql volume mount to the `postgres` service defined in the base file.
|
||||
|
||||
### What you see
|
||||
|
||||
The dashboard shows pre-populated charts: expiration heatmap with upcoming renewals, status distribution across Active/Expiring/Expired/Failed states, 30-day job trends, and issuance rates. The sidebar pages (Certificates, Agents, Discovery, Jobs, etc.) all have data to explore.
|
||||
|
||||
### Resetting demo data
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml down -v
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `down -v` deletes the `postgres_data` volume. On next boot, PostgreSQL re-runs all init scripts including the demo seed, giving you a clean starting point.
|
||||
|
||||
**Expert note:** The demo overlay is a pure data layer, not a configuration change. The server, agent, and their environment variables remain identical to the base. This means any behavior you see in the demo is exactly what the base environment produces once you populate data through normal operations.
|
||||
|
||||
---
|
||||
|
||||
## Development Overlay
|
||||
|
||||
**File:** `docker-compose.dev.yml`
|
||||
**When to use:** When you're contributing to certctl and need debug logging, database inspection, or a debugger attached to the server process.
|
||||
|
||||
### What it adds
|
||||
|
||||
| Addition | Purpose |
|
||||
|----------|---------|
|
||||
| Debug-level logging on server and agent | See every HTTP request, scheduler tick, and connector operation |
|
||||
| PgAdmin on port 5050 | Visual database browser for inspecting tables, running queries |
|
||||
| Delve debugger port 40000 | Attach a Go debugger to the running server process |
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Omit `-d` during development so you see logs streaming in your terminal.
|
||||
|
||||
### Using PgAdmin
|
||||
|
||||
Open **http://localhost:5050** in your browser. PgAdmin is pre-configured in desktop mode (no login required). To connect to the certctl database:
|
||||
|
||||
1. Right-click "Servers" in the left panel, choose "Register" > "Server"
|
||||
2. Name: `certctl`
|
||||
3. Connection tab: Host = `postgres`, Port = `5432`, Username = `certctl`, Password = `certctl` (or whatever you set in `.env`)
|
||||
|
||||
From there you can browse all 19 tables, inspect certificate records, view audit events, check the scheduler's job queue, and run arbitrary SQL.
|
||||
|
||||
### Using the Delve debugger
|
||||
|
||||
Port 40000 is exposed for remote debugging. To use it, you'd need to modify the Dockerfile to build with debug symbols and start the server under Delve:
|
||||
|
||||
```bash
|
||||
# In Dockerfile, replace the CMD with:
|
||||
CMD ["dlv", "--listen=:40000", "--headless=true", "--api-version=2", "exec", "/app/server"]
|
||||
```
|
||||
|
||||
Then attach from your IDE (VS Code, GoLand) using remote debug configuration pointing to `localhost:40000`.
|
||||
|
||||
### Hot reload
|
||||
|
||||
The dev overlay includes commented-out volume mounts for source code directories. Uncomment them and install [air](https://github.com/cosmtrek/air) to get automatic recompilation on file changes:
|
||||
|
||||
```bash
|
||||
go install github.com/cosmtrek/air@latest
|
||||
```
|
||||
|
||||
**Expert note:** The `builds: context: ..` in the dev overlay overrides the base service's image reference, forcing a local build from the repository root. This means changes to your Go source code are compiled fresh on each `docker compose up --build`.
|
||||
|
||||
---
|
||||
|
||||
## Test Environment
|
||||
|
||||
**File:** `docker-compose.test.yml`
|
||||
**When to use:** Integration testing against real CA backends. This is a standalone environment (not an overlay) with 7 containers on a static-IP subnet.
|
||||
|
||||
### What it runs
|
||||
|
||||
| Service | IP | Purpose |
|
||||
|---------|----|---------|
|
||||
| `postgres` | 10.30.50.2 | Database (clean, no demo data) |
|
||||
| `pebble-challtestsrv` | 10.30.50.3 | DNS/HTTP challenge test server for Pebble |
|
||||
| `pebble` | 10.30.50.4 | ACME test server (simulates Let's Encrypt) |
|
||||
| `step-ca` | 10.30.50.5 | Private CA (Smallstep, JWK provisioner) |
|
||||
| `certctl-server` | 10.30.50.6 | Control plane with all issuers configured |
|
||||
| `nginx` | 10.30.50.7 | TLS target server for deployment testing |
|
||||
| `certctl-agent` | 10.30.50.8 | Agent with NGINX volume + discovery |
|
||||
|
||||
### Why static IPs?
|
||||
|
||||
Pebble (the ACME test server) validates HTTP-01 challenges by connecting to the challenge URL. It resolves domain names via `pebble-challtestsrv`, which is configured to return `10.30.50.6` (the certctl server) for all lookups. Without static IPs, container IPs would be assigned randomly on each boot, breaking the challenge validation chain.
|
||||
|
||||
The `/24` subnet (10.30.50.0/24) provides 254 usable addresses, far more than needed but standard practice for test networks.
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.test.yml up --build
|
||||
```
|
||||
|
||||
Wait for all health checks to pass (about 60 seconds for step-ca's first-run bootstrap). Then:
|
||||
|
||||
```bash
|
||||
# Dashboard with auth enabled (HTTPS-only as of v2.2; browser will warn on the self-signed cert —
|
||||
# accept the warning or trust `deploy/test/certs/ca.crt` in your OS keychain)
|
||||
open https://localhost:8443
|
||||
# API key: test-key-2026
|
||||
|
||||
# NGINX serving a self-signed placeholder
|
||||
curl -k https://localhost:8444
|
||||
```
|
||||
|
||||
### What's different from the base
|
||||
|
||||
The test environment is configured for production-like behavior:
|
||||
|
||||
- **API key auth enabled** (`CERTCTL_AUTH_TYPE: api-key`, `CERTCTL_AUTH_SECRET: test-key-2026`). Every API request needs `Authorization: Bearer test-key-2026`.
|
||||
- **Agent-side key generation** (`CERTCTL_KEYGEN_MODE: agent`). The agent generates ECDSA P-256 keys locally and submits only the CSR to the server. Private keys never leave the agent container.
|
||||
- **Three real issuers configured:**
|
||||
- **Local CA** (self-signed) for instant issuance testing
|
||||
- **ACME via Pebble** for Let's Encrypt-compatible flow testing (HTTP-01 challenges validated through the challenge test server)
|
||||
- **step-ca** for private CA testing with JWK provisioner authentication
|
||||
- **EST server enabled** (`CERTCTL_EST_ENABLED: "true"`) for RFC 7030 enrollment testing
|
||||
- **Post-deployment verification enabled** (`CERTCTL_VERIFY_DEPLOYMENT: "true"`) so the agent probes NGINX after deploying a cert and confirms the TLS fingerprint matches
|
||||
- **Dynamic config encryption enabled** (`CERTCTL_CONFIG_ENCRYPTION_KEY`) so issuer/target configs added through the GUI are encrypted at rest
|
||||
- **TLS trust bootstrapping:** The server runs a `setup-trust.sh` entrypoint that fetches Pebble's root CA from its management API and copies step-ca's root cert from a shared volume, then runs `update-ca-certificates` before starting the server binary. This is necessary because both CAs use self-signed roots that aren't in Alpine's default trust store.
|
||||
|
||||
### Running the Go integration tests
|
||||
|
||||
The test environment is designed to support the Go integration test suite at `deploy/test/integration_test.go`:
|
||||
|
||||
```bash
|
||||
# Start the environment
|
||||
docker compose -f deploy/docker-compose.test.yml up --build -d
|
||||
|
||||
# Wait for health checks
|
||||
sleep 30
|
||||
|
||||
# Run integration tests (from repo root)
|
||||
go test -tags integration -v ./deploy/test/...
|
||||
```
|
||||
|
||||
The integration tests exercise 12 phases: health, agent heartbeat, Local CA issuance, ACME issuance, renewal, step-ca issuance, revocation + CRL + OCSP, EST enrollment, S/MIME issuance, discovery, network scan, and deployment verification. PostgreSQL port 5432 is exposed so the test binary can query the database directly for assertions.
|
||||
|
||||
See [docs/test-env.md](../docs/test-env.md) for the full walkthrough and manual QA procedures.
|
||||
|
||||
### Stopping and cleaning up
|
||||
|
||||
```bash
|
||||
# Stop but keep data (volumes persist)
|
||||
docker compose -f deploy/docker-compose.test.yml down
|
||||
|
||||
# Full reset (delete step-ca bootstrap, database, agent keys, NGINX certs)
|
||||
docker compose -f deploy/docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
**Expert note:** The step-ca container auto-bootstraps on first run: generates a root CA, creates a JWK provisioner named "admin" with password "password123", and writes everything to the `stepca_data` volume. Subsequent starts reuse this volume. If you `down -v`, the next boot generates a new root CA, which means all previously issued step-ca certs become untrusted.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
Every `CERTCTL_*` environment variable is read by the server's `internal/config/config.go` via `os.Getenv`. If the prefix is missing, the variable is silently ignored.
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_DATABASE_URL` | (required) | PostgreSQL connection string |
|
||||
| `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` 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 |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable network TLS scanning scheduler loop |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the network scanner runs |
|
||||
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max request body size in bytes (1MB) |
|
||||
| `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 |
|
||||
|
||||
### Agent
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `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` | (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) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | (none) | Comma-separated paths to scan for existing certs |
|
||||
|
||||
### Issuers (Server)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CERTCTL_ACME_DIRECTORY_URL` | ACME CA directory (e.g., Let's Encrypt, Pebble) |
|
||||
| `CERTCTL_ACME_EMAIL` | ACME account email |
|
||||
| `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_ACME_ARI_ENABLED` | Enable RFC 9773 Renewal Information |
|
||||
| `CERTCTL_ACME_PROFILE` | ACME profile (`tlsserver`, `shortlived`) |
|
||||
| `CERTCTL_STEPCA_URL` | step-ca server URL |
|
||||
| `CERTCTL_STEPCA_ROOT_CERT` | Path to step-ca root CA cert |
|
||||
| `CERTCTL_STEPCA_PROVISIONER` | Provisioner name |
|
||||
| `CERTCTL_STEPCA_PASSWORD` | Provisioner password |
|
||||
| `CERTCTL_STEPCA_KEY_PATH` | Path to provisioner key |
|
||||
| `CERTCTL_CA_CERT_PATH` / `CERTCTL_CA_KEY_PATH` | Sub-CA mode: load CA cert+key from disk |
|
||||
| `CERTCTL_VAULT_ADDR` | Vault server address |
|
||||
| `CERTCTL_VAULT_TOKEN` | Vault auth token |
|
||||
| `CERTCTL_VAULT_MOUNT` | PKI secrets engine mount (default: `pki`) |
|
||||
| `CERTCTL_VAULT_ROLE` | PKI role name |
|
||||
| `CERTCTL_DIGICERT_API_KEY` | DigiCert CertCentral API key |
|
||||
| `CERTCTL_DIGICERT_ORG_ID` | DigiCert organization ID |
|
||||
| `CERTCTL_SECTIGO_CUSTOMER_URI` / `_LOGIN` / `_PASSWORD` | Sectigo SCM auth |
|
||||
| `CERTCTL_GOOGLE_CAS_PROJECT` / `_LOCATION` / `_CA_POOL` / `_CREDENTIALS` | Google CAS config |
|
||||
|
||||
### EST Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST endpoints |
|
||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||
| `CERTCTL_EST_PROFILE_ID` | (none) | Optional profile constraint |
|
||||
|
||||
### Post-Deployment Verification
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_VERIFY_DEPLOYMENT` | `false` | Agent probes TLS after deploying |
|
||||
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||
| `CERTCTL_VERIFY_DELAY` | `2s` | Wait before probing (let service reload) |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CERTCTL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_FROM_ADDRESS` / `_USE_TLS` | SMTP email |
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` / `_CHANNEL` / `_USERNAME` | Slack notifications |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | Microsoft Teams |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` / `_SEVERITY` | PagerDuty alerts |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` / `_PRIORITY` | OpsGenie alerts |
|
||||
| `CERTCTL_DIGEST_ENABLED` / `_INTERVAL` / `_RECIPIENTS` | Scheduled digest email |
|
||||
|
||||
---
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Viewing logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f deploy/docker-compose.yml logs -f
|
||||
|
||||
# Single service
|
||||
docker compose -f deploy/docker-compose.yml logs -f certctl-server
|
||||
|
||||
# Last 100 lines
|
||||
docker compose -f deploy/docker-compose.yml logs --tail 100 certctl-server
|
||||
```
|
||||
|
||||
### Rebuilding after code changes
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Docker only rebuilds images that have changed source files. The `--build` flag is essential after editing Go code or frontend files.
|
||||
|
||||
### Connecting to the database directly
|
||||
|
||||
```bash
|
||||
docker exec -it certctl-postgres psql -U certctl -d certctl
|
||||
```
|
||||
|
||||
Useful queries:
|
||||
```sql
|
||||
-- Certificate inventory
|
||||
SELECT id, common_name, status, expires_at FROM managed_certificates ORDER BY expires_at;
|
||||
|
||||
-- Recent jobs
|
||||
SELECT id, type, status, certificate_id, created_at FROM jobs ORDER BY created_at DESC LIMIT 20;
|
||||
|
||||
-- Audit trail
|
||||
SELECT event_type, actor, resource_id, created_at FROM audit_events ORDER BY created_at DESC LIMIT 20;
|
||||
|
||||
-- Issuer configurations (encrypted_config is AES-256-GCM)
|
||||
SELECT id, type, source, enabled, test_status FROM issuers;
|
||||
```
|
||||
|
||||
### Checking container resource usage
|
||||
|
||||
```bash
|
||||
docker stats --no-stream
|
||||
```
|
||||
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Migrations are idempotent (`IF NOT EXISTS`), so upgrading to a version with new schema changes is safe. PostgreSQL only runs init scripts on first boot of a fresh volume, so new migrations in an upgrade require running them manually:
|
||||
|
||||
```bash
|
||||
docker exec -i certctl-postgres psql -U certctl -d certctl < migrations/000011_new_feature.up.sql
|
||||
```
|
||||
|
||||
Or, for a clean upgrade: `down -v` and `up --build` (loses existing data).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Demo mode: pre-populated dashboard with 32 certificates, 8 agents, 10 issuers, etc.
|
||||
# Use this to showcase certctl's dashboard with realistic data.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||
#
|
||||
# To start fresh (wipe previous data):
|
||||
# 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
|
||||
#
|
||||
# U-3 (P1, cat-u-seed_initdb_schema_drift): 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, so
|
||||
# the schema existed at initdb time. 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 demo overlay just sets CERTCTL_DEMO_SEED=true; the server
|
||||
# applies seed_demo.sql at boot via postgres.RunDemoSeed AFTER baseline
|
||||
# migrations + seed.sql are in place. Same single source of truth, no
|
||||
# initdb mounts, no schema-vs-seed drift.
|
||||
services:
|
||||
certctl-server:
|
||||
environment:
|
||||
CERTCTL_DEMO_SEED: "true"
|
||||
@@ -9,11 +9,21 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Node frontend stage and Go module
|
||||
# download can reach the public registries behind corporate proxies.
|
||||
# Defaults to empty; omit the variables from the host environment for
|
||||
# un-proxied builds and the behaviour is byte-identical to the pre-fix
|
||||
# tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
environment:
|
||||
# Verbose logging for development
|
||||
LOG_LEVEL: debug
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: "8443"
|
||||
volumes:
|
||||
# Mount local source for hot reload (requires air or similar)
|
||||
# Uncomment if using air or similar for hot reload:
|
||||
@@ -29,8 +39,17 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Go module download stage can reach
|
||||
# the public Go module proxy behind corporate proxies. Defaults to
|
||||
# empty; omit the variables from the host environment for un-proxied
|
||||
# builds and the behaviour is byte-identical to the pre-fix tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
|
||||
# PgAdmin for database exploration
|
||||
pgadmin:
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
# =============================================================================
|
||||
# certctl Testing Environment — Docker Compose
|
||||
# =============================================================================
|
||||
#
|
||||
# Spins up the full certctl platform with real CA backends for manual QA:
|
||||
#
|
||||
# 0. certctl-tls-init — one-shot init container; writes self-signed
|
||||
# server.crt/.key/ca.crt into ./test/certs (bind
|
||||
# mount, not a named volume — host-readable for
|
||||
# the Go integration test binary)
|
||||
# 1. PostgreSQL 16 — database (clean, no demo data)
|
||||
# 2. certctl-server — control plane API + web dashboard on :8443 (HTTPS)
|
||||
# 3. certctl-agent — polls for work, deploys certs to NGINX
|
||||
# 4. step-ca — private CA (JWK provisioner, auto-bootstraps)
|
||||
# 5. Pebble — ACME test server (simulates Let's Encrypt)
|
||||
# 6. pebble-challtestsrv — DNS/HTTP challenge test server for Pebble
|
||||
# 7. NGINX — TLS target server on :8080 (HTTP) / :8444 (HTTPS)
|
||||
#
|
||||
# Usage:
|
||||
# cd deploy
|
||||
# docker compose -f docker-compose.test.yml up --build
|
||||
#
|
||||
# Dashboard: https://localhost:8443 (self-signed — use --cacert test/certs/ca.crt)
|
||||
# API key: test-key-2026
|
||||
# NGINX: https://localhost:8444 (self-signed placeholder until cert deployed)
|
||||
#
|
||||
# Integration tests: `go test -tags integration ./deploy/test/...` picks up
|
||||
# the CA bundle at ./test/certs/ca.crt automatically via CERTCTL_TEST_CA_BUNDLE.
|
||||
#
|
||||
# See docs/test-env.md for the full walkthrough.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTPS-Everywhere Phase 6 — self-signed TLS bootstrap for the test harness.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mirrors the production `certctl-tls-init` (see docker-compose.yml §10-43)
|
||||
# but writes into a *host bind mount* (./test/certs) instead of a named
|
||||
# volume. The named-volume approach works fine inside Docker but hides the
|
||||
# CA bundle from the Go integration test binary that runs on the host; the
|
||||
# bind mount exposes /etc/certctl/tls/ca.crt at deploy/test/certs/ca.crt
|
||||
# so `newTestClient()` can load it into an x509.CertPool and validate the
|
||||
# self-signed server cert. Test-only divergence, explicitly documented.
|
||||
#
|
||||
# The generated cert has SAN=DNS:certctl-server,DNS:localhost,IP:127.0.0.1
|
||||
# so both in-cluster traffic (agent → certctl-server:8443) and host traffic
|
||||
# (go test → localhost:8443) validate cleanly. Destroy via
|
||||
# `docker compose -f docker-compose.test.yml down -v` + `rm -rf test/certs`
|
||||
# to force regeneration. Keys written 0600, certs 0644, owned 1000:1000
|
||||
# (the UID the server binary runs as inside its container per Dockerfile:64).
|
||||
certctl-tls-init:
|
||||
image: alpine/openssl:latest
|
||||
container_name: certctl-test-tls-init
|
||||
restart: "no"
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
CERT=/etc/certctl/tls/server.crt
|
||||
KEY=/etc/certctl/tls/server.key
|
||||
CA=/etc/certctl/tls/ca.crt
|
||||
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
|
||||
echo "TLS cert already present at $$CERT — skipping generation"
|
||||
else
|
||||
mkdir -p /etc/certctl/tls
|
||||
openssl req -x509 -newkey ec \
|
||||
-pkeyopt ec_paramgen_curve:P-256 \
|
||||
-nodes \
|
||||
-keyout "$$KEY" \
|
||||
-out "$$CERT" \
|
||||
-days 3650 \
|
||||
-subj "/CN=certctl-server" \
|
||||
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
cp "$$CERT" "$$CA"
|
||||
echo "Generated self-signed TLS cert for certctl-test-server (ECDSA-P256/SHA-256, 3650d, CN=certctl-server)"
|
||||
fi
|
||||
# The test server container runs as root (see `user: "0:0"` below)
|
||||
# because setup-trust.sh needs to update the system trust store, so
|
||||
# the perms here are really about host-side readability — 0644 on
|
||||
# the CA/cert lets `go test` on the host read the bundle without a
|
||||
# chown dance.
|
||||
chown 1000:1000 "$$CERT" "$$KEY" "$$CA" || true
|
||||
chmod 0644 "$$CERT" "$$CA"
|
||||
chmod 0600 "$$KEY"
|
||||
volumes:
|
||||
- ./test/certs:/etc/certctl/tls
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.9
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: testpass
|
||||
volumes:
|
||||
- test_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.2
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U certctl -d certctl"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble — ACME test server (simulates Let's Encrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble is the official ACME test server from Let's Encrypt (RFC 8555).
|
||||
# It validates challenges via the companion challtestsrv.
|
||||
# Root CA cert available at https://pebble:15000/roots/0 (management API).
|
||||
pebble-challtestsrv:
|
||||
image: ghcr.io/letsencrypt/pebble-challtestsrv:latest
|
||||
container_name: certctl-test-challtestsrv
|
||||
# ENTRYPOINT is /app (the binary). command: provides only the FLAGS.
|
||||
# Matches the official Pebble docker-compose format.
|
||||
# -doh "" disables DoH (default :8443 would conflict with certctl server).
|
||||
# defaultIPv4 must point to the certctl-server (10.30.50.6) because that's where
|
||||
# the ACME HTTP-01 challenge server runs (port 80 inside the container).
|
||||
# Pebble resolves domains via challtestsrv, then connects to this IP to validate.
|
||||
command: -defaultIPv4 10.30.50.6 -defaultIPv6 "" -doh ""
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.3
|
||||
restart: unless-stopped
|
||||
|
||||
pebble:
|
||||
image: ghcr.io/letsencrypt/pebble:latest
|
||||
container_name: certctl-test-pebble
|
||||
depends_on:
|
||||
- pebble-challtestsrv
|
||||
environment:
|
||||
PEBBLE_VA_NOSLEEP: 1
|
||||
PEBBLE_VA_ALWAYS_VALID: 0
|
||||
# ENTRYPOINT is /app (the binary). command: provides only the FLAGS.
|
||||
command:
|
||||
- -config
|
||||
- /test/config/pebble-config.json
|
||||
- -dnsserver
|
||||
- "10.30.50.3:8053"
|
||||
- -strict
|
||||
volumes:
|
||||
- ./test/pebble-config.json:/test/config/pebble-config.json:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.4
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# step-ca — Private CA (Smallstep)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-bootstraps on first run: generates root CA + JWK provisioner "admin".
|
||||
# Root cert: /home/step/certs/root_ca.crt (inside stepca_data volume)
|
||||
# Provisioner key: /home/step/secrets/provisioner_key (encrypted JWK)
|
||||
step-ca:
|
||||
image: smallstep/step-ca:latest
|
||||
container_name: certctl-test-stepca
|
||||
environment:
|
||||
DOCKER_STEPCA_INIT_NAME: "certctl-test-ca"
|
||||
DOCKER_STEPCA_INIT_DNS_NAMES: "step-ca,localhost"
|
||||
DOCKER_STEPCA_INIT_PROVISIONER_NAME: "admin"
|
||||
DOCKER_STEPCA_INIT_PASSWORD: "password123"
|
||||
DOCKER_STEPCA_INIT_ADDRESS: ":9000"
|
||||
volumes:
|
||||
- stepca_data:/home/step
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.5
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fk", "https://localhost:9000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# certctl Server (Control Plane)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connects to PostgreSQL, Pebble (ACME), step-ca, and Local CA.
|
||||
#
|
||||
# TLS trust problem: Pebble and step-ca use self-signed root CAs that
|
||||
# aren't in Alpine's trust store. The ACME and step-ca connectors use
|
||||
# Go's default http.Client (no InsecureSkipVerify), so they need the
|
||||
# CA certs in the system trust store.
|
||||
#
|
||||
# Solution: setup-trust.sh runs as root, fetches Pebble CA from its
|
||||
# management API, copies step-ca root cert from the shared volume,
|
||||
# runs update-ca-certificates, then execs the server binary.
|
||||
certctl-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Node frontend stage and Go module
|
||||
# download can reach the public registries behind corporate proxies.
|
||||
# Defaults to empty; omit the variables from the host environment for
|
||||
# un-proxied builds and the behaviour is byte-identical to the pre-fix
|
||||
# tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-test-server
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
pebble:
|
||||
condition: service_started
|
||||
step-ca:
|
||||
condition: service_healthy
|
||||
# HTTPS-Everywhere Phase 6: block server boot until the init container
|
||||
# has written server.crt / server.key / ca.crt into ./test/certs. The
|
||||
# init container runs once and exits 0; service_completed_successfully
|
||||
# makes that a gating dependency rather than a liveness one.
|
||||
certctl-tls-init:
|
||||
condition: service_completed_successfully
|
||||
# Run as root so update-ca-certificates can write to /etc/ssl/certs.
|
||||
# Container isolation provides the security boundary.
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "/app/setup-trust.sh"]
|
||||
environment:
|
||||
# Database
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:testpass@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
# HTTPS-Everywhere Phase 6: point the server at the init-container-generated
|
||||
# cert/key pair (bind-mounted from ./test/certs). Same paths as production
|
||||
# compose so the server binary code path is identical; only the host-side
|
||||
# storage differs (bind mount vs named volume — see §certctl-tls-init block).
|
||||
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
|
||||
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
|
||||
# Auth — API key required (production-like)
|
||||
CERTCTL_AUTH_TYPE: api-key
|
||||
CERTCTL_AUTH_SECRET: test-key-2026
|
||||
|
||||
# Key generation — agent-side (production-like)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
|
||||
# Local CA issuer (iss-local) — self-signed mode (no CA cert/key paths)
|
||||
# This is the simplest issuer, always available.
|
||||
|
||||
# ACME issuer (iss-acme-staging) — pointed at Pebble
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://pebble:14000/dir
|
||||
CERTCTL_ACME_EMAIL: test@certctl.dev
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||
CERTCTL_ACME_INSECURE: "true"
|
||||
|
||||
# step-ca issuer (iss-stepca)
|
||||
CERTCTL_STEPCA_URL: https://step-ca:9000
|
||||
CERTCTL_STEPCA_ROOT_CERT: /stepca-data/certs/root_ca.crt
|
||||
CERTCTL_STEPCA_PROVISIONER: admin
|
||||
CERTCTL_STEPCA_PASSWORD: password123
|
||||
CERTCTL_STEPCA_KEY_PATH: /stepca-data/secrets/provisioner_key
|
||||
|
||||
# EST server (RFC 7030) — uses Local CA by default
|
||||
CERTCTL_EST_ENABLED: "true"
|
||||
CERTCTL_EST_ISSUER_ID: iss-local
|
||||
|
||||
# Dynamic issuer/target config encryption (M34/M35)
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!!
|
||||
|
||||
# Network scanning
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
|
||||
# Post-deployment TLS verification
|
||||
CERTCTL_VERIFY_DEPLOYMENT: "true"
|
||||
CERTCTL_VERIFY_TIMEOUT: "10s"
|
||||
CERTCTL_VERIFY_DELAY: "3s"
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- ./test/setup-trust.sh:/app/setup-trust.sh:ro
|
||||
# step-ca data volume (root cert at /certs/root_ca.crt, key at /secrets/provisioner_key)
|
||||
- stepca_data:/stepca-data:ro
|
||||
# HTTPS-Everywhere Phase 6: read-only bind mount of the init-generated
|
||||
# TLS material. The init container writes here; server reads here; the
|
||||
# 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
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.6
|
||||
healthcheck:
|
||||
# HTTPS-Everywhere Phase 6: healthcheck now speaks TLS with --cacert to
|
||||
# verify the self-signed server cert against the init-generated bundle.
|
||||
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the
|
||||
# Bearer token. curl exits non-zero on both TLS handshake failure and
|
||||
# non-2xx status — either failure keeps depends_on: {condition:
|
||||
# service_healthy} from unblocking the agent, which is what we want.
|
||||
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "-H", "Authorization: Bearer test-key-2026", "https://localhost:8443/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NGINX — TLS Target Server
|
||||
# ---------------------------------------------------------------------------
|
||||
# The agent deploys certificates here via the shared nginx_certs volume.
|
||||
# nginx-entrypoint.sh generates a self-signed placeholder cert so NGINX
|
||||
# can boot before the agent deploys a real cert.
|
||||
#
|
||||
# Ports: 8080 (HTTP) / 8444 (HTTPS) — offset to avoid conflict with server.
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: certctl-test-nginx
|
||||
entrypoint: ["/bin/sh", "/entrypoint.sh"]
|
||||
volumes:
|
||||
- ./test/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./test/nginx-entrypoint.sh:/entrypoint.sh:ro
|
||||
- nginx_certs:/etc/nginx/certs
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8444:443"
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.7
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fk https://localhost/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# certctl Agent
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polls the server for work, generates ECDSA P-256 keys locally,
|
||||
# deploys certs to NGINX via the shared volume, and discovers existing
|
||||
# certs in the NGINX cert directory.
|
||||
certctl-agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Go module download stage can reach
|
||||
# the public Go module proxy behind corporate proxies. Defaults to
|
||||
# empty; omit the variables from the host environment for un-proxied
|
||||
# builds and the behaviour is byte-identical to the pre-fix tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-test-agent
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# HTTPS-Everywhere Phase 6: agent dials the server over TLS and validates
|
||||
# the self-signed cert against the CA bundle pinned by
|
||||
# CERTCTL_SERVER_CA_BUNDLE_PATH. Same env vars + container paths as
|
||||
# production compose so the agent binary code path (loadCABundle →
|
||||
# x509.CertPool → *tls.Config{RootCAs, MinVersion: TLS13}) is identical.
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
|
||||
CERTCTL_API_KEY: test-key-2026
|
||||
CERTCTL_AGENT_NAME: test-agent-01
|
||||
CERTCTL_AGENT_ID: agent-test-01
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
CERTCTL_DISCOVERY_DIRS: /nginx-certs
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
- nginx_certs:/nginx-certs
|
||||
# HTTPS-Everywhere Phase 6: same bind mount as the server, same path,
|
||||
# so /etc/certctl/tls/ca.crt resolves to the identical bytes. This is
|
||||
# the only way the CN=certctl-server cert validates on the agent side.
|
||||
- ./test/certs:/etc/certctl/tls:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.8
|
||||
restart: unless-stopped
|
||||
|
||||
# =============================================================================
|
||||
# Network
|
||||
# =============================================================================
|
||||
# Static IPs are required because:
|
||||
# - Pebble needs to know the challtestsrv DNS server address (10.30.50.3)
|
||||
# - challtestsrv resolves all domains to certctl-server (10.30.50.6) for HTTP-01 challenges
|
||||
# - Avoids DNS race conditions during startup
|
||||
networks:
|
||||
certctl-test:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.30.50.0/24
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
test_postgres_data:
|
||||
driver: local
|
||||
stepca_data:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
nginx_certs:
|
||||
driver: local
|
||||
+115
-12
@@ -1,5 +1,81 @@
|
||||
services:
|
||||
# HTTPS-Everywhere Phase 3 — self-signed TLS bootstrap (init container).
|
||||
# Generates a CN=certctl-server ECDSA-P256 (SHA-256 signature) cert with
|
||||
# the SAN list locked by milestone §3.6 on first boot; subsequent boots
|
||||
# see the cert already present in the `certs` named volume and no-op out.
|
||||
# Server + agent mount the volume read-only. Destroy via `docker compose
|
||||
# down -v` to force regeneration. This bootstrap is for docker-compose
|
||||
# demos and local dev only; Helm operators supply a Secret / cert-manager
|
||||
# Certificate per docs/tls.md.
|
||||
#
|
||||
# Rationale for ECDSA-P256 (was ed25519 pre-v2.0.48): Apple's TLS stack
|
||||
# — Safari Network Framework and the macOS-bundled LibreSSL 3.3.6
|
||||
# /usr/bin/curl — does not advertise ed25519 in the ClientHello
|
||||
# signature_algorithms extension for server certs, yielding "tls: peer
|
||||
# doesn't support any of the certificate's signature algorithms" at
|
||||
# handshake. ECDSA-P256 with SHA-256 is universally supported. See
|
||||
# docs/tls.md Pattern 1.
|
||||
certctl-tls-init:
|
||||
image: alpine/openssl:latest
|
||||
container_name: certctl-tls-init
|
||||
restart: "no"
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
CERT=/etc/certctl/tls/server.crt
|
||||
KEY=/etc/certctl/tls/server.key
|
||||
CA=/etc/certctl/tls/ca.crt
|
||||
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
|
||||
echo "TLS cert already present at $$CERT — skipping generation"
|
||||
else
|
||||
mkdir -p /etc/certctl/tls
|
||||
openssl req -x509 -newkey ec \
|
||||
-pkeyopt ec_paramgen_curve:P-256 \
|
||||
-nodes \
|
||||
-keyout "$$KEY" \
|
||||
-out "$$CERT" \
|
||||
-days 3650 \
|
||||
-subj "/CN=certctl-server" \
|
||||
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
cp "$$CERT" "$$CA"
|
||||
echo "Generated self-signed TLS cert for certctl-server (ECDSA-P256/SHA-256, 3650d, CN=certctl-server)"
|
||||
fi
|
||||
# certctl binary runs as UID 1000 inside the server container per
|
||||
# Dockerfile:64-65; the cert + key must be readable by that UID.
|
||||
chown 1000:1000 "$$CERT" "$$KEY" "$$CA"
|
||||
chmod 0644 "$$CERT" "$$CA"
|
||||
chmod 0600 "$$KEY"
|
||||
volumes:
|
||||
- certs:/etc/certctl/tls
|
||||
networks:
|
||||
- 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
|
||||
@@ -11,16 +87,6 @@ services:
|
||||
- "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/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
@@ -28,6 +94,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Certctl Server (API + scheduler)
|
||||
@@ -35,27 +102,49 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Node frontend stage and Go module
|
||||
# download can reach the public registries behind corporate proxies.
|
||||
# Defaults to empty; omit the variables from the host environment for
|
||||
# un-proxied builds and the behaviour is byte-identical to the pre-fix
|
||||
# tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-server
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
certctl-tls-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
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
|
||||
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:
|
||||
- certs:/etc/certctl/tls:ro
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8443/health"]
|
||||
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "https://localhost:8443/health"]
|
||||
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"
|
||||
@@ -73,17 +162,29 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Go module download stage can reach
|
||||
# the public Go module proxy behind corporate proxies. Defaults to
|
||||
# empty; omit the variables from the host environment for un-proxied
|
||||
# builds and the behaviour is byte-identical to the pre-fix tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-agent
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
|
||||
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
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
- certs:/etc/certctl/tls:ro
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
@@ -112,3 +213,5 @@ volumes:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
certs:
|
||||
driver: local
|
||||
|
||||
@@ -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 |
|
||||
| `server.auth.apiKey` | "" | API key (REQUIRED) |
|
||||
| `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.logging.level` | info | Log level |
|
||||
| `server.logging.format` | json | Log format |
|
||||
|
||||
@@ -458,4 +458,4 @@ For issues, questions, or contributions:
|
||||
## License
|
||||
|
||||
BSL-1.1 (Business Source License)
|
||||
Converts to Apache 2.0 on March 28, 2033
|
||||
Converts to Apache 2.0 on March 14, 2033
|
||||
|
||||
@@ -236,10 +236,12 @@ kubectl get svc -l app.kubernetes.io/instance=certctl
|
||||
kubectl get ingress
|
||||
kubectl describe ingress certctl
|
||||
|
||||
# Test API connectivity
|
||||
# Test API connectivity (HTTPS-only as of v2.2)
|
||||
POD=$(kubectl get pods -l app.kubernetes.io/component=server -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl port-forward $POD 8443:8443 &
|
||||
curl -H "Authorization: Bearer $API_KEY" http://localhost:8443/health
|
||||
# If the chart provisioned a self-signed cert, fetch the CA bundle from the TLS secret first:
|
||||
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
|
||||
curl --cacert /tmp/certctl-ca.crt -H "Authorization: Bearer $API_KEY" https://localhost:8443/health
|
||||
```
|
||||
|
||||
### Step 6: Access the Dashboard
|
||||
@@ -333,9 +335,10 @@ kubectl logs $POD | tail -20
|
||||
# Port forward to API
|
||||
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||
|
||||
# Create a test certificate
|
||||
# Create a test certificate (HTTPS-only as of v2.2 — pin the chart-provisioned CA bundle)
|
||||
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
|
||||
API_KEY="your-api-key"
|
||||
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
curl --cacert /tmp/certctl-ca.crt -X POST https://localhost:8443/api/v1/certificates \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
|
||||
@@ -33,9 +33,11 @@ kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||
# View server logs
|
||||
kubectl logs -l app.kubernetes.io/component=server -f
|
||||
|
||||
# Access the API
|
||||
# Access the API (HTTPS-only as of v2.2; use --cacert or -k depending on your cert provisioning)
|
||||
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||
curl http://localhost:8443/health
|
||||
# If the chart provisioned a self-signed cert, fetch the CA bundle from the secret first:
|
||||
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
|
||||
curl --cacert /tmp/certctl-ca.crt https://localhost:8443/health
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# certctl Helm Chart
|
||||
|
||||
Production-ready Helm chart for deploying [certctl](https://github.com/shankar0123/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.
|
||||
@@ -4,36 +4,46 @@
|
||||
{{- else if contains "NodePort" .Values.server.service.type }}
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "certctl.fullname" . }}-server)
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
echo https://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.server.service.type }}
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server --template "{.status.loadBalancer.ingress[0].ip}")
|
||||
echo http://$SERVICE_IP:{{ .Values.server.service.port }}
|
||||
echo https://$SERVICE_IP:{{ .Values.server.service.port }}
|
||||
{{- else }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=server" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
echo "Visit https://127.0.0.1:8443 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8443:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. Get the default API key:
|
||||
2. Talk to the HTTPS-only server from your workstation:
|
||||
# Export the CA bundle that signed the server cert (self-signed or cert-manager-issued)
|
||||
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.tls.secretName" . }} \
|
||||
-o jsonpath='{.data.ca\.crt}' | base64 --decode > /tmp/certctl-ca.crt
|
||||
# (If ca.crt is empty, fall back to tls.crt — typical when the Secret
|
||||
# was created from a self-signed bootstrap cert without a separate CA.)
|
||||
|
||||
# Adapt the URL below to match the Server URL printed in step 1.
|
||||
curl --cacert /tmp/certctl-ca.crt https://127.0.0.1:8443/health
|
||||
|
||||
3. Get the default API key:
|
||||
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server -o jsonpath="{.data.api-key}" | base64 --decode; echo
|
||||
|
||||
3. Get PostgreSQL connection details:
|
||||
4. Get PostgreSQL connection details:
|
||||
Host: {{ include "certctl.fullname" . }}-postgres.{{ .Release.Namespace }}.svc.cluster.local
|
||||
Port: 5432
|
||||
Database: {{ .Values.postgresql.auth.database }}
|
||||
Username: {{ .Values.postgresql.auth.username }}
|
||||
Password: $(kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-postgres -o jsonpath="{.data.password}" | base64 --decode)
|
||||
|
||||
4. Check deployment status:
|
||||
5. Check deployment status:
|
||||
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
|
||||
|
||||
5. View server logs:
|
||||
6. View server logs:
|
||||
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=server -f
|
||||
|
||||
{{- if .Values.agent.enabled }}
|
||||
|
||||
6. View agent logs:
|
||||
7. View agent logs:
|
||||
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=agent -f
|
||||
|
||||
{{- end }}
|
||||
@@ -58,11 +68,7 @@ IMPORTANT NOTES FOR PRODUCTION:
|
||||
- Use an external PostgreSQL managed service (AWS RDS, Cloud SQL, etc.)
|
||||
- Set postgresql.enabled=false and configure CERTCTL_DATABASE_URL in values
|
||||
|
||||
5. Enable HTTPS/TLS using an Ingress with certificate management:
|
||||
- Configure cert-manager for automatic TLS certificate renewal
|
||||
- Update ingress values with your domain and certificate issuer
|
||||
|
||||
6. Review security contexts and network policies:
|
||||
5. Review security contexts and network policies:
|
||||
- All containers run as non-root
|
||||
- Implement network policies to restrict traffic between components
|
||||
- Consider pod security policies or security standards for your cluster
|
||||
|
||||
@@ -118,8 +118,77 @@ postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ includ
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Server URL (for agents)
|
||||
Server URL (for agents). HTTPS-only as of v2.2 — see docs/tls.md.
|
||||
*/}}
|
||||
{{- define "certctl.serverURL" -}}
|
||||
http://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
|
||||
https://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TLS Secret name resolver.
|
||||
|
||||
Operator-facing precedence:
|
||||
1. server.tls.existingSecret — operator points at a pre-existing kubernetes.io/tls Secret
|
||||
2. server.tls.certManager.secretName — explicit secret name for the cert-manager Certificate CR
|
||||
3. "<fullname>-tls" — default when cert-manager is enabled but secretName is blank
|
||||
|
||||
Never emits an empty string — that case is already excluded by certctl.tls.required below,
|
||||
which must be invoked by any template that depends on the resolved secret name.
|
||||
*/}}
|
||||
{{- define "certctl.tls.secretName" -}}
|
||||
{{- if .Values.server.tls.existingSecret -}}
|
||||
{{- .Values.server.tls.existingSecret -}}
|
||||
{{- else if .Values.server.tls.certManager.secretName -}}
|
||||
{{- .Values.server.tls.certManager.secretName -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-tls" (include "certctl.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TLS configuration gate.
|
||||
|
||||
HTTPS is the only supported listener mode (v2.2+). The server refuses to start
|
||||
without a cert/key pair mounted at server.tls.mountPath, so `helm template` /
|
||||
`helm install` must fail loudly at render-time rather than shipping a broken
|
||||
Deployment that crash-loops with "tls config required".
|
||||
|
||||
Operators MUST configure EXACTLY ONE of:
|
||||
(a) server.tls.existingSecret: <name-of-kubernetes.io/tls-secret>
|
||||
(b) server.tls.certManager.enabled: true (+ issuerRef.name populated)
|
||||
|
||||
Any template that mounts the TLS Secret must call
|
||||
`{{ include "certctl.tls.required" . }}` at the top so this guard runs once
|
||||
per affected resource. No-op when configured correctly.
|
||||
*/}}
|
||||
{{- define "certctl.tls.required" -}}
|
||||
{{- 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.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 }}
|
||||
|
||||
{{/*
|
||||
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" -}}
|
||||
{{- if not (has .Values.server.auth.type $valid) -}}
|
||||
{{- fail (printf "\n\nserver.auth.type=%q is not supported (valid: %v).\n\nFor JWT/OIDC, 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" .Values.server.auth.type $valid) -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- if .Values.agent.enabled }}
|
||||
{{- include "certctl.tls.required" . }}
|
||||
{{- if eq .Values.agent.kind "DaemonSet" }}
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
@@ -53,6 +54,8 @@ spec:
|
||||
fieldPath: metadata.name
|
||||
- name: CERTCTL_KEY_DIR
|
||||
value: {{ .Values.agent.keyDir }}
|
||||
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
|
||||
{{- if .Values.agent.discoveryDirs }}
|
||||
- name: CERTCTL_DISCOVERY_DIRS
|
||||
valueFrom:
|
||||
@@ -70,12 +73,19 @@ spec:
|
||||
mountPath: {{ .Values.agent.keyDir }}
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: server-tls
|
||||
mountPath: {{ .Values.server.tls.mountPath }}
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: agent-keys
|
||||
emptyDir:
|
||||
sizeLimit: 1Gi
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: server-tls
|
||||
secret:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
defaultMode: 0400
|
||||
{{- else if eq .Values.agent.kind "Deployment" }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -135,6 +145,8 @@ spec:
|
||||
{{- end }}
|
||||
- name: CERTCTL_KEY_DIR
|
||||
value: {{ .Values.agent.keyDir }}
|
||||
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
|
||||
{{- if .Values.agent.discoveryDirs }}
|
||||
- name: CERTCTL_DISCOVERY_DIRS
|
||||
valueFrom:
|
||||
@@ -152,11 +164,18 @@ spec:
|
||||
mountPath: {{ .Values.agent.keyDir }}
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: server-tls
|
||||
mountPath: {{ .Values.server.tls.mountPath }}
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: agent-keys
|
||||
emptyDir:
|
||||
sizeLimit: 1Gi
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: server-tls
|
||||
secret:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
defaultMode: 0400
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- if and .Values.ingress.certManager.enabled (not .Values.ingress.certManager.issuerRef.name) -}}
|
||||
{{- fail "\n\ningress.certManager.enabled=true but ingress.certManager.issuerRef.name is empty.\n\nSet:\n --set ingress.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nThis is separate from server.tls.certManager — it issues the external-facing\nIngress cert, not the in-cluster server TLS cert. See docs/tls.md.\n" -}}
|
||||
{{- end -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- if .Values.ingress.certManager.enabled }}
|
||||
{{- if eq .Values.ingress.certManager.issuerRef.kind "ClusterIssuer" }}
|
||||
cert-manager.io/cluster-issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
|
||||
{{- else }}
|
||||
cert-manager.io/issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
@@ -33,7 +43,7 @@ spec:
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
name: {{ include "certctl.fullname" $ }}-server
|
||||
port:
|
||||
number: {{ $.Values.server.service.port }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{{- if .Values.server.tls.certManager.enabled }}
|
||||
{{- include "certctl.tls.required" . }}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server-tls
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
commonName: {{ .Values.server.tls.certManager.commonName | quote }}
|
||||
dnsNames:
|
||||
{{- range .Values.server.tls.certManager.dnsNames }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
duration: {{ .Values.server.tls.certManager.duration }}
|
||||
renewBefore: {{ .Values.server.tls.certManager.renewBefore }}
|
||||
usages:
|
||||
- server auth
|
||||
- digital signature
|
||||
- key encipherment
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
rotationPolicy: Always
|
||||
issuerRef:
|
||||
name: {{ .Values.server.tls.certManager.issuerRef.name | quote }}
|
||||
kind: {{ .Values.server.tls.certManager.issuerRef.kind }}
|
||||
group: {{ .Values.server.tls.certManager.issuerRef.group }}
|
||||
{{- end }}
|
||||
@@ -1,3 +1,4 @@
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
{{- include "certctl.tls.required" . }}
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -32,7 +34,7 @@ spec:
|
||||
image: {{ include "certctl.serverImage" . }}
|
||||
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
- name: https
|
||||
containerPort: {{ .Values.server.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
@@ -40,6 +42,10 @@ spec:
|
||||
value: "0.0.0.0"
|
||||
- name: CERTCTL_SERVER_PORT
|
||||
value: "{{ .Values.server.port }}"
|
||||
- name: CERTCTL_SERVER_TLS_CERT_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/tls.crt"
|
||||
- name: CERTCTL_SERVER_TLS_KEY_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/tls.key"
|
||||
- name: CERTCTL_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -172,12 +178,19 @@ spec:
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: tls
|
||||
mountPath: {{ .Values.server.tls.mountPath }}
|
||||
readOnly: true
|
||||
{{- if .Values.server.volumeMounts }}
|
||||
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
defaultMode: 0400
|
||||
{{- if .Values.server.volumes }}
|
||||
{{- toYaml .Values.server.volumes | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
|
||||
@@ -13,8 +13,8 @@ spec:
|
||||
type: {{ .Values.server.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.server.service.port }}
|
||||
targetPort: http
|
||||
targetPort: https
|
||||
protocol: TCP
|
||||
name: http
|
||||
name: https
|
||||
selector:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
|
||||
|
||||
@@ -18,7 +18,14 @@ metadata:
|
||||
name: {{ include "certctl.fullname" . }}
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
rules: []
|
||||
rules:
|
||||
{{- if .Values.kubernetesSecrets.enabled }}
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "list", "create", "update", "patch"]
|
||||
{{- else }}
|
||||
[]
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
|
||||
@@ -48,35 +48,103 @@ server:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
# Liveness and readiness probes
|
||||
# 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.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
port: https
|
||||
scheme: HTTPS
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
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: /readyz
|
||||
port: http
|
||||
path: /ready
|
||||
port: https
|
||||
scheme: HTTPS
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 2
|
||||
|
||||
# TLS configuration — REQUIRED. HTTPS is the only supported mode (v2.2+).
|
||||
# Operator must configure EXACTLY ONE of:
|
||||
# (a) server.tls.existingSecret: <name> # pre-existing kubernetes.io/tls Secret
|
||||
# (b) server.tls.certManager.enabled: true # provision a cert-manager Certificate CR
|
||||
# Refusing to set either makes `helm template` fail with a diagnostic pointing at docs/tls.md.
|
||||
tls:
|
||||
# Name of a pre-existing Secret (type kubernetes.io/tls) holding tls.crt + tls.key (+ optional ca.crt).
|
||||
# Leave empty to fall through to the cert-manager path.
|
||||
existingSecret: ""
|
||||
|
||||
# Mount path for the TLS Secret inside the server + agent containers.
|
||||
mountPath: /etc/certctl/tls
|
||||
|
||||
# cert-manager auto-provisioning. Opt-in (off by default per milestone §3.4).
|
||||
certManager:
|
||||
enabled: false
|
||||
|
||||
# Secret name the cert-manager Certificate CR writes into. Agents and the server
|
||||
# both read from this Secret. If empty, defaults to "<fullname>-tls".
|
||||
secretName: ""
|
||||
|
||||
# Cert-manager issuer reference.
|
||||
issuerRef:
|
||||
name: "" # e.g. "letsencrypt-prod" or "internal-ca"
|
||||
kind: ClusterIssuer # ClusterIssuer or Issuer
|
||||
group: cert-manager.io
|
||||
|
||||
# Subject fields on the issued cert.
|
||||
commonName: "certctl-server"
|
||||
dnsNames:
|
||||
- certctl-server
|
||||
- localhost
|
||||
|
||||
# Certificate lifetime + renewal window.
|
||||
duration: 2160h # 90 days
|
||||
renewBefore: 360h # 15 days
|
||||
|
||||
# Service type (ClusterIP, LoadBalancer, NodePort)
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8443
|
||||
annotations: {}
|
||||
|
||||
# Authentication configuration
|
||||
# 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.
|
||||
auth:
|
||||
type: api-key # Options: api-key, none (for demo only)
|
||||
apiKey: "" # REQUIRED in production - set via --set or values override
|
||||
type: api-key
|
||||
apiKey: "" # REQUIRED when type=api-key (set via --set or values override).
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
@@ -221,7 +289,30 @@ postgresql:
|
||||
auth:
|
||||
database: certctl
|
||||
username: certctl
|
||||
password: "" # REQUIRED - set via --set or values override
|
||||
# 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: ""
|
||||
|
||||
# Storage configuration
|
||||
storage:
|
||||
@@ -356,7 +447,16 @@ ingress:
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
|
||||
# Optional cert-manager integration for the public-facing Ingress cert.
|
||||
# This is completely independent of server.tls.* — the Ingress terminates
|
||||
# an *additional* TLS hop between the internet and the in-cluster Service.
|
||||
# Leave disabled unless an Ingress is exposing certctl to the outside world.
|
||||
certManager:
|
||||
enabled: false
|
||||
issuerRef:
|
||||
name: "" # e.g. "letsencrypt-prod"
|
||||
kind: ClusterIssuer # ClusterIssuer or Issuer
|
||||
hosts:
|
||||
- host: certctl.local
|
||||
paths:
|
||||
@@ -381,6 +481,13 @@ serviceAccount:
|
||||
rbac:
|
||||
create: true
|
||||
|
||||
# ==============================================================================
|
||||
# Kubernetes Secrets Target Connector
|
||||
# ==============================================================================
|
||||
kubernetesSecrets:
|
||||
# Enable RBAC rules for managing TLS Secrets
|
||||
enabled: false
|
||||
|
||||
# ==============================================================================
|
||||
# Pod Disruption Budget (for HA deployments)
|
||||
# ==============================================================================
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//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.
|
||||
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.")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Generate a self-signed placeholder certificate so NGINX can boot
|
||||
# before the certctl agent deploys a real certificate.
|
||||
# Once the agent deploys, it overwrites these files and reloads NGINX.
|
||||
|
||||
CERT_DIR="/etc/nginx/certs"
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Make cert directory world-writable so the certctl-agent container
|
||||
# (which shares this volume) can overwrite the placeholder certs.
|
||||
chmod 777 "$CERT_DIR"
|
||||
|
||||
if [ ! -f "$CERT_DIR/cert.pem" ]; then
|
||||
echo "Generating self-signed placeholder certificate..."
|
||||
apk add --no-cache openssl > /dev/null 2>&1
|
||||
openssl req -x509 -nodes -days 1 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-keyout "$CERT_DIR/key.pem" \
|
||||
-out "$CERT_DIR/cert.pem" \
|
||||
-subj "/CN=placeholder.certctl.test" \
|
||||
2>/dev/null
|
||||
# Make placeholder certs writable by the agent container
|
||||
chmod 666 "$CERT_DIR/cert.pem" "$CERT_DIR/key.pem"
|
||||
echo "Placeholder certificate generated."
|
||||
fi
|
||||
|
||||
# Start NGINX in foreground
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -0,0 +1,42 @@
|
||||
# NGINX configuration for certctl test environment.
|
||||
# The agent deploys certificates to /etc/nginx/certs/ and reloads NGINX.
|
||||
# On startup, NGINX uses a self-signed placeholder so it can boot before any cert is deployed.
|
||||
|
||||
# Generate a self-signed placeholder on container start (see entrypoint in compose).
|
||||
# Once the agent deploys a real cert, it overwrites these files and reloads.
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# HTTP → redirect to HTTPS (optional, for realism)
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS server — serves whatever cert the agent has deployed
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
|
||||
# Modern TLS settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
location / {
|
||||
default_type text/plain;
|
||||
return 200 'certctl test environment — NGINX is serving TLS\n';
|
||||
}
|
||||
|
||||
location /health {
|
||||
default_type text/plain;
|
||||
return 200 'ok\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pebble": {
|
||||
"listenAddress": "0.0.0.0:14000",
|
||||
"managementListenAddress": "0.0.0.0:15000",
|
||||
"certificate": "test/certs/localhost/cert.pem",
|
||||
"privateKey": "test/certs/localhost/key.pem",
|
||||
"httpPort": 80,
|
||||
"tlsPort": 443,
|
||||
"ocspResponderURL": "",
|
||||
"externalAccountBindingRequired": false,
|
||||
"retryAfter": {
|
||||
"authz": 3,
|
||||
"order": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+972
@@ -0,0 +1,972 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# DEPRECATED — prefer `go test -tags integration ./deploy/test/...`
|
||||
# =============================================================================
|
||||
#
|
||||
# This bash harness predates the Go integration test suite in
|
||||
# deploy/test/integration_test.go (build tag `integration`, 34 subtests across
|
||||
# 13 phases — health, agent heartbeat, Local CA issuance, ACME, step-ca, EST,
|
||||
# S/MIME, discovery, network scan, revocation + CRL, deployment verification).
|
||||
# The Go suite uses crypto/x509, crypto/tls, and database/sql to parse certs,
|
||||
# probe TLS, and talk to PostgreSQL directly — no openssl text-scraping or
|
||||
# brittle curl pipelines. It is the authoritative integration test surface as
|
||||
# of milestone M-007 (HTTPS Everywhere, Phase 6), where the test compose
|
||||
# stack wires the server on https://localhost:8443 behind a pinned CA bundle
|
||||
# at ./certs/ca.crt.
|
||||
#
|
||||
# Run the Go suite:
|
||||
# (cd deploy && docker compose -f docker-compose.test.yml up -d --build)
|
||||
# go test -tags integration -v -count=1 ./deploy/test/...
|
||||
#
|
||||
# Keep this bash script around because:
|
||||
# * It is cited in docs/test-env.md and muscle-memory for contributors.
|
||||
# * It exercises the CLI / curl path end-to-end (a different failure mode
|
||||
# than the Go HTTP client path).
|
||||
# But any NEW integration coverage goes in integration_test.go — not here.
|
||||
#
|
||||
# =============================================================================
|
||||
# certctl End-to-End Test Script
|
||||
# =============================================================================
|
||||
#
|
||||
# Automates the full lifecycle test from docs/test-env.md:
|
||||
# 1. Bring up all 7 containers (build from source)
|
||||
# 2. Wait for every service to be healthy
|
||||
# 3. Verify pre-seeded data (agents, issuers, targets, profiles)
|
||||
# 4. Issue a certificate via Local CA → deploy to NGINX → verify TLS
|
||||
# 5. Issue a certificate via ACME/Pebble → verify
|
||||
# 6. Issue a certificate via step-ca → verify
|
||||
# 7. Test revocation + CRL
|
||||
# 8. Test discovery
|
||||
# 9. Test renewal (re-issue step-ca cert, check version history)
|
||||
# 10. EST enrollment (RFC 7030) — cacerts + simpleenroll
|
||||
# 11. S/MIME issuance — emailProtection EKU + adaptive KeyUsage
|
||||
# 12. API spot checks + print summary
|
||||
#
|
||||
# Usage:
|
||||
# cd certctl/deploy
|
||||
# ./test/run-test.sh # full run (build + test)
|
||||
# ./test/run-test.sh --no-build # skip docker build, reuse existing containers
|
||||
# ./test/run-test.sh --no-teardown # leave containers running after test
|
||||
#
|
||||
# Requirements: docker, curl, openssl, jq (or python3 for json parsing)
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
COMPOSE_FILE="docker-compose.test.yml"
|
||||
API_URL="https://localhost:8443"
|
||||
API_KEY="test-key-2026"
|
||||
NGINX_TLS="localhost:8444"
|
||||
AUTH_HEADER="Authorization: Bearer ${API_KEY}"
|
||||
CACERT="./certs/ca.crt"
|
||||
|
||||
# Flags
|
||||
BUILD=true
|
||||
TEARDOWN=true
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) BUILD=false ;;
|
||||
--no-teardown) TEARDOWN=false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
pass() {
|
||||
PASS=$((PASS + 1))
|
||||
echo -e " ${GREEN}PASS${NC} $1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAIL=$((FAIL + 1))
|
||||
echo -e " ${RED}FAIL${NC} $1"
|
||||
if [ -n "${2:-}" ]; then
|
||||
echo -e " ${RED}$2${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
skip() {
|
||||
SKIP=$((SKIP + 1))
|
||||
echo -e " ${YELLOW}SKIP${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${CYAN}==>${NC} $1"
|
||||
}
|
||||
|
||||
header() {
|
||||
echo ""
|
||||
echo -e "${BOLD}─── $1 ───${NC}"
|
||||
}
|
||||
|
||||
# API helper: GET endpoint, return JSON body. Exits 1 on HTTP error.
|
||||
api_get() {
|
||||
local path="$1"
|
||||
curl -sf --cacert "${CACERT}" -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
||||
}
|
||||
|
||||
# API helper: POST with optional JSON body
|
||||
api_post() {
|
||||
local path="$1"
|
||||
local body="${2:-}"
|
||||
if [ -n "$body" ]; then
|
||||
curl -sf --cacert "${CACERT}" -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/json" \
|
||||
-d "$body" "${API_URL}${path}" 2>/dev/null
|
||||
else
|
||||
curl -sf --cacert "${CACERT}" -X POST -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait for an HTTP endpoint to return 200. Retries with backoff.
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
local max_wait="${3:-120}"
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
|
||||
while [ $elapsed -lt $max_wait ]; do
|
||||
if curl -sf -H "${AUTH_HEADER}" "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract a field from JSON using python3 (no jq dependency)
|
||||
json_field() {
|
||||
python3 -c "import sys,json; d=json.load(sys.stdin); print($1)" 2>/dev/null
|
||||
}
|
||||
|
||||
# Wait for a job to reach a terminal state (Completed or Failed)
|
||||
# Usage: wait_for_job <cert_id> <max_seconds>
|
||||
# Returns 0 if Completed, 1 if Failed/timeout
|
||||
wait_for_jobs_done() {
|
||||
local cert_id="$1"
|
||||
local max_wait="${2:-180}"
|
||||
local elapsed=0
|
||||
local interval=5
|
||||
|
||||
while [ $elapsed -lt $max_wait ]; do
|
||||
local jobs_json
|
||||
jobs_json=$(api_get "/api/v1/jobs" 2>/dev/null || echo '{"data":[]}')
|
||||
|
||||
# Check if all jobs for this cert are in terminal state
|
||||
# API returns jobs under "data" key (not "jobs")
|
||||
local pending
|
||||
pending=$(echo "$jobs_json" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
jobs = data.get('data') or data.get('jobs') or []
|
||||
active = [j for j in jobs if j.get('certificate_id') == '$cert_id'
|
||||
and j.get('status') not in ('Completed', 'Failed', 'Cancelled')]
|
||||
print(len(active))
|
||||
" 2>/dev/null || echo "99")
|
||||
|
||||
if [ "$pending" = "0" ]; then
|
||||
# Check how many jobs exist and their terminal states
|
||||
local job_counts
|
||||
job_counts=$(echo "$jobs_json" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
jobs = data.get('data') or data.get('jobs') or []
|
||||
mine = [j for j in jobs if j.get('certificate_id') == '$cert_id']
|
||||
completed = len([j for j in mine if j.get('status') == 'Completed'])
|
||||
failed = len([j for j in mine if j.get('status') in ('Failed', 'Cancelled')])
|
||||
print(f'{len(mine)} {completed} {failed}')
|
||||
" 2>/dev/null || echo "0 0 0")
|
||||
local total_jobs completed_jobs failed_jobs
|
||||
total_jobs=$(echo "$job_counts" | cut -d' ' -f1)
|
||||
completed_jobs=$(echo "$job_counts" | cut -d' ' -f2)
|
||||
failed_jobs=$(echo "$job_counts" | cut -d' ' -f3)
|
||||
|
||||
if [ "$completed_jobs" -gt 0 ]; then
|
||||
return 0 # At least one job completed successfully
|
||||
fi
|
||||
if [ "$total_jobs" -gt 0 ] && [ "$failed_jobs" -gt 0 ]; then
|
||||
return 1 # All jobs are in terminal state but none completed — all failed
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get the TLS cert subject from NGINX for a given SNI
|
||||
get_tls_subject() {
|
||||
local sni="$1"
|
||||
echo | openssl s_client -connect "$NGINX_TLS" -servername "$sni" 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null \
|
||||
| sed 's/subject=//' | sed 's/^ *//'
|
||||
}
|
||||
|
||||
get_tls_issuer() {
|
||||
local sni="$1"
|
||||
echo | openssl s_client -connect "$NGINX_TLS" -servername "$sni" 2>/dev/null \
|
||||
| openssl x509 -noout -issuer 2>/dev/null \
|
||||
| sed 's/issuer=//' | sed 's/^ *//'
|
||||
}
|
||||
|
||||
# Get the TLS cert SANs from NGINX for a given SNI
|
||||
# Modern CAs (including Let's Encrypt / Pebble) put domains only in SAN, not Subject CN.
|
||||
get_tls_san() {
|
||||
local sni="$1"
|
||||
echo | openssl s_client -connect "$NGINX_TLS" -servername "$sni" 2>/dev/null \
|
||||
| openssl x509 -noout -ext subjectAltName 2>/dev/null \
|
||||
| grep -i "DNS:" | sed 's/^ *//'
|
||||
}
|
||||
|
||||
# Check if NGINX is serving a cert that matches the given domain (checks Subject then SAN)
|
||||
check_tls_identity() {
|
||||
local domain="$1"
|
||||
local subject issuer san
|
||||
subject=$(get_tls_subject "$domain")
|
||||
issuer=$(get_tls_issuer "$domain")
|
||||
san=$(get_tls_san "$domain")
|
||||
if echo "$subject" | grep -qi "$domain" || echo "$san" | grep -qi "$domain"; then
|
||||
echo "MATCH"
|
||||
echo "Subject: $subject"
|
||||
echo "SAN: $san"
|
||||
echo "Issuer: $issuer"
|
||||
else
|
||||
echo "NO_MATCH"
|
||||
echo "Subject: $subject"
|
||||
echo "SAN: $san"
|
||||
echo "Issuer: $issuer"
|
||||
fi
|
||||
}
|
||||
|
||||
# SQL exec in the postgres container
|
||||
psql_exec() {
|
||||
docker exec certctl-test-postgres psql -U certctl -d certctl -tAc "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup trap
|
||||
# ---------------------------------------------------------------------------
|
||||
cleanup() {
|
||||
if [ "$TEARDOWN" = true ]; then
|
||||
info "Tearing down test environment..."
|
||||
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
||||
else
|
||||
info "Leaving containers running (--no-teardown)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 0: Environment Check
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 0: Environment Check"
|
||||
|
||||
# Make sure we're in the deploy directory
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo -e "${RED}ERROR: $COMPOSE_FILE not found.${NC}"
|
||||
echo "Run this script from the certctl/deploy directory:"
|
||||
echo " cd certctl/deploy && ./test/run-test.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in docker curl openssl python3; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
pass "$cmd available"
|
||||
else
|
||||
fail "$cmd not found" "Install $cmd and try again"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
pass "docker compose available"
|
||||
else
|
||||
fail "docker compose not available" "Install Docker Compose v2+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 1: Start the Stack
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 1: Start Test Environment"
|
||||
|
||||
# Teardown any previous run
|
||||
info "Cleaning up previous test environment..."
|
||||
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
||||
|
||||
# Set the cleanup trap AFTER the initial teardown
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "$BUILD" = true ]; then
|
||||
info "Building and starting containers (this takes 2-5 minutes on first run)..."
|
||||
docker compose -f "$COMPOSE_FILE" up --build -d 2>&1 | tail -5
|
||||
else
|
||||
info "Starting containers (--no-build)..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d 2>&1 | tail -5
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 2: Wait for Services
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 2: Waiting for Services"
|
||||
|
||||
info "Waiting for PostgreSQL..."
|
||||
if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U certctl -d certctl >/dev/null 2>&1 ||
|
||||
wait_for_http "${API_URL}/health" "postgres" 60; then
|
||||
pass "PostgreSQL ready"
|
||||
else
|
||||
fail "PostgreSQL not ready after 60s"
|
||||
fi
|
||||
|
||||
info "Waiting for certctl server..."
|
||||
if wait_for_http "${API_URL}/health" "server" 120; then
|
||||
pass "certctl server healthy"
|
||||
# Show trust setup + connector init for debugging
|
||||
echo " --- Server startup (trust setup) ---"
|
||||
docker logs certctl-test-server 2>&1 | grep -E "trust|Added|Extract|provisioner|Pre-launch|key file|WARNING|CERTCTL_" | head -15
|
||||
echo " ---"
|
||||
else
|
||||
fail "certctl server not healthy after 120s"
|
||||
echo ""
|
||||
echo "Server logs:"
|
||||
docker logs certctl-test-server --tail 30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Waiting for NGINX..."
|
||||
if wait_for_http "http://localhost:8080" "nginx" 30; then
|
||||
pass "NGINX healthy"
|
||||
else
|
||||
# NGINX might not respond to plain curl on /health without the right path
|
||||
# Check docker health instead
|
||||
if docker inspect certctl-test-nginx --format='{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then
|
||||
pass "NGINX healthy (docker healthcheck)"
|
||||
else
|
||||
skip "NGINX health check inconclusive (will verify via TLS later)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Give the agent a few seconds to register and send first heartbeat
|
||||
info "Waiting for agent heartbeat (up to 45s)..."
|
||||
AGENT_READY=false
|
||||
for i in $(seq 1 15); do
|
||||
AGENT_STATUS=$(api_get "/api/v1/agents/agent-test-01" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "")
|
||||
if [ "$AGENT_STATUS" = "online" ]; then
|
||||
AGENT_READY=true
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ "$AGENT_READY" = true ]; then
|
||||
pass "Agent online"
|
||||
else
|
||||
skip "Agent not yet online (may be slow to heartbeat — continuing)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 3: Verify Pre-Seeded Data
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 3: Verify Pre-Seeded Data"
|
||||
|
||||
# Agents
|
||||
AGENT_COUNT=$(api_get "/api/v1/agents" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$AGENT_COUNT" -ge 2 ]; then
|
||||
pass "Agents: $AGENT_COUNT found (agent-test-01 + server-scanner)"
|
||||
else
|
||||
fail "Agents: expected >= 2, got $AGENT_COUNT"
|
||||
fi
|
||||
|
||||
# Issuers
|
||||
ISSUER_COUNT=$(api_get "/api/v1/issuers" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$ISSUER_COUNT" -ge 3 ]; then
|
||||
pass "Issuers: $ISSUER_COUNT found (iss-local, iss-acme-staging, iss-stepca)"
|
||||
else
|
||||
fail "Issuers: expected >= 3, got $ISSUER_COUNT" "Check seed_test.sql loaded correctly"
|
||||
fi
|
||||
|
||||
# Targets
|
||||
TARGET_COUNT=$(api_get "/api/v1/targets" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$TARGET_COUNT" -ge 1 ]; then
|
||||
pass "Targets: $TARGET_COUNT found (target-test-nginx)"
|
||||
else
|
||||
fail "Targets: expected >= 1, got $TARGET_COUNT" "seed_test.sql may have failed after iss-local"
|
||||
fi
|
||||
|
||||
# Profile
|
||||
PROFILE_RESP=$(api_get "/api/v1/profiles" 2>/dev/null || echo '{"total":0}')
|
||||
PROFILE_COUNT=$(echo "$PROFILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$PROFILE_COUNT" -ge 2 ]; then
|
||||
pass "Profiles: $PROFILE_COUNT found (prof-test-tls, prof-test-smime)"
|
||||
else
|
||||
fail "Profiles: expected >= 1, got $PROFILE_COUNT"
|
||||
fi
|
||||
|
||||
# Bail if seed data is broken
|
||||
if [ "$ISSUER_COUNT" -lt 3 ] || [ "$TARGET_COUNT" -lt 1 ]; then
|
||||
echo ""
|
||||
echo -e "${RED}Seed data is incomplete. Cannot continue.${NC}"
|
||||
echo "Check PostgreSQL logs: docker logs certctl-test-postgres"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 4: Local CA Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 4: Local CA Certificate Issuance"
|
||||
|
||||
info "Creating certificate record mc-local-test..."
|
||||
CREATE_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-local-test",
|
||||
"name": "local-test-cert",
|
||||
"common_name": "local.certctl.test",
|
||||
"sans": ["local.certctl.test"],
|
||||
"issuer_id": "iss-local",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-tls",
|
||||
"environment": "development"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$CREATE_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-local-test'" 2>/dev/null; then
|
||||
pass "Certificate record created"
|
||||
else
|
||||
fail "Certificate creation failed" "$CREATE_RESP"
|
||||
fi
|
||||
|
||||
info "Linking certificate to NGINX target..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-local-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
pass "Target mapping inserted"
|
||||
|
||||
info "Triggering issuance..."
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/mc-local-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Issuance triggered"
|
||||
else
|
||||
fail "Trigger failed" "$RENEW_RESP"
|
||||
fi
|
||||
|
||||
# Verify a job was created (this is the bug fix check)
|
||||
sleep 2
|
||||
JOB_COUNT=$(api_get "/api/v1/jobs" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
jobs = [j for j in (data.get('data') or data.get('jobs') or []) if j.get('certificate_id') == 'mc-local-test']
|
||||
print(len(jobs))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$JOB_COUNT" -gt 0 ]; then
|
||||
pass "Job created ($JOB_COUNT jobs for mc-local-test)"
|
||||
else
|
||||
fail "No jobs created — TriggerRenewalWithActor bug still present"
|
||||
fi
|
||||
|
||||
info "Waiting for issuance + deployment (up to 180s)..."
|
||||
if wait_for_jobs_done "mc-local-test" 180; then
|
||||
pass "All jobs completed"
|
||||
else
|
||||
fail "Jobs did not complete within 180s"
|
||||
echo " Current jobs:"
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -m json.tool 2>/dev/null | head -30
|
||||
fi
|
||||
|
||||
info "Reloading NGINX to pick up deployed certificate..."
|
||||
docker exec certctl-test-nginx nginx -s reload 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
info "Verifying TLS certificate on NGINX..."
|
||||
TLS_CHECK=$(check_tls_identity "local.certctl.test")
|
||||
TLS_RESULT=$(echo "$TLS_CHECK" | head -1)
|
||||
if [ "$TLS_RESULT" = "MATCH" ]; then
|
||||
pass "NGINX serving cert for local.certctl.test"
|
||||
echo "$TLS_CHECK" | tail -n +2 | while read -r line; do echo -e " $line"; done
|
||||
else
|
||||
fail "NGINX not serving expected cert" "$(echo "$TLS_CHECK" | tail -n +2 | tr '\n' ', ')"
|
||||
fi
|
||||
|
||||
# Check cert status in API
|
||||
CERT_STATUS=$(api_get "/api/v1/certificates/mc-local-test" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown")
|
||||
if [ "$CERT_STATUS" = "Active" ]; then
|
||||
pass "Certificate status: Active"
|
||||
else
|
||||
skip "Certificate status: $CERT_STATUS (expected Active — may need more time)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 5: ACME (Pebble) Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 5: ACME (Pebble) Certificate Issuance"
|
||||
|
||||
info "Creating certificate record mc-acme-test..."
|
||||
CREATE_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-acme-test",
|
||||
"name": "acme-test-cert",
|
||||
"common_name": "acme.certctl.test",
|
||||
"sans": ["acme.certctl.test"],
|
||||
"issuer_id": "iss-acme-staging",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-tls",
|
||||
"environment": "staging"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$CREATE_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-acme-test'" 2>/dev/null; then
|
||||
pass "Certificate record created"
|
||||
else
|
||||
fail "Certificate creation failed" "$CREATE_RESP"
|
||||
fi
|
||||
|
||||
info "Linking to target and triggering issuance..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-acme-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/mc-acme-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Issuance triggered"
|
||||
else
|
||||
fail "Trigger failed" "$RENEW_RESP"
|
||||
fi
|
||||
|
||||
info "Waiting for ACME issuance + deployment (up to 180s)..."
|
||||
if wait_for_jobs_done "mc-acme-test" 180; then
|
||||
pass "All jobs completed"
|
||||
|
||||
info "Reloading NGINX to pick up deployed certificate..."
|
||||
docker exec certctl-test-nginx nginx -s reload 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
TLS_CHECK=$(check_tls_identity "acme.certctl.test")
|
||||
TLS_RESULT=$(echo "$TLS_CHECK" | head -1)
|
||||
if [ "$TLS_RESULT" = "MATCH" ]; then
|
||||
pass "NGINX serving cert for acme.certctl.test"
|
||||
echo "$TLS_CHECK" | tail -n +2 | while read -r line; do echo -e " $line"; done
|
||||
else
|
||||
fail "NGINX not serving expected ACME cert" "$(echo "$TLS_CHECK" | tail -n +2 | tr '\n' ', ')"
|
||||
fi
|
||||
else
|
||||
fail "ACME jobs did not complete within 180s"
|
||||
info "Checking ACME job status..."
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for j in data.get('data', []):
|
||||
if j.get('certificate_id') == 'mc-acme-test':
|
||||
print(f\" Job {j['id']}: type={j['type']} status={j['status']} error={j.get('last_error','')}\")" 2>/dev/null || true
|
||||
echo " Server logs (last 20 lines):"
|
||||
docker logs certctl-test-server --tail 20 2>&1 | grep -i "acme\|error\|fail\|CSR" | head -10 || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 6: step-ca Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 6: step-ca (Private CA) Certificate Issuance"
|
||||
|
||||
info "Creating certificate record mc-stepca-test..."
|
||||
CREATE_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-stepca-test",
|
||||
"name": "stepca-test-cert",
|
||||
"common_name": "stepca.certctl.test",
|
||||
"sans": ["stepca.certctl.test"],
|
||||
"issuer_id": "iss-stepca",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-tls",
|
||||
"environment": "staging"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$CREATE_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-stepca-test'" 2>/dev/null; then
|
||||
pass "Certificate record created"
|
||||
else
|
||||
fail "Certificate creation failed" "$CREATE_RESP"
|
||||
fi
|
||||
|
||||
info "Linking to target and triggering issuance..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-stepca-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/mc-stepca-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Issuance triggered"
|
||||
else
|
||||
fail "Trigger failed" "$RENEW_RESP"
|
||||
fi
|
||||
|
||||
info "Waiting for step-ca issuance + deployment (up to 120s)..."
|
||||
if wait_for_jobs_done "mc-stepca-test" 120; then
|
||||
pass "All jobs completed"
|
||||
else
|
||||
fail "Jobs did not complete in time"
|
||||
info "Checking step-ca job status..."
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for j in data.get('data', []):
|
||||
if j.get('certificate_id') == 'mc-stepca-test':
|
||||
print(f\" Job {j['id']}: type={j['type']} status={j['status']} error={j.get('last_error','')}\")" 2>/dev/null || true
|
||||
echo " Server logs (step-ca related):"
|
||||
docker logs certctl-test-server --tail 30 2>&1 | grep -i "stepca\|step-ca\|provisioner\|jwe\|decrypt\|CSR.*fail\|error" | head -10 || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 7: Revocation
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 7: Revocation"
|
||||
|
||||
info "Revoking mc-local-test (reason: superseded)..."
|
||||
REVOKE_RESP=$(api_post "/api/v1/certificates/mc-local-test/revoke" '{"reason": "superseded"}' 2>/dev/null || echo "ERROR")
|
||||
if echo "$REVOKE_RESP" | grep -qi "revoked\|status"; then
|
||||
pass "Certificate revoked"
|
||||
else
|
||||
fail "Revocation failed" "$REVOKE_RESP"
|
||||
fi
|
||||
|
||||
info "Checking DER CRL under /.well-known/pki (RFC 5280 §5, RFC 8615)..."
|
||||
# The JSON CRL endpoint (`GET /api/v1/crl`) was removed in M-006. RFC 5280
|
||||
# defines only the DER wire format, now served unauthenticated at
|
||||
# `/.well-known/pki/crl/{issuer_id}`. Fetch without the Bearer header to
|
||||
# prove the endpoint is reachable by relying parties with no API key.
|
||||
CRL_TMP=$(mktemp)
|
||||
CRL_HEADERS=$(mktemp)
|
||||
CRL_HTTP_CODE=$(curl -s -o "$CRL_TMP" -D "$CRL_HEADERS" -w "%{http_code}" "${API_URL}/.well-known/pki/crl/iss-local" 2>/dev/null || echo "000")
|
||||
CRL_SIZE=$(wc -c < "$CRL_TMP" | tr -d ' ')
|
||||
CRL_CONTENT_TYPE=$(awk 'tolower($1)=="content-type:" { sub(/\r$/,"",$2); print tolower($2) }' "$CRL_HEADERS" | head -n1)
|
||||
rm -f "$CRL_TMP" "$CRL_HEADERS"
|
||||
|
||||
if [ "$CRL_HTTP_CODE" = "200" ] && [ "$CRL_CONTENT_TYPE" = "application/pkix-crl" ] && [ "$CRL_SIZE" -gt 0 ]; then
|
||||
pass "DER CRL served unauthenticated (HTTP 200, Content-Type application/pkix-crl, ${CRL_SIZE} bytes)"
|
||||
else
|
||||
fail "DER CRL fetch failed: HTTP=$CRL_HTTP_CODE Content-Type=$CRL_CONTENT_TYPE size=$CRL_SIZE"
|
||||
fi
|
||||
|
||||
CERT_STATUS=$(api_get "/api/v1/certificates/mc-local-test" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown")
|
||||
if [ "$CERT_STATUS" = "Revoked" ]; then
|
||||
pass "Certificate status updated to Revoked"
|
||||
else
|
||||
fail "Certificate status: $CERT_STATUS (expected Revoked)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 8: Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 8: Certificate Discovery"
|
||||
|
||||
info "Checking discovered certificates..."
|
||||
DISC_RESP=$(api_get "/api/v1/discovered-certificates" 2>/dev/null || echo '{"total":0}')
|
||||
DISC_TOTAL=$(echo "$DISC_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$DISC_TOTAL" -ge 1 ]; then
|
||||
pass "Discovered $DISC_TOTAL certificate(s) on filesystem"
|
||||
else
|
||||
skip "No discovered certificates yet (agent scan may not have run)"
|
||||
fi
|
||||
|
||||
SUMMARY_RESP=$(api_get "/api/v1/discovery-summary" 2>/dev/null || echo '{}')
|
||||
echo -e " Discovery summary: $SUMMARY_RESP"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 9: Renewal (re-issue ACME cert)
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 9: Renewal"
|
||||
|
||||
# Try mc-stepca-test first (mc-local-test was revoked in Phase 7).
|
||||
# Fall back to mc-acme-test if step-ca cert isn't Active.
|
||||
RENEWAL_CERT=""
|
||||
for candidate in mc-stepca-test mc-acme-test; do
|
||||
STATUS=$(api_get "/api/v1/certificates/$candidate" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown")
|
||||
if [ "$STATUS" = "Active" ]; then
|
||||
RENEWAL_CERT="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$RENEWAL_CERT" ]; then
|
||||
skip "Cannot test renewal — no certificate in Active state"
|
||||
else
|
||||
info "Using $RENEWAL_CERT for renewal test..."
|
||||
info "Triggering renewal on $RENEWAL_CERT..."
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/$RENEWAL_CERT/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Renewal triggered"
|
||||
else
|
||||
skip "Renewal trigger returned: $RENEW_RESP"
|
||||
fi
|
||||
|
||||
info "Waiting for renewal to complete (up to 180s)..."
|
||||
if wait_for_jobs_done "$RENEWAL_CERT" 180; then
|
||||
pass "Renewal jobs completed"
|
||||
|
||||
info "Reloading NGINX to pick up renewed certificate..."
|
||||
docker exec certctl-test-nginx nginx -s reload 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# Verify version history shows multiple versions
|
||||
VERSIONS=$(api_get "/api/v1/certificates/$RENEWAL_CERT/versions" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d) if isinstance(d, list) else d.get('total', 0))" 2>/dev/null || echo 0)
|
||||
if [ "$VERSIONS" -ge 2 ]; then
|
||||
pass "Certificate has $VERSIONS versions (original + renewal)"
|
||||
else
|
||||
skip "Expected 2+ versions, got $VERSIONS"
|
||||
fi
|
||||
else
|
||||
skip "Renewal jobs did not complete within 180s"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 10: EST Enrollment (RFC 7030)
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 10: EST Enrollment (RFC 7030)"
|
||||
|
||||
# Test cacerts endpoint — should return PKCS#7 with CA cert chain
|
||||
info "Testing EST cacerts endpoint..."
|
||||
EST_CACERTS_RESP=$(curl -sf -H "${AUTH_HEADER}" "${API_URL}/.well-known/est/cacerts" 2>/dev/null || echo "ERROR")
|
||||
if [ "$EST_CACERTS_RESP" != "ERROR" ] && [ -n "$EST_CACERTS_RESP" ]; then
|
||||
# Response should be base64-encoded PKCS#7
|
||||
if echo "$EST_CACERTS_RESP" | base64 -d >/dev/null 2>&1; then
|
||||
pass "EST cacerts returns valid base64 PKCS#7 response"
|
||||
else
|
||||
fail "EST cacerts returned non-base64 data"
|
||||
fi
|
||||
else
|
||||
fail "EST cacerts endpoint failed" "$EST_CACERTS_RESP"
|
||||
fi
|
||||
|
||||
# Test csrattrs endpoint
|
||||
info "Testing EST csrattrs endpoint..."
|
||||
EST_CSRATTRS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -H "${AUTH_HEADER}" "${API_URL}/.well-known/est/csrattrs" 2>/dev/null || echo "000")
|
||||
if [ "$EST_CSRATTRS_STATUS" = "200" ] || [ "$EST_CSRATTRS_STATUS" = "204" ]; then
|
||||
pass "EST csrattrs returns $EST_CSRATTRS_STATUS"
|
||||
else
|
||||
fail "EST csrattrs returned $EST_CSRATTRS_STATUS (expected 200 or 204)"
|
||||
fi
|
||||
|
||||
# Test simpleenroll — generate CSR, POST as base64-encoded DER
|
||||
info "Testing EST simpleenroll with generated CSR..."
|
||||
EST_KEY_FILE=$(mktemp /tmp/est-key-XXXXXX.pem)
|
||||
EST_CSR_PEM_FILE=$(mktemp /tmp/est-csr-XXXXXX.pem)
|
||||
EST_CSR_DER_FILE=$(mktemp /tmp/est-csr-XXXXXX.der)
|
||||
trap "rm -f $EST_KEY_FILE $EST_CSR_PEM_FILE $EST_CSR_DER_FILE" EXIT
|
||||
|
||||
# Generate ECDSA key + CSR
|
||||
openssl ecparam -genkey -name prime256v1 -noout -out "$EST_KEY_FILE" 2>/dev/null
|
||||
openssl req -new -key "$EST_KEY_FILE" -out "$EST_CSR_PEM_FILE" -subj "/CN=est-device.certctl.test" 2>/dev/null
|
||||
openssl req -in "$EST_CSR_PEM_FILE" -out "$EST_CSR_DER_FILE" -outform DER 2>/dev/null
|
||||
|
||||
# base64-encode the DER CSR (EST wire format)
|
||||
EST_CSR_B64=$(base64 < "$EST_CSR_DER_FILE" | tr -d '\n')
|
||||
|
||||
EST_ENROLL_RESP=$(curl -sf \
|
||||
-X POST \
|
||||
-H "${AUTH_HEADER}" \
|
||||
-H "Content-Type: application/pkcs10" \
|
||||
-d "$EST_CSR_B64" \
|
||||
"${API_URL}/.well-known/est/simpleenroll" 2>/dev/null || echo "ERROR")
|
||||
|
||||
if [ "$EST_ENROLL_RESP" != "ERROR" ] && [ -n "$EST_ENROLL_RESP" ]; then
|
||||
# Response should be base64-encoded PKCS#7 containing the issued cert
|
||||
if echo "$EST_ENROLL_RESP" | base64 -d >/dev/null 2>&1; then
|
||||
pass "EST simpleenroll issued certificate via PKCS#7 response"
|
||||
else
|
||||
fail "EST simpleenroll returned non-base64 data"
|
||||
fi
|
||||
else
|
||||
fail "EST simpleenroll failed" "$(curl -s -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/pkcs10" -d "$EST_CSR_B64" "${API_URL}/.well-known/est/simpleenroll" 2>&1 | head -5)"
|
||||
fi
|
||||
|
||||
# Test simplereenroll (should work identically)
|
||||
info "Testing EST simplereenroll..."
|
||||
EST_REENROLL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "${AUTH_HEADER}" \
|
||||
-H "Content-Type: application/pkcs10" \
|
||||
-d "$EST_CSR_B64" \
|
||||
"${API_URL}/.well-known/est/simplereenroll" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$EST_REENROLL_STATUS" = "200" ]; then
|
||||
pass "EST simplereenroll works (status 200)"
|
||||
else
|
||||
fail "EST simplereenroll returned $EST_REENROLL_STATUS (expected 200)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 11: S/MIME Certificate Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 11: S/MIME Certificate Issuance"
|
||||
|
||||
info "Creating S/MIME certificate record..."
|
||||
SMIME_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-smime-test",
|
||||
"name": "smime-test-cert",
|
||||
"common_name": "testuser@certctl.test",
|
||||
"sans": ["testuser@certctl.test"],
|
||||
"issuer_id": "iss-local",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-smime",
|
||||
"environment": "staging"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$SMIME_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-smime-test'" 2>/dev/null; then
|
||||
pass "S/MIME certificate record created"
|
||||
else
|
||||
fail "S/MIME certificate creation failed" "$SMIME_RESP"
|
||||
fi
|
||||
|
||||
info "Linking S/MIME cert to target (needed for agent work routing)..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-smime-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
|
||||
info "Triggering S/MIME issuance..."
|
||||
SMIME_RENEW=$(api_post "/api/v1/certificates/mc-smime-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$SMIME_RENEW" | grep -q "renewal_triggered\|status"; then
|
||||
pass "S/MIME issuance triggered"
|
||||
else
|
||||
fail "S/MIME trigger failed" "$SMIME_RENEW"
|
||||
fi
|
||||
|
||||
info "Waiting for S/MIME issuance (up to 120s)..."
|
||||
if wait_for_jobs_done "mc-smime-test" 120; then
|
||||
pass "S/MIME jobs completed"
|
||||
|
||||
# Fetch the issued cert and verify EKU
|
||||
info "Verifying S/MIME certificate EKU..."
|
||||
SMIME_VERSIONS=$(api_get "/api/v1/certificates/mc-smime-test/versions" 2>/dev/null || echo "[]")
|
||||
SMIME_PEM=$(echo "$SMIME_VERSIONS" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
versions = data if isinstance(data, list) else data.get('data', [])
|
||||
if versions:
|
||||
print(versions[-1].get('pem_chain', versions[-1].get('pem', '')))
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$SMIME_PEM" ]; then
|
||||
# Parse the cert and check for emailProtection EKU
|
||||
SMIME_EKU=$(echo "$SMIME_PEM" | openssl x509 -noout -text 2>/dev/null | grep -A2 "Extended Key Usage" || echo "")
|
||||
if echo "$SMIME_EKU" | grep -qi "emailProtection\|E-mail Protection"; then
|
||||
pass "S/MIME cert has emailProtection EKU"
|
||||
else
|
||||
fail "S/MIME cert missing emailProtection EKU" "Got: $SMIME_EKU"
|
||||
fi
|
||||
|
||||
# Check KeyUsage flags (S/MIME should have Digital Signature + Content Commitment)
|
||||
SMIME_KU=$(echo "$SMIME_PEM" | openssl x509 -noout -text 2>/dev/null | awk '/X509v3 Key Usage:/{getline; print; exit}')
|
||||
if echo "$SMIME_KU" | grep -qi "Digital Signature"; then
|
||||
pass "S/MIME cert has Digital Signature KeyUsage"
|
||||
else
|
||||
fail "S/MIME cert missing Digital Signature KeyUsage" "Got: $SMIME_KU"
|
||||
fi
|
||||
|
||||
# Check that email SAN is present
|
||||
SMIME_SAN=$(echo "$SMIME_PEM" | openssl x509 -noout -ext subjectAltName 2>/dev/null || echo "")
|
||||
if echo "$SMIME_SAN" | grep -qi "email:testuser@certctl.test"; then
|
||||
pass "S/MIME cert has email SAN"
|
||||
else
|
||||
# Some implementations use rfc822Name instead of email:
|
||||
if echo "$SMIME_SAN" | grep -qi "testuser@certctl.test"; then
|
||||
pass "S/MIME cert has email SAN (rfc822Name)"
|
||||
else
|
||||
skip "S/MIME email SAN not found in cert (may be in CN only)"
|
||||
echo " SAN content: $SMIME_SAN"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "Could not extract S/MIME cert PEM for EKU verification"
|
||||
fi
|
||||
else
|
||||
fail "S/MIME issuance did not complete within 120s"
|
||||
info "Checking S/MIME job status..."
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for j in data.get('data', []):
|
||||
if j.get('certificate_id') == 'mc-smime-test':
|
||||
print(f\" Job {j['id']}: type={j['type']} status={j['status']} error={j.get('last_error','')}\")" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 12: API Spot Checks
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 12: API Spot Checks"
|
||||
|
||||
# Health
|
||||
if api_get "/health" >/dev/null 2>&1; then
|
||||
pass "GET /health returns 200"
|
||||
else
|
||||
fail "GET /health failed"
|
||||
fi
|
||||
|
||||
# Metrics
|
||||
METRICS_RESP=$(api_get "/api/v1/metrics" 2>/dev/null || echo "ERROR")
|
||||
if echo "$METRICS_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'gauge' in d" 2>/dev/null; then
|
||||
pass "GET /api/v1/metrics returns valid JSON"
|
||||
else
|
||||
fail "Metrics endpoint broken"
|
||||
fi
|
||||
|
||||
# Stats summary
|
||||
STATS_RESP=$(api_get "/api/v1/stats/summary" 2>/dev/null || echo "ERROR")
|
||||
if echo "$STATS_RESP" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
|
||||
pass "GET /api/v1/stats/summary returns valid JSON"
|
||||
else
|
||||
fail "Stats summary endpoint broken"
|
||||
fi
|
||||
|
||||
# Audit trail
|
||||
AUDIT_RESP=$(api_get "/api/v1/audit" 2>/dev/null || echo '{"total":0}')
|
||||
AUDIT_TOTAL=$(echo "$AUDIT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$AUDIT_TOTAL" -gt 0 ]; then
|
||||
pass "Audit trail: $AUDIT_TOTAL events recorded"
|
||||
else
|
||||
fail "Audit trail empty"
|
||||
fi
|
||||
|
||||
# Jobs summary
|
||||
JOBS_RESP=$(api_get "/api/v1/jobs" 2>/dev/null || echo '{"total":0}')
|
||||
JOBS_TOTAL=$(echo "$JOBS_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
pass "Total jobs created: $JOBS_TOTAL"
|
||||
|
||||
# Prometheus
|
||||
PROM_RESP=$(curl -sf -H "${AUTH_HEADER}" "${API_URL}/api/v1/metrics/prometheus" 2>/dev/null || echo "")
|
||||
if echo "$PROM_RESP" | grep -q "certctl_certificate_total"; then
|
||||
pass "Prometheus metrics endpoint working"
|
||||
else
|
||||
fail "Prometheus metrics endpoint broken"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Test Summary"
|
||||
|
||||
TOTAL=$((PASS + FAIL + SKIP))
|
||||
echo ""
|
||||
echo -e " ${GREEN}Passed: $PASS${NC}"
|
||||
echo -e " ${RED}Failed: $FAIL${NC}"
|
||||
echo -e " ${YELLOW}Skipped: $SKIP${NC}"
|
||||
echo -e " Total: $TOTAL"
|
||||
echo ""
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo -e "${GREEN}${BOLD}All tests passed.${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}${BOLD}$FAIL test(s) failed.${NC}"
|
||||
echo ""
|
||||
echo "Useful debug commands:"
|
||||
echo " docker logs certctl-test-server --tail 50"
|
||||
echo " docker logs certctl-test-agent --tail 50"
|
||||
echo " docker compose -f $COMPOSE_FILE ps"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/bin/sh
|
||||
# This script runs inside the certctl-server container at startup.
|
||||
# It fetches CA certificates from Pebble and step-ca, adds them to the
|
||||
# system trust store, then starts the certctl server.
|
||||
#
|
||||
# Why: The ACME connector and step-ca connector use Go's default http.Client
|
||||
# with no InsecureSkipVerify. They rely on the system trust store to verify
|
||||
# TLS connections. Pebble and step-ca both use self-signed root CAs that
|
||||
# aren't in Alpine's default CA bundle, so we must add them manually.
|
||||
#
|
||||
# This script runs as root (user: "0:0" in docker-compose) so that
|
||||
# update-ca-certificates can write to /etc/ssl/certs/.
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== certctl trust store setup ==="
|
||||
|
||||
# --- Pebble CA cert (fetched from management API) ---
|
||||
# Pebble's management API serves the root CA at /roots/0.
|
||||
# We use -k because we can't verify Pebble's TLS cert yet (chicken-and-egg).
|
||||
echo "Fetching Pebble root CA from management API..."
|
||||
PEBBLE_CA=""
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if PEBBLE_CA=$(curl -sk https://pebble:15000/roots/0 2>/dev/null); then
|
||||
if [ -n "$PEBBLE_CA" ]; then
|
||||
echo "$PEBBLE_CA" > /usr/local/share/ca-certificates/pebble-ca.crt
|
||||
echo " Added: Pebble test CA"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
echo " Waiting for Pebble (attempt $i/10)..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$PEBBLE_CA" ]; then
|
||||
echo " WARNING: Could not fetch Pebble CA. ACME issuance will fail."
|
||||
fi
|
||||
|
||||
# --- step-ca root cert (from shared volume) ---
|
||||
# The step-ca container writes its root CA to /home/step/certs/root_ca.crt.
|
||||
# We mount the step-ca data volume at /stepca-data inside this container.
|
||||
STEPCA_ROOT="/stepca-data/certs/root_ca.crt"
|
||||
echo "Waiting for step-ca root cert..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if [ -f "$STEPCA_ROOT" ]; then
|
||||
cp "$STEPCA_ROOT" /usr/local/share/ca-certificates/step-ca-root.crt
|
||||
echo " Added: step-ca root CA"
|
||||
break
|
||||
fi
|
||||
echo " Waiting for step-ca root cert (attempt $i/10)..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ ! -f "$STEPCA_ROOT" ]; then
|
||||
echo " WARNING: step-ca root cert not found at $STEPCA_ROOT"
|
||||
echo " step-ca issuance may fail until the cert is available."
|
||||
fi
|
||||
|
||||
# --- step-ca provisioner key (extracted from ca.json) ---
|
||||
# When step-ca auto-bootstraps via DOCKER_STEPCA_INIT_* env vars, the
|
||||
# encrypted provisioner key (JWE) is NOT written as a separate file.
|
||||
# Instead, it's embedded in ca.json under:
|
||||
# authority.provisioners[0].encryptedKey
|
||||
# We extract it here and write to /tmp so the certctl server can read it.
|
||||
# The stepca_data volume is mounted :ro, so we can't write there.
|
||||
STEPCA_CA_JSON="/stepca-data/config/ca.json"
|
||||
STEPCA_KEY_EXTRACTED="/tmp/step-ca-provisioner-key"
|
||||
echo "Extracting step-ca provisioner key from ca.json..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if [ -f "$STEPCA_CA_JSON" ]; then
|
||||
# Extract the encryptedKey value using grep+sed (no jq in Alpine base)
|
||||
# The field looks like: "encryptedKey": "eyJhbGciOi..."
|
||||
ENCRYPTED_KEY=$(grep -o '"encryptedKey":"[^"]*"' "$STEPCA_CA_JSON" | head -1 | sed 's/"encryptedKey":"//;s/"$//')
|
||||
if [ -z "$ENCRYPTED_KEY" ]; then
|
||||
# Try with spaces around colon (JSON formatting varies)
|
||||
ENCRYPTED_KEY=$(grep -o '"encryptedKey" *: *"[^"]*"' "$STEPCA_CA_JSON" | head -1 | sed 's/"encryptedKey" *: *"//;s/"$//')
|
||||
fi
|
||||
if [ -n "$ENCRYPTED_KEY" ]; then
|
||||
# Check if it's JWE compact serialization (dot-separated) or JSON serialization
|
||||
case "$ENCRYPTED_KEY" in
|
||||
\{*)
|
||||
# Already JSON serialization — write as-is
|
||||
echo "$ENCRYPTED_KEY" > "$STEPCA_KEY_EXTRACTED"
|
||||
;;
|
||||
*)
|
||||
# JWE compact serialization: header.encrypted_key.iv.ciphertext.tag
|
||||
# Convert to JSON serialization expected by Go decryptProvisionerKey()
|
||||
JWE_PROTECTED=$(echo "$ENCRYPTED_KEY" | cut -d. -f1)
|
||||
JWE_ENCKEY=$(echo "$ENCRYPTED_KEY" | cut -d. -f2)
|
||||
JWE_IV=$(echo "$ENCRYPTED_KEY" | cut -d. -f3)
|
||||
JWE_CT=$(echo "$ENCRYPTED_KEY" | cut -d. -f4)
|
||||
JWE_TAG=$(echo "$ENCRYPTED_KEY" | cut -d. -f5)
|
||||
printf '{"protected":"%s","encrypted_key":"%s","iv":"%s","ciphertext":"%s","tag":"%s"}' \
|
||||
"$JWE_PROTECTED" "$JWE_ENCKEY" "$JWE_IV" "$JWE_CT" "$JWE_TAG" > "$STEPCA_KEY_EXTRACTED"
|
||||
;;
|
||||
esac
|
||||
echo " Extracted provisioner key to $STEPCA_KEY_EXTRACTED"
|
||||
echo " Key file size: $(wc -c < "$STEPCA_KEY_EXTRACTED") bytes"
|
||||
echo " Key starts with: $(head -c 40 "$STEPCA_KEY_EXTRACTED")..."
|
||||
# Override the env var so the server reads from the extracted file
|
||||
export CERTCTL_STEPCA_KEY_PATH="$STEPCA_KEY_EXTRACTED"
|
||||
break
|
||||
else
|
||||
echo " ca.json found but encryptedKey not found in it (attempt $i/10)"
|
||||
fi
|
||||
else
|
||||
echo " Waiting for step-ca ca.json (attempt $i/10)..."
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ ! -f "$STEPCA_KEY_EXTRACTED" ]; then
|
||||
echo " WARNING: Could not extract step-ca provisioner key"
|
||||
echo " Listing /stepca-data/config/ for debugging:"
|
||||
ls -la /stepca-data/config/ 2>/dev/null || echo " /stepca-data/config/ does not exist"
|
||||
echo " step-ca issuance will fail."
|
||||
fi
|
||||
|
||||
# --- Update system trust store ---
|
||||
echo "Updating system CA trust store..."
|
||||
update-ca-certificates 2>/dev/null || true
|
||||
|
||||
echo "Trust store updated."
|
||||
|
||||
# --- Debug: verify configuration before starting server ---
|
||||
echo "=== Pre-launch verification ==="
|
||||
echo " CERTCTL_STEPCA_KEY_PATH=$CERTCTL_STEPCA_KEY_PATH"
|
||||
if [ -f "$CERTCTL_STEPCA_KEY_PATH" ]; then
|
||||
echo " step-ca key file exists ($(wc -c < "$CERTCTL_STEPCA_KEY_PATH") bytes)"
|
||||
echo " step-ca key preview: $(head -c 60 "$CERTCTL_STEPCA_KEY_PATH")..."
|
||||
else
|
||||
echo " WARNING: step-ca key file NOT FOUND at $CERTCTL_STEPCA_KEY_PATH"
|
||||
fi
|
||||
echo " CERTCTL_ACME_DIRECTORY_URL=$CERTCTL_ACME_DIRECTORY_URL"
|
||||
echo " CERTCTL_ACME_INSECURE=$CERTCTL_ACME_INSECURE"
|
||||
echo " Pebble CA cert: $(ls -la /usr/local/share/ca-certificates/pebble-ca.crt 2>/dev/null || echo 'NOT FOUND')"
|
||||
echo " step-ca root cert: $(ls -la /usr/local/share/ca-certificates/step-ca-root.crt 2>/dev/null || echo 'NOT FOUND')"
|
||||
echo " System CA count: $(ls /etc/ssl/certs/*.pem 2>/dev/null | wc -l) PEM files"
|
||||
echo "=== Starting certctl server ==="
|
||||
exec /app/server
|
||||
+255
-54
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
|
||||
### Design Principles
|
||||
|
||||
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
|
||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work. For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work and receive only jobs assigned to their targets (routed via `agent_id` on jobs or through target→agent relationships). For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
|
||||
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
|
||||
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
||||
@@ -61,7 +61,7 @@ flowchart TB
|
||||
API["REST API\n(Go net/http, :8443)"]
|
||||
SVC["Service Layer"]
|
||||
REPO["Repository Layer\n(database/sql + lib/pq)"]
|
||||
SCHED["Background Scheduler\n7 loops"]
|
||||
SCHED["Background Scheduler\n8 always-on + 4 optional loops"]
|
||||
DASH["Web Dashboard\n(React SPA)"]
|
||||
end
|
||||
|
||||
@@ -80,15 +80,30 @@ flowchart TB
|
||||
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
||||
CA3["step-ca\n(/sign API)"]
|
||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||
CA6["Vault PKI\n(planned)"]
|
||||
CA6["Vault PKI\n(token auth, /sign API)"]
|
||||
CA7["DigiCert CertCentral\n(async order model)"]
|
||||
CA8["Sectigo SCM\n(async order model)"]
|
||||
CA9["Google CAS\n(OAuth2, sync)"]
|
||||
CA10["AWS ACM PCA\n(sync issuance)"]
|
||||
CA11["Entrust\n(mTLS, sync/async)"]
|
||||
CA12["GlobalSign Atlas\n(mTLS + API key)"]
|
||||
CA13["EJBCA\n(mTLS or OAuth2)"]
|
||||
end
|
||||
|
||||
subgraph "Target Systems"
|
||||
T1["NGINX\n(file write + reload)"]
|
||||
T4["Apache httpd\n(file write + reload)"]
|
||||
T5["HAProxy\n(combined PEM + reload)"]
|
||||
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
||||
T6["Traefik\n(file provider)"]
|
||||
T7["Caddy\n(admin API / file)"]
|
||||
T8["Envoy\n(file-based SDS)"]
|
||||
T9["Postfix/Dovecot\n(file + service reload)"]
|
||||
T2["F5 BIG-IP\n(proxy agent + iControl REST)"]
|
||||
T3["IIS\n(WinRM + local)"]
|
||||
T10["SSH\n(SFTP + reload)"]
|
||||
T11["WinCertStore\n(PowerShell import)"]
|
||||
T12["Java Keystore\n(keytool pipeline)"]
|
||||
T13["Kubernetes Secrets\n(K8s API)"]
|
||||
end
|
||||
|
||||
DASH --> API
|
||||
@@ -96,7 +111,7 @@ flowchart TB
|
||||
SVC --> REPO
|
||||
REPO --> PG
|
||||
SCHED --> SVC
|
||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3
|
||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 & CA4 & CA6 & CA7 & CA8 & CA9 & CA10
|
||||
|
||||
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
|
||||
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
|
||||
@@ -116,7 +131,7 @@ The server exposes a REST API under `/api/v1/` and optionally serves the web das
|
||||
|
||||
### Agents
|
||||
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy fully implemented; F5 BIG-IP, IIS interface only with V2 implementations planned) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
|
||||
The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for actionable jobs via `GET /api/v1/agents/{id}/work`. Jobs may be `AwaitingCSR` (agent needs to generate key + submit CSR) or `Deployment` (agent needs to deploy a certificate). Private keys are stored in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`) with 0600 permissions.
|
||||
|
||||
@@ -124,11 +139,23 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it
|
||||
|
||||
**Agent groups (M11b):** Dynamic device grouping allows organizing agents by metadata criteria. Agent groups can match by OS, architecture, IP CIDR, and version. Groups support both dynamic matching (agents automatically join when criteria match) and manual membership (explicit include/exclude). Renewal policies can be scoped to agent groups via the `agent_group_id` foreign key. The GUI provides full CRUD management for agent groups with visual match criteria badges.
|
||||
|
||||
**Agent soft-retirement (I-004):** `DELETE /api/v1/agents/{id}` is a soft-delete surface — the row is never removed. Retirement stamps `agents.retired_at` (TIMESTAMPTZ) and `agents.retired_reason` (TEXT) and flips the operational status to `Offline`. Default listings (`GET /api/v1/agents`, the dashboard stats counter, and the stale-offline sweeper) filter retired rows out via `AgentRepository.ListActive`; retired rows are surfaced only through the opt-in `GET /api/v1/agents/retired` view. The endpoint follows a preflight → block → escape-hatch contract:
|
||||
|
||||
- **Clean retire** (no active dependencies) — `200 OK` with `RetireAgentResponse` (`cascade=false`, zero counts).
|
||||
- **Blocked by active dependencies** — `409 Conflict` with `BlockedByDependenciesResponse`. The three counts (`active_targets`, `active_certificates`, `pending_jobs`) tell the operator exactly which rows would be orphaned. The schema diverges from `ErrorResponse` because downstream dashboards parse the stable three-key shape.
|
||||
- **Force cascade** — `DELETE /api/v1/agents/{id}?force=true&reason=...`. `reason` is required (400 otherwise). Transactionally soft-retires downstream `deployment_targets`, cancels pending jobs, and soft-retires the agent, emitting an `agent_retirement_cascaded` audit event with actor + reason + per-bucket counts.
|
||||
- **Idempotent re-retire** — a retire attempt against an already-retired agent returns `204 No Content` with an empty body (no second audit event, no response shape — callers that POST again on a retry get a clean no-op).
|
||||
- **Sentinel refusal** — the four sentinel agent IDs (`server-scanner`, `cloud-aws-sm`, `cloud-azure-kv`, `cloud-gcp-sm`) back non-agent discovery subsystems (the network scanner and the three cloud secret-manager sources). They are refused unconditionally — even with `force=true` — via `ErrAgentIsSentinel` → `403 Forbidden`. The ID list lives in `internal/domain/connector.go` (`SentinelAgentIDs`) so handler, repository, and scheduler code can filter them without importing `service`.
|
||||
|
||||
Retired agents receive `410 Gone` on subsequent heartbeats (`service.ErrAgentRetired`). `cmd/agent` treats 410 as a terminal signal and exits cleanly so retired agents stop phoning home. Migration `000015` flipped `deployment_targets.agent_id` from `ON DELETE CASCADE` to `ON DELETE RESTRICT`, making the old hard-delete path a schema error and forcing all retirement through this contract.
|
||||
|
||||
**Registration is by-design pull-only (C-1 closure, cat-b-6177f36636fb).** Agents register themselves at first heartbeat via `install-agent.sh` + `cmd/agent/main.go` — never via the GUI. The `web/src/api/client.ts::registerAgent` client function is intentionally orphan in the dashboard for this reason. It's preserved in `client.ts` (rather than deleted) so future features that want to drive registration from the GUI — for example, a one-click "register proxy agent" panel for network-appliance topologies where the agent runs in a different network zone from the device it manages — can reach the endpoint without a `client.ts` edit. Operators looking to scale agent enrollment use `install-agent.sh` against a config-management system (Ansible, Salt, Puppet) or a baked-in cloud-init script, not the dashboard.
|
||||
|
||||
### Web Dashboard
|
||||
|
||||
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
|
||||
|
||||
**Current views** (21 pages): certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info + OS/architecture grouping with charts), job queue (status, retry, cancel, approve/reject for AwaitingApproval jobs), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), discovered certificates triage (claim/dismiss unmanaged certs discovered by agents or network scans), network scan targets management (CRUD for network scan targets + Scan Now button), summary dashboard with charts (expiration heatmap, renewal success rate, status distribution, issuance rate), and login page.
|
||||
**Current views** (24 pages): certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info + OS/architecture grouping with charts), job queue (list + detail with verification section, timeline, audit events; approve/reject for AwaitingApproval jobs), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (catalog with 10 type cards + 3-step create wizard + detail with test connection), targets (list with 3-step configuration wizard + detail with deployment history), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), discovered certificates triage (claim/dismiss unmanaged certs discovered by agents or network scans), network scan targets management (CRUD + Scan Now button), summary dashboard with charts (expiration heatmap, renewal success rate, status distribution, issuance rate), digest preview and send, observability (health, metrics, Prometheus config), and login page.
|
||||
|
||||
The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
|
||||
|
||||
@@ -138,6 +165,10 @@ The dashboard includes an **ErrorBoundary component** for graceful error recover
|
||||
- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography
|
||||
- SSE/WebSocket planned for real-time job status updates
|
||||
|
||||
**Backend ↔ frontend round-trip rule (B-1 closure):** every backend CRUD operation must have at least one GUI consumer in `web/src/pages/`. Shipping a handler + repository method + OpenAPI operation + `client.ts` fetcher with no page that calls it leaves operators forced to `psql` directly — defeats the "every backend feature ships with its GUI surface" invariant and creates a destructive workflow when the missing path is `update*` (operators delete-and-recreate, losing FK history and audit-trail continuity). The CI guardrail in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) enforces this for the eight previously-orphan functions (`updateOwner`/`updateTeam`/`updateAgentGroup`/`updateIssuer`/`updateProfile` + `createRenewalPolicy`/`updateRenewalPolicy`/`deleteRenewalPolicy`); apply the same rule when adding any new write endpoint. If a fetcher is needed in `client.ts` before its consumer page exists, leave a TODO referencing this rule and ship them in the same commit.
|
||||
|
||||
**TS ↔ Go type contract rule (D-1 + D-2 closure):** every TypeScript interface in `web/src/api/types.ts` must field-match the Go-side `internal/domain/*.go` struct's JSON-emitted shape exactly. Phantom fields (declared on TS, never emitted by Go) silently render `'—'` and lull consumers into thinking a value will arrive that never does; missing fields (emitted by Go, absent from TS) force `(x as any).X` escapes that lose type-checking. Both failure modes are blocked by the CI guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) which awk-windows each interface and grep-fails the build on phantom-field reintroduction — currently covers Certificate (D-1), Agent / Issuer / Notification (D-2). Apply the same rule when adding any new on-wire type: the Go-side json tag is the contract, the TS interface adapts to it, and a literal-construction Vitest in `web/src/api/types.test.ts` pins the post-add shape. Stricter side wins: when in doubt, the side that actually emits the field is the contract; never propose adding a phantom on Go to match a TS over-declaration.
|
||||
|
||||
### PostgreSQL Database
|
||||
|
||||
All state is stored in PostgreSQL 16. The schema uses TEXT primary keys (not UUIDs) with human-readable prefixed IDs like `mc-api-prod`, `t-platform`, `o-alice`.
|
||||
@@ -260,6 +291,9 @@ erDiagram
|
||||
text channel
|
||||
text recipient
|
||||
text status
|
||||
int retry_count
|
||||
timestamptz next_retry_at
|
||||
text last_error
|
||||
}
|
||||
certificate_profiles {
|
||||
text id PK
|
||||
@@ -320,7 +354,12 @@ erDiagram
|
||||
}
|
||||
```
|
||||
|
||||
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL.
|
||||
The ER diagram above documents **database shape**, not REST-API wire shape. Several columns are intentionally server-internal and never serialized to clients:
|
||||
|
||||
- `agents.api_key_hash` — SHA-256 of the agent's plaintext API key, populated by `service.RegisterAgent` (`hashAPIKey(apiKey)` at `internal/service/agent.go`) and consumed by `repository.AgentRepository::GetByAPIKey` for the auth-lookup. **Not** exposed via the REST API, **not** echoed via CLI / MCP / agent registration response, **never** logged. Enforced by `internal/domain/connector.go::Agent.MarshalJSON` (G-2 audit closure, `cat-s5-apikey_leak`); the OpenAPI Agent schema explicitly excludes the field, the frontend `Agent` interface omits it, and a CI grep guardrail at `.github/workflows/ci.yml` blocks reintroduction.
|
||||
- `issuers.config` / `deployment_targets.config` — plaintext jsonb shadow of the AES-GCM-encrypted on-disk blob; the encrypted form lives on `EncryptedConfig []byte` (Go-only field tagged `json:"-"`).
|
||||
|
||||
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times. Pre-U-3 (`cat-u-seed_initdb_schema_drift`, GitHub #10) the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/` so initdb applied them on first boot, *and* the server re-applied the same files via `RunMigrations` on every start. The dual source of truth was the bug: every time a migration shipped that the seed depended on (e.g., 000013 added `policy_rules.severity`), the mount list had to be updated by hand, and missing the update crashed initdb on first boot. Post-U-3 the server is the single source of truth: postgres comes up with an empty schema, `RunMigrations` applies the entire ladder, then `RunSeed` lands the baseline seed (and `RunDemoSeed` lands the demo overlay when `CERTCTL_DEMO_SEED=true`). Helm has used this pattern since day one (postgres-init `emptyDir`); the docker-compose deploy now matches.
|
||||
|
||||
## Data Flow: Certificate Lifecycle
|
||||
|
||||
@@ -381,7 +420,11 @@ sequenceDiagram
|
||||
Note over A: Agent deploys using locally-held private key
|
||||
```
|
||||
|
||||
**Profile enforcement:** If the certificate is assigned to a profile (`certificate_profile_id`), the profile's `allowed_key_algorithms` and `max_validity_days` constraints are checked during CSR validation. A CSR with a disallowed key type or a validity period exceeding the profile maximum is rejected before reaching the issuer connector.
|
||||
**Profile enforcement (M11c):** Crypto policy enforcement is wired into all four issuance paths: renewal (server-side and agent CSR), agent fallback CSR signing, EST enrollment (RFC 7030), and SCEP enrollment (RFC 8894). At each path, the service layer resolves the certificate's profile and calls `ValidateCSRAgainstProfile()` to check the CSR key algorithm and minimum key size against the profile's `allowed_key_algorithms` rules. A CSR with a disallowed key type or insufficient key size is rejected before reaching the issuer connector.
|
||||
|
||||
**MaxTTL enforcement:** When a profile specifies `max_ttl_seconds`, the value is forwarded through the service-layer `IssuerConnector` interface to the connector layer via `MaxTTLSeconds` on `IssuanceRequest` and `RenewalRequest`. Each issuer connector enforces the cap according to its capabilities: the Local CA caps `NotAfter` directly, Vault overrides its TTL string, step-ca caps `NotAfter` with zero-value handling, and OpenSSL logs an advisory warning (script-based signing can't enforce server-side). For CAs that control validity themselves (ACME, DigiCert, Sectigo, Google CAS, AWS ACM PCA), MaxTTLSeconds passes through but the CA makes the final decision.
|
||||
|
||||
**Key metadata persistence:** Certificate versions record `key_algorithm` and `key_size` extracted from the CSR during issuance. This metadata enables post-hoc auditing — operators can verify that all issued certificates comply with the key requirements in effect at the time of issuance.
|
||||
|
||||
#### Server-Side Key Generation (Demo Only)
|
||||
|
||||
@@ -413,8 +456,8 @@ The agent deploys certificates using target connectors. Each connector knows how
|
||||
- **NGINX**: Writes cert/chain/key files to disk, validates config with `nginx -t`, reloads with `nginx -s reload` or `systemctl reload nginx`
|
||||
- **Apache httpd**: Writes separate cert/chain/key files, validates with `apachectl configtest`, graceful reload
|
||||
- **HAProxy**: Builds a combined PEM file (cert + chain + key), optionally validates config, reloads via systemctl or signal
|
||||
- **F5 BIG-IP** (planned): A proxy agent in the same network zone calls the iControl REST API to upload certificate and update SSL profile bindings. The server assigns the work; the proxy agent executes it.
|
||||
- **IIS** (planned, dual-mode): (1) Agent-local (recommended) — a Windows agent on the IIS box runs PowerShell `Import-PfxCertificate` + `Set-WebBinding` directly. (2) Proxy agent WinRM — for agentless IIS targets, a nearby Windows agent reaches the IIS box via WinRM.
|
||||
- **F5 BIG-IP**: A proxy agent in the same network zone calls the iControl REST API to upload certificate/key files, install crypto objects, and update the SSL client profile within an atomic transaction. The server assigns the work; the proxy agent executes it.
|
||||
- **IIS** (implemented, dual-mode): (1) Agent-local (recommended) — a Windows agent on the IIS box runs PowerShell `Import-PfxCertificate` + `Set-WebBinding` directly with PFX conversion and SHA-1 thumbprint computation. (2) Proxy agent WinRM — for agentless IIS targets, a nearby Windows agent reaches the IIS box via WinRM.
|
||||
|
||||
The agent handles both the certificate (public) and the private key (read from local key store at `CERTCTL_KEY_DIR`). The control plane never sees the private key and never initiates outbound connections to agents or targets (pull-only model).
|
||||
|
||||
@@ -444,46 +487,65 @@ sequenceDiagram
|
||||
API-->>U: 200 OK
|
||||
```
|
||||
|
||||
The revocation is recorded in the `certificate_revocations` table (separate from the certificate status update) for CRL generation. The DER-encoded CRL at `GET /api/v1/crl/{issuer_id}` is generated on-demand by querying this table and signing with the issuing CA's key. The OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` checks both the certificate status and the revocations table to return signed good/revoked/unknown responses.
|
||||
The revocation is recorded in the `certificate_revocations` table (separate from the certificate status update) for CRL generation. The DER-encoded CRL at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615) is generated on-demand by querying this table and signing with the issuing CA's key. The OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960) checks both the certificate status and the revocations table to return signed good/revoked/unknown responses. Both endpoints are served unauthenticated — relying parties (TLS clients, hardware appliances, browsers) must be able to reach them without a certctl API key — and carry the IANA-registered media types `application/pkix-crl` and `application/ocsp-response` respectively.
|
||||
|
||||
Short-lived certificates (those with profile TTL < 1 hour) return "good" from OCSP and are excluded from CRL — their rapid expiry is treated as sufficient revocation.
|
||||
|
||||
#### Bulk Revocation
|
||||
|
||||
For compliance events requiring fleet-wide revocation (key compromise, CA distrust, mass decommission), certctl supports bulk revocation by filter criteria. The `POST /api/v1/certificates/bulk-revoke` endpoint accepts filter parameters (profile_id, owner_id, agent_id, issuer_id) and creates individual revocation jobs for each matching certificate. Bulk revocation reuses the same 7-step single-cert flow for each certificate — no new issuer notification or audit mechanics. The operation is idempotent: revoking an already-revoked certificate is a no-op. Partial failures are tolerated — if one certificate fails to revoke (e.g., issuer unavailable), the operation continues for remaining certs and returns a summary. A single `bulk_revocation_initiated` audit event logs the operation with filter criteria, operator actor, and summary (total requested, succeeded, failed counts). Audit events for individual certificate revocations record the operator identity separately. The GUI bulk revoke button on the certificates list filters by visible selections and displays an affected-cert count modal before confirmation.
|
||||
|
||||
### 4. Automatic Renewal
|
||||
|
||||
The control plane runs a scheduler with seven background loops:
|
||||
The control plane runs a scheduler with 8 always-on loops plus up to 4 optional loops (enabled by configuration). `internal/scheduler/scheduler.go:262-265` is the authoritative count.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Scheduler (Background Goroutines)"
|
||||
R["Renewal Checker\n⏱ every 1h"]
|
||||
J["Job Processor\n⏱ every 30s"]
|
||||
JR["Job Retry\n⏱ every 5m"]
|
||||
JT["Job Timeout\n⏱ every 10m"]
|
||||
H["Agent Health\n⏱ every 2m"]
|
||||
N["Notification Processor\n⏱ every 1m"]
|
||||
NR["Notification Retry\n⏱ every 2m"]
|
||||
SL["Short-Lived Expiry\n⏱ every 30s"]
|
||||
NS["Network Scanner\n⏱ every 6h"]
|
||||
DG["Certificate Digest\n⏱ every 24h"]
|
||||
HC["Endpoint Health\n⏱ every 60s"]
|
||||
CD["Cloud Discovery\n⏱ every 6h"]
|
||||
end
|
||||
|
||||
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
|
||||
J -->|"Process pending jobs\nCoordinate issuance"| DB
|
||||
JR -->|"Retry Failed jobs\nFailed→Pending"| DB
|
||||
JT -->|"Reap stalled AwaitingCSR / AwaitingApproval jobs"| DB
|
||||
H -->|"Check heartbeat staleness\nMark agents offline"| DB
|
||||
N -->|"Send pending notifications\nEmail / Webhook / Slack"| DB
|
||||
NR -->|"Retry failed notifications\n2^n-min backoff, DLQ after 5 attempts"| DB
|
||||
SL -->|"Expire short-lived certs\nMark as Expired"| DB
|
||||
NS -->|"Probe TLS endpoints\nStore discovered certs"| DB
|
||||
DG -->|"Generate & send HTML digest\nEmail to recipients"| DB
|
||||
HC -->|"Probe deployed TLS endpoints\nState machine + mismatch"| DB
|
||||
CD -->|"AWS SM / Azure KV / GCP SM\nFeed discovery pipeline"| DB
|
||||
```
|
||||
|
||||
| Loop | Interval | Timeout | Purpose |
|
||||
|------|----------|---------|---------|
|
||||
| Renewal checker | 1 hour | 5 minutes | Finds certificates approaching expiry, creates renewal jobs |
|
||||
| Job processor | 30 seconds | 2 minutes | Processes pending jobs (issuance, renewal, deployment) |
|
||||
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
|
||||
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
|
||||
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
||||
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`). CIDR size validated at API level — max /20 (4096 IPs) per range. |
|
||||
| Certificate digest | 24 hours | 5 minutes | Generates HTML email with certificate stats, expiration timeline, job health, agent count. Does NOT run on startup — waits for first scheduled tick. Configurable interval and recipients via `CERTCTL_DIGEST_INTERVAL` and `CERTCTL_DIGEST_RECIPIENTS`. Falls back to certificate owner emails if no explicit recipients configured. |
|
||||
| Loop | Interval | Always-on? | Purpose |
|
||||
|------|----------|------------|---------|
|
||||
| Renewal checker | 1 hour | Yes | Finds certificates approaching expiry (threshold-based or ARI-directed), creates renewal jobs |
|
||||
| Job processor | 30 seconds | Yes | Processes pending jobs (issuance, renewal, deployment) |
|
||||
| Job retry | 5 minutes (`CERTCTL_SCHEDULER_RETRY_INTERVAL`) | Yes | Transitions `Failed` jobs back to `Pending` for re-dispatch (I-001) |
|
||||
| Job timeout | 10 minutes (`CERTCTL_JOB_TIMEOUT_INTERVAL`) | Yes | Reaps `AwaitingCSR` jobs older than 24h and `AwaitingApproval` jobs older than 7d to `Failed`, feeding the retry loop (I-003) |
|
||||
| Agent health check | 2 minutes | Yes | Marks agents as offline if heartbeat is stale |
|
||||
| Notification processor | 1 minute | Yes | Sends pending notifications via configured channels |
|
||||
| Notification retry | 2 minutes (`CERTCTL_NOTIFICATION_RETRY_INTERVAL`) | Yes | Re-dispatches `Failed` notifications whose `next_retry_at` has elapsed; exponential backoff (2^n minutes, capped at 1h), 5-attempt budget, terminal `dead` status after exhaustion (I-005) |
|
||||
| Short-lived expiry | 30 seconds | Yes | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
||||
| Network scanner | 6 hours | Opt-in (`CERTCTL_NETWORK_SCAN_ENABLED`) | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21). CIDR size validated at API level — max /20 (4096 IPs) per range. |
|
||||
| Certificate digest | 24 hours (`CERTCTL_DIGEST_INTERVAL`) | Opt-in (digest service) | Generates HTML email with certificate stats, expiration timeline, job health, agent count. Does NOT run on startup — waits for first scheduled tick. Falls back to certificate owner emails if no explicit recipients configured. |
|
||||
| Endpoint health | 60 seconds (`CERTCTL_HEALTH_CHECK_INTERVAL`) | Opt-in (health check service) | Probes deployed TLS endpoints, drives the healthy/degraded/down/cert_mismatch state machine (M48) |
|
||||
| Cloud discovery | 6 hours | Opt-in (at least one cloud source configured) | Walks AWS Secrets Manager / Azure Key Vault / GCP Secret Manager, feeds discovery pipeline (M50) |
|
||||
|
||||
Each loop uses `sync/atomic.Bool` idempotency guards to prevent concurrent tick execution — if a loop iteration is still running when the next tick fires, the tick is skipped with a warning log. All loops (including short-lived expiry check) run immediately on startup before entering their ticker interval, ensuring no gap between scheduler start and first execution. The certificate digest loop is the exception — it does NOT run on startup, only on scheduled ticks. Graceful shutdown uses `sync.WaitGroup` with `WaitForCompletion()` to drain all in-flight work before process exit.
|
||||
Each loop uses `sync/atomic.Bool` idempotency guards to prevent concurrent tick execution — if a loop iteration is still running when the next tick fires, the tick is skipped with a warning log. Most loops (including short-lived expiry, job retry, job timeout, and notification retry) run immediately on startup before entering their ticker interval, ensuring no gap between scheduler start and first execution. The certificate digest loop is the exception — it does NOT run on startup, only on scheduled ticks. Graceful shutdown uses `sync.WaitGroup` with `WaitForCompletion()` to drain all in-flight work before process exit.
|
||||
|
||||
Each operation has a context timeout to prevent indefinite hangs if external services become unresponsive.
|
||||
|
||||
@@ -504,9 +566,16 @@ flowchart TB
|
||||
II["IssuerConnector Interface\nIssueCertificate() | RenewCertificate()\nRevokeCertificate() | GetOrderStatus()"]
|
||||
II --> LC["Local CA"]
|
||||
II --> ACME["ACME v2"]
|
||||
II --> SC["step-ca"]
|
||||
II --> SCA["step-ca"]
|
||||
II --> OC["OpenSSL / Custom CA"]
|
||||
II --> VP["Vault PKI (planned)"]
|
||||
II --> VP["Vault PKI"]
|
||||
II --> DC["DigiCert CertCentral"]
|
||||
II --> SG["Sectigo SCM"]
|
||||
II --> GC["Google CAS"]
|
||||
II --> AP2["AWS ACM PCA"]
|
||||
II --> EN["Entrust"]
|
||||
II --> GS["GlobalSign Atlas"]
|
||||
II --> EJ["EJBCA"]
|
||||
end
|
||||
|
||||
subgraph "Target Connectors"
|
||||
@@ -517,8 +586,14 @@ flowchart TB
|
||||
TI --> HP["HAProxy"]
|
||||
TI --> TF["Traefik"]
|
||||
TI --> CD["Caddy"]
|
||||
TI --> F5["F5 BIG-IP (interface only)"]
|
||||
TI --> IIS["IIS (interface only)"]
|
||||
TI --> EV["Envoy"]
|
||||
TI --> PO["Postfix/Dovecot"]
|
||||
TI --> IIS["IIS"]
|
||||
TI --> F5["F5 BIG-IP"]
|
||||
TI --> SSH["SSH"]
|
||||
TI --> WCS["WinCertStore"]
|
||||
TI --> JKS["Java Keystore"]
|
||||
TI --> K8S["K8s Secrets"]
|
||||
end
|
||||
|
||||
subgraph "Notifier Connectors"
|
||||
@@ -570,9 +645,9 @@ type Connector interface {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||
Built-in issuers (9 connectors): **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), **DigiCert** (commercial CA via CertCentral REST API with async order processing), **Sectigo SCM** (async order model with 3-header auth), **Google CAS** (Cloud Certificate Authority Service with OAuth2 service account auth), and **AWS ACM Private CA** (synchronous issuance via ACM PCA API). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||
|
||||
**ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
|
||||
**ACME Renewal Information (ARI, RFC 9773):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9773. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
|
||||
|
||||
The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
||||
|
||||
@@ -590,11 +665,11 @@ type Connector interface {
|
||||
|
||||
The `DeploymentRequest` struct carries the full material needed by the target system: the signed certificate, the CA chain, the agent-generated private key, target-specific configuration, and arbitrary metadata. The key field is populated by the agent from its local key store (`CERTCTL_KEY_DIR`) — it never originates from the control plane.
|
||||
|
||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **Traefik** (file provider — writes cert/key to watched directory, Traefik auto-reloads), **Caddy** (dual-mode: admin API hot-reload or file-based), **F5 BIG-IP** (interface only — proxy agent + iControl REST, implementation planned), **IIS** (interface only — dual-mode: agent-local PowerShell primary + proxy agent WinRM for agentless targets, implementation planned).
|
||||
Built-in targets (14 connector types): **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **Traefik** (file provider — writes cert/key to watched directory, Traefik auto-reloads), **Caddy** (dual-mode: admin API hot-reload or file-based), **Envoy** (file-based with optional SDS JSON config), **F5 BIG-IP** (proxy agent + iControl REST, transaction-based atomic SSL profile updates), **IIS** (dual-mode: agent-local PowerShell + proxy agent WinRM for agentless targets), **Postfix/Dovecot** (file write + service reload), **SSH** (agentless deployment via SSH/SFTP), **Windows Certificate Store** (PowerShell-based cert import, dual-mode local/WinRM), **Java Keystore** (PEM → PKCS#12 → keytool pipeline, JKS and PKCS12 formats), **Kubernetes Secrets** (deploys as `kubernetes.io/tls` Secrets via injectable K8sClient interface, in-cluster or kubeconfig auth).
|
||||
|
||||
After deployment, agents can perform **post-deployment TLS verification**: the agent probes the live TLS endpoint using `crypto/tls.DialWithDialer` and compares the SHA-256 fingerprint of the served certificate against what was deployed. Results are reported via `POST /api/v1/jobs/{id}/verify` and stored on the job record. Verification is best-effort — failures don't block or rollback deployments.
|
||||
|
||||
Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
||||
The SSH connector enables agentless deployment to any Linux/Unix server via SSH/SFTP, using the proxy agent pattern. The Kubernetes Secrets connector deploys certificates as `kubernetes.io/tls` Secrets via an injectable K8sClient interface supporting both in-cluster and out-of-cluster auth.
|
||||
|
||||
### Notifier Connector
|
||||
|
||||
@@ -612,6 +687,16 @@ Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incomi
|
||||
|
||||
See the [Connector Development Guide](connectors.md) for details on building custom connectors.
|
||||
|
||||
### Notification Retry & Dead-Letter Queue
|
||||
|
||||
A transient notifier failure (SMTP timeout, 5xx webhook response, Slack rate-limit) must not silently drop a critical alert. Migration `000016_notification_retry` adds three columns to `notification_events` — `retry_count INTEGER NOT NULL DEFAULT 0`, `next_retry_at TIMESTAMPTZ` (nullable — only meaningful while a row is in `failed` state), and `last_error TEXT` (the most recent transient error, preserved for operator triage) — together with a partial index `idx_notification_events_retry_sweep ON notification_events(next_retry_at) WHERE status = 'failed' AND next_retry_at IS NOT NULL` so the retry hot path scales with the retry-eligible slice rather than the full notification history.
|
||||
|
||||
The scheduler's notification-retry loop (see the scheduler section above) calls `NotificationService.RetryFailedNotifications(ctx)` every `CERTCTL_NOTIFICATION_RETRY_INTERVAL` (default `2m`). Each tick pulls up to 1000 rows via `notifRepo.ListRetryEligible(ctx, now, maxAttempts, sweepLimit)` — a partial-index-driven query that filters on `status='failed' AND next_retry_at <= now() AND retry_count < 5` — and redispatches them through the same notifier registry used by `ProcessPendingNotifications`. A successful redispatch transitions the row directly to `sent` without incrementing `retry_count`, so the audit trail preserves "delivered on attempt N". A failed redispatch re-arms `next_retry_at` using exponential backoff — `wait = min(2^retry_count minutes, 1h)` — bumps `retry_count`, and stamps `last_error`. When `retry_count >= 4` (the fifth attempt has just failed) the row is promoted to the terminal `dead` status via `notifRepo.MarkAsDead`, which clears `next_retry_at` so the partial retry-sweep index stops matching and the row cannot be re-entered into the retry rotation without operator action.
|
||||
|
||||
`NotificationService.RequeueNotification(ctx, id)` is the operator-driven escape hatch from `dead`. It atomically resets `retry_count → 0`, `next_retry_at → NULL`, `last_error → NULL`, and `status → pending`, handing the row back to `ProcessPendingNotifications` on the next 1m tick. This is the correct response to "the notifier outage is resolved, redeliver the queue"; it is not a retry, which is why the retry counter is reset rather than incremented.
|
||||
|
||||
The dead-letter depth is surfaced in two places. First, `DashboardSummary.NotificationsDead` is populated by `StatsService.GetDashboardSummary` via `notifRepo.CountByStatus(ctx, "dead")`. The injection uses a `SetNotifRepo` setter pattern (mirroring `CertificateService.SetTargetRepo`) rather than a new positional argument to `NewStatsService`, which keeps all nine existing `NewStatsService` call sites (main.go plus eight digest tests and stats_test.go) signature-stable — when the notification repository has not been wired in, `NotificationsDead` falls through to zero. Second, the `/api/v1/metrics/prometheus` endpoint emits `certctl_notification_dead_total` as a counter (operator alert thresholds per the I-005 spec: `> 0` warning, `> 10` critical) using the same `DashboardSummary` snapshot so the dashboard card and the Prometheus counter cannot skew. The web dashboard exposes a two-tab toolbar on `/notifications` — "All" (the pre-I-005 inbox) and "Dead letter" (threads `?status=dead` into the list query, surfaces `Retry N/5` and the truncated `last_error` with a full-text tooltip per row, and binds a Requeue button to `POST /api/v1/notifications/{id}/requeue`).
|
||||
|
||||
### EST Server (RFC 7030)
|
||||
|
||||
The EST (Enrollment over Secure Transport) server provides an industry-standard enrollment interface for devices that need certificates without using the REST API. It runs under `/.well-known/est/` per RFC 7030 and supports four operations: CA certificate distribution (`/cacerts`), initial enrollment (`/simpleenroll`), re-enrollment (`/simplereenroll`), and CSR attributes (`/csrattrs`).
|
||||
@@ -647,10 +732,52 @@ type ESTService interface {
|
||||
}
|
||||
```
|
||||
|
||||
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA returns its CA certificate PEM; Vault PKI fetches via `GET /v1/{mount}/ca/pem`; Google CAS fetches via API; AWS ACM PCA retrieves via `GetCertificateAuthorityCertificate`. ACME, step-ca, OpenSSL, DigiCert, and Sectigo connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||
|
||||
**Authentication:** EST endpoints are served unauthenticated at the HTTP layer under `/.well-known/est/*` — no Bearer token required. Per RFC 7030 §3.2.3 EST authentication is deployment-specific, and per §4.1.1 `/cacerts` is explicitly anonymous. certctl enforces authentication via CSR signature verification inside `ESTService.SimpleEnroll`/`SimpleReEnroll` plus profile policy gates (allowed key algorithms, minimum key size, permitted SANs, permitted EKUs, MaxTTL). The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes `/.well-known/est/*` through `noAuthHandler` (RequestID + structuredLogger + Recovery only). Operators who need stronger client identification should terminate mTLS at an upstream reverse proxy and pin the CSR's SAN to the client cert subject at the profile level.
|
||||
|
||||
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
|
||||
|
||||
### SCEP Server (RFC 8894)
|
||||
|
||||
The SCEP (Simple Certificate Enrollment Protocol) server provides certificate enrollment for MDM platforms and network devices. It runs at `/scep` with operation-based dispatch via query parameters per RFC 8894.
|
||||
|
||||
**Architecture:** SCEP follows the exact same layering as EST — a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. The `SCEPService` bridges the `SCEPHandler` to whichever issuer connector is configured via `CERTCTL_SCEP_ISSUER_ID`.
|
||||
|
||||
```
|
||||
Client (MDM, network device, SCEP client)
|
||||
│
|
||||
▼
|
||||
SCEPHandler (handler layer)
|
||||
│ PKCS#7 envelope parsing, CSR extraction, challenge password extraction
|
||||
▼
|
||||
SCEPService (service layer)
|
||||
│ Challenge password validation, CSR validation, CN/SAN extraction, audit recording
|
||||
▼
|
||||
IssuerConnector (connector layer via IssuerConnectorAdapter)
|
||||
│ Certificate signing (Local CA, step-ca, etc.)
|
||||
▼
|
||||
Signed certificate returned as PKCS#7 certs-only
|
||||
```
|
||||
|
||||
**Wire format:** SCEP clients wrap CSRs in PKCS#7 SignedData envelopes. The handler parses the outer ASN.1 ContentInfo → SignedData → EncapsulatedContentInfo to extract the CSR bytes. Fallback paths handle base64-encoded PKCS#7 and raw CSR submissions (for simpler clients). Responses use PKCS#7 certs-only via the shared `internal/pkcs7` package (same as EST). Single certs are returned as raw DER for `GetCACert`, chains as PKCS#7.
|
||||
|
||||
**Authentication:** SCEP endpoints at `/scep` and `/scep/*` are served unauthenticated at the HTTP layer — no Bearer token required — per RFC 8894 §3.2, which defines authentication via the `challengePassword` attribute (OID 1.2.840.113549.1.9.7) embedded in the PKCS#10 CSR rather than an HTTP credential. The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes `/scep` and `/scep/*` through `noAuthHandler` (RequestID + structuredLogger + Recovery only). The `challengePassword` is mandatory: `preflightSCEPChallengePassword` at startup refuses to boot the control plane when `CERTCTL_SCEP_ENABLED=true` is set without `CERTCTL_SCEP_CHALLENGE_PASSWORD`, closing CWE-306 (missing authentication for a critical function). `SCEPService.PKCSReq` enforces the same invariant defense-in-depth — an empty `s.challengePassword` rejects every enrollment — and the password comparison uses `crypto/subtle.ConstantTimeCompare` to prevent response-time side-channel leakage. The startup log line `SCEP server enabled` emits a `challenge_password_set` boolean for operator visibility.
|
||||
|
||||
**Interface:** The `SCEPHandler` defines an `SCEPService` interface (dependency inversion):
|
||||
|
||||
```go
|
||||
type SCEPService interface {
|
||||
GetCACaps(ctx context.Context) string
|
||||
GetCACert(ctx context.Context) (string, error)
|
||||
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Shared PKCS#7 package:** Both EST and SCEP handlers share a common `internal/pkcs7` package for building PKCS#7 certs-only responses and PEM-to-DER chain conversion, eliminating code duplication between the two enrollment protocols.
|
||||
|
||||
**Audit:** Every SCEP enrollment is recorded in the audit trail with `protocol: "SCEP"`, the CN, SANs, issuer ID, serial number, transaction ID, and optional profile ID.
|
||||
|
||||
## Security Model
|
||||
|
||||
### Private Key Management
|
||||
@@ -692,10 +819,11 @@ The control plane only handles public material: certificates, chains, and CSRs.
|
||||
|
||||
### Authentication
|
||||
|
||||
- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode
|
||||
- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode. Applies to every path under `/api/v1/*`.
|
||||
- **Agent → Server**: API key registered at agent creation, included in all requests
|
||||
- **Server → Issuers**: ACME account key, or connector-specific credentials
|
||||
- **Agent → Targets**: API tokens, WinRM credentials (stored locally on agent or proxy agent — never on server). Credential scope is limited to the agent's network zone.
|
||||
- **Standards-based enrollment and PKI distribution endpoints**: `/.well-known/est/*` (RFC 7030), `/scep` and `/scep/*` (RFC 8894), and `/.well-known/pki/crl/{issuer_id}` + `/.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 5280 §5 / RFC 6960 / RFC 8615) are served unauthenticated at the HTTP layer. These protocols carry their own authentication semantics — CSR signature + profile policy for EST (§3.2.3 says EST auth is deployment-specific; §4.1.1 makes `/cacerts` explicitly anonymous), `challengePassword` in CSR attributes for SCEP (§3.2), and relying-party accessibility for CRL/OCSP — and cannot present certctl Bearer tokens. The dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes these prefixes through `noAuthHandler` (RequestID + structuredLogger + Recovery only, no auth or rate-limit middleware). CWE-306 is closed for SCEP by `preflightSCEPChallengePassword`, which refuses to start the server when SCEP is enabled without `CERTCTL_SCEP_CHALLENGE_PASSWORD`. The 27-subtest regression harness `cmd/server/finalhandler_test.go` pins this dispatch surface (EST 4-endpoint, SCEP exact + trailing-slash + query-string, PKI CRL+OCSP, health probes, `/api/v1/*` authenticated, `/assets/*` file server, SPA fallback).
|
||||
|
||||
### Audit Trail
|
||||
|
||||
@@ -732,6 +860,34 @@ All shell-facing inputs (connector scripts, domain names, ACME tokens) are valid
|
||||
|
||||
All incoming HTTP request bodies are capped by `http.MaxBytesReader` middleware (default 1MB, configurable via `CERTCTL_MAX_BODY_SIZE`). Requests exceeding the limit receive a 413 Request Entity Too Large response. The middleware is positioned before authentication in the chain so oversized payloads are rejected early, before any auth processing or database work occurs. Requests without bodies (GET, HEAD, nil body) skip the limit check.
|
||||
|
||||
### Config Encryption at Rest
|
||||
|
||||
Dynamic issuer and target configurations (rows with `source='database'`) contain credentials — ACME EAB HMACs, Vault tokens, DigiCert/Sectigo API keys, SSH private keys, WinRM passwords, F5 BIG-IP passwords, and similar. These are sealed at rest in PostgreSQL via `internal/crypto/encryption.go` using AES-256-GCM with a key derived from the operator passphrase `CERTCTL_CONFIG_ENCRYPTION_KEY` through PBKDF2-SHA256 (100,000 rounds, 32-byte output).
|
||||
|
||||
**v2 wire format (current, M-8 remediation, CWE-916 / CWE-329):**
|
||||
|
||||
```
|
||||
magic(0x02) || salt(16) || nonce(12) || ciphertext+tag
|
||||
```
|
||||
|
||||
Every call to `EncryptIfKeySet` draws 16 fresh bytes from `crypto/rand` as the PBKDF2 salt, so the derived AES-256 key is distinct per ciphertext and per re-encryption. The salt is stored alongside the ciphertext; decryption reads the magic byte, splits out the salt, re-derives the key, and verifies the AEAD tag.
|
||||
|
||||
**v1 legacy format (read-only):**
|
||||
|
||||
```
|
||||
nonce(12) || ciphertext+tag
|
||||
```
|
||||
|
||||
Pre-M-8 blobs were sealed with a package-level fixed salt `"certctl-config-encryption-v1"`. `DecryptIfKeySet` preserves the v1 read path unchanged — a blob whose first byte is not `0x02`, or whose v2 AEAD verification fails (including the 1/256 case where a v1 nonce happens to begin with `0x02`), falls through to a v1 attempt against the legacy fixed salt. v1 blobs are never written by the post-M-8 code path; they re-seal as v2 naturally on the next UPDATE through the normal service CRUD flow. No operator migration ceremony is required.
|
||||
|
||||
**Fail-closed behavior (C-2 sentinel, CWE-311):** both `EncryptIfKeySet` and `DecryptIfKeySet` return `ErrEncryptionKeyRequired` when invoked with an empty passphrase. The server refuses to start if any `source='database'` rows already exist without `CERTCTL_CONFIG_ENCRYPTION_KEY` set.
|
||||
|
||||
**Low-level primitives preserved byte-identical.** `Encrypt`, `Decrypt`, and `DeriveKey` are kept bit-stable so v1 fixtures on disk remain decryptable unchanged and so callers outside the config-encryption path (none today, but the symbols are exported) do not see a breaking change. The new per-ciphertext salt path is reached via the helper `deriveKeyWithSalt(passphrase, salt)`.
|
||||
|
||||
**Passphrase plumbing.** Services (`IssuerService`, `TargetService`, `IssuerRegistry`) hold the operator passphrase as a raw `string` and delegate PBKDF2 to the crypto package per ciphertext. This replaces the pre-M-8 design that pre-derived a single `[]byte` key at service construction and reused it for every row, which was the direct consequence of the fixed-salt KDF.
|
||||
|
||||
**Coverage gate.** CI enforces `internal/crypto/...` coverage ≥ 85% (observed 86.7%) — the encryption primitives are a security-critical gate, and the v2 format plus v1 fallback plus C-2 sentinel paths all need exhaustive coverage to avoid silent regressions.
|
||||
|
||||
### CORS
|
||||
|
||||
CORS uses a **deny-by-default** posture: when `CERTCTL_CORS_ORIGINS` is empty, no CORS headers are set and only same-origin requests can read responses. Operators must explicitly configure allowed origins. This prevents accidental exposure of the API to cross-origin requests in production.
|
||||
@@ -746,12 +902,18 @@ The HTTP middleware stack processes requests in the following order (see `cmd/se
|
||||
4. **BodyLimit** - request body size cap via `http.MaxBytesReader`
|
||||
5. **RateLimiter** - token bucket rate limiting (optional, when enabled)
|
||||
6. **CORS** - cross-origin request handling (deny-by-default)
|
||||
7. **Auth** - API key or JWT validation
|
||||
7. **Auth** - API key validation (or none in development; JWT/OIDC via authenticating gateway, see below — not in-process)
|
||||
8. **AuditLog** - records every API call to the audit trail (requires auth context for actor)
|
||||
|
||||
### Authenticating-gateway pattern (JWT, OIDC, mTLS)
|
||||
|
||||
certctl's in-process authentication surface is intentionally narrow: `api-key` for production deployments and `none` for development. There is no in-process JWT, OIDC, mTLS, or SAML middleware. (`CERTCTL_AUTH_TYPE=jwt` was accepted pre-G-1 but silently routed through the api-key bearer middleware — a security finding masquerading as a config option, removed at the v2.x boundary; see [`upgrade-to-v2-jwt-removal.md`](upgrade-to-v2-jwt-removal.md) if you previously set it.)
|
||||
|
||||
For deployments that need JWT/OIDC/mTLS, the standard pattern is to put an authenticating gateway in front of certctl and configure `CERTCTL_AUTH_TYPE=none` on the upstream certctl process. The gateway terminates the federated identity protocol, validates tokens / certificates / SAML assertions, and proxies the authenticated request to certctl as a same-origin call on a private network. This separation gives operators the full breadth of the modern identity ecosystem (oauth2-proxy, Envoy `ext_authz`, Traefik `ForwardAuth`, Pomerium, Authelia, Caddy `forward_auth`, Apache `mod_auth_openidc`, nginx `auth_request`) without certctl itself having to track signing-key rotation, claim mapping, audience validation, and the rest of the JWT/OIDC surface area. Operators wanting per-request actor attribution past the gateway boundary forward the gateway-resolved identity (e.g., `X-Auth-Request-User` from oauth2-proxy) and run a small authorization layer at the gateway that enforces the bearer-key contract certctl actually uses.
|
||||
|
||||
### Concurrency Safety
|
||||
|
||||
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 7 loops — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
|
||||
The background scheduler uses `sync/atomic.Bool` idempotency guards on every loop (8 always-on plus up to 4 optional) — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
|
||||
|
||||
### Logging
|
||||
|
||||
@@ -770,10 +932,12 @@ All endpoints are under `/api/v1/` and follow consistent patterns:
|
||||
|
||||
Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications, discovered-certificates, discovery-scans, network-scan-targets, stats, metrics.
|
||||
|
||||
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 99 endpoints across 23 resource domains (97 under `/api/v1/` + `/.well-known/est/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, Prometheus metrics from M22, 4 EST enrollment endpoints from M23, 2 digest endpoints from M29), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
|
||||
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 97 operations across `/api/v1/` and `/.well-known/est/` (includes auth, 7 discovery endpoints, 6 network scan endpoints, Prometheus metrics, 4 EST enrollment endpoints, 2 digest endpoints, 2 verification endpoints, 2 export endpoints), all request/response schemas, and pagination conventions. The server also registers `/health` and `/ready` outside the OpenAPI spec, bringing the total route count to 107. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
|
||||
|
||||
Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`.
|
||||
|
||||
**Bulk Operations:** `POST /api/v1/certificates/bulk-revoke` — Bulk revocation by filter criteria (profile_id, owner_id, agent_id, issuer_id). Creates individual revocation jobs for matching certificates, with partial-failure tolerance and a summary audit event.
|
||||
|
||||
**Enhanced Query Features (M20):** Certificate list endpoints support additional query capabilities beyond basic pagination:
|
||||
|
||||
- **Sorting**: `?sort=notAfter` (ascending) or `?sort=-createdAt` (descending). Whitelist: notAfter, expiresAt, createdAt, updatedAt, commonName, name, status, environment.
|
||||
@@ -783,7 +947,7 @@ Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST
|
||||
- **Additional filters**: `?agent_id=`, `?profile_id=` (in addition to existing status, environment, owner_id, team_id, issuer_id).
|
||||
- **Deployments**: `GET /api/v1/certificates/{id}/deployments` returns deployment targets for a certificate.
|
||||
|
||||
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. A JSON-formatted CRL is available at `GET /api/v1/crl`, and a DER-encoded X.509 CRL signed by the issuing CA at `GET /api/v1/crl/{issuer_id}`. An embedded OCSP responder serves signed responses at `GET /api/v1/ocsp/{issuer_id}/{serial}`. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation.
|
||||
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. The DER-encoded X.509 CRL signed by the issuing CA is served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 + RFC 8615, `Content-Type: application/pkix-crl`). The embedded OCSP responder serves signed responses unauthenticated at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`). Both endpoints are accessible to relying parties with no certctl API credentials, as RFC-compliant PKI consumers expect. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation.
|
||||
|
||||
Certificate export (M27): `GET /api/v1/certificates/{id}/export/pem` returns PEM-encoded certificate and chain, and `POST /api/v1/certificates/{id}/export/pkcs12` returns a PKCS#12 bundle (binary). Private keys are never exported — they remain on agents. All exports are audited with actor, timestamp, and format.
|
||||
|
||||
@@ -798,7 +962,7 @@ flowchart LR
|
||||
AI["AI Assistant\n(Claude, Cursor)"] -->|"stdio"| MCP["MCP Server\ncmd/mcp-server/"]
|
||||
MCP -->|"HTTP + Bearer token"| API["certctl REST API\n:8443"]
|
||||
|
||||
subgraph "78 MCP Tools"
|
||||
subgraph "MCP Tools"
|
||||
T1["Certificate CRUD"]
|
||||
T2["Agent Management"]
|
||||
T3["Job Operations"]
|
||||
@@ -812,7 +976,7 @@ flowchart LR
|
||||
|
||||
The MCP server is a stateless HTTP proxy — every MCP tool call translates to an HTTP request to the certctl REST API. It adds no new state, no new dependencies, and no new attack surface beyond what the API already exposes. Configuration is minimal: `CERTCTL_SERVER_URL` and `CERTCTL_API_KEY` environment variables.
|
||||
|
||||
The 78 tools are organized across 16 resource domains with typed input structs and `jsonschema` struct tags for automatic LLM-friendly schema generation. Binary response support handles DER CRL and OCSP endpoints.
|
||||
The tools are organized across 16 resource domains with typed input structs and `jsonschema` struct tags for automatic LLM-friendly schema generation. Binary response support handles DER CRL and OCSP endpoints.
|
||||
|
||||
## CLI Tool
|
||||
|
||||
@@ -887,9 +1051,9 @@ See `deploy/helm/certctl/values.yaml` for the full configuration reference and `
|
||||
|
||||
For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.).
|
||||
|
||||
## Discovery Data Flow (M18b + M21)
|
||||
## Discovery Data Flow (M18b + M21 + M50)
|
||||
|
||||
Certificate discovery enables operators to build a complete inventory of existing certificates before managing them with certctl. There are two discovery modes that feed into the same pipeline:
|
||||
Certificate discovery enables operators to build a complete inventory of existing certificates before managing them with certctl. There are three discovery modes that feed into the same pipeline:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -898,6 +1062,7 @@ flowchart TB
|
||||
SCAN["Filesystem Scanner\n(CERTCTL_DISCOVERY_DIRS)"]
|
||||
SERVER["certctl-server\n(network discovery)"]
|
||||
NETSCAN["TLS Scanner\n(CIDR ranges + ports)"]
|
||||
CLOUD["Cloud Discovery\n(AWS SM / Azure KV / GCP SM)"]
|
||||
end
|
||||
|
||||
EXTRACT["Extract Metadata\n(CN, SANs, serial, issuer, expiry, fingerprint)"]
|
||||
@@ -913,6 +1078,7 @@ flowchart TB
|
||||
SCAN --> EXTRACT
|
||||
SERVER -->|"Scheduler loop\n(every 6h)"| NETSCAN
|
||||
NETSCAN -->|"crypto/tls.Dial\n50 goroutines"| EXTRACT
|
||||
CLOUD -->|"Scheduler loop\n(every 6h)"| EXTRACT
|
||||
EXTRACT --> SERVICE
|
||||
SERVICE --> REPO
|
||||
REPO -->|"Dedup by fingerprint\n+ agent_id + source_path"| DB
|
||||
@@ -939,7 +1105,16 @@ flowchart TB
|
||||
5. **Sentinel agent** — Results submitted using `server-scanner` as virtual agent ID, with `source_path` set to `ip:port` and `source_format` set to `network`
|
||||
6. **Same pipeline** — Feeds into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery — same dedup, same audit trail, same triage workflow
|
||||
|
||||
**Common triage workflow (both sources):**
|
||||
**Cloud Secret Manager Discovery (M50):**
|
||||
|
||||
1. **Pluggable sources** — Each cloud provider implements the `DiscoverySource` interface (Name, Type, Discover, ValidateConfig). Three built-in sources: AWS Secrets Manager, Azure Key Vault, GCP Secret Manager
|
||||
2. **CloudDiscoveryService orchestrator** — Iterates registered sources, calls `Discover()` on each, feeds reports into `ProcessDiscoveryReport()`. Errors from one source don't prevent other sources from running
|
||||
3. **Scheduler integration** — opt-in cloud discovery scheduler loop (6h default; see `docs/architecture.md` 12-loop topology), runs immediately on startup, `atomic.Bool` idempotency guard
|
||||
4. **Sentinel agents** — Each source uses its own sentinel agent ID (`cloud-aws-sm`, `cloud-azure-kv`, `cloud-gcp-sm`) for dedup and triage filtering
|
||||
5. **Source path format** — `aws-sm://{region}/{secret}`, `azure-kv://{cert-name}/{version}`, `gcp-sm://{project}/{secret}`
|
||||
6. **No new schema** — Reuses existing `discovered_certificates` and `discovery_scans` tables. Sentinel agent IDs leverage existing `(fingerprint_sha256, agent_id, source_path)` dedup constraint
|
||||
|
||||
**Common triage workflow (all sources):**
|
||||
|
||||
1. **Storage** — Records stored in `discovered_certificates` table with status = "Unmanaged"
|
||||
2. **Audit** — `discovery_scan_completed` event logged with agent ID, cert count, scan timestamp
|
||||
@@ -952,29 +1127,53 @@ flowchart TB
|
||||
|
||||
This data flow is pull-based and non-blocking. Agents discover at their own pace; the server stores results for later review. There's no pressure to claim or dismiss; operators can leave certificates in "Unmanaged" status indefinitely.
|
||||
|
||||
## Continuous TLS Health Monitoring (M48)
|
||||
|
||||
Beyond one-time discovery, certctl continuously monitors TLS endpoints for certificate health using a shared TLS probing package and a state-machine-driven health check service. Endpoints transition between states (Healthy → Degraded → Down) based on consecutive failures, and `cert_mismatch` status alerts when a deployed certificate is unexpectedly replaced.
|
||||
|
||||
**Architecture:** Probing is extracted into a shared `internal/tlsprobe/` package used by both the network scanner (M21) and the health monitor. The `HealthCheckService` manages 8 API endpoints for CRUD operations and state transitions. A dedicated opt-in endpoint health scheduler loop runs every 60 seconds (configurable via `CERTCTL_HEALTH_CHECK_INTERVAL`). Individual health check targets have their own check intervals (default 300 seconds) — the scheduler queries only endpoints due for check via `ListDueForCheck()`. Results are stored with historical tracking for 30 days (configurable via `CERTCTL_HEALTH_CHECK_HISTORY_RETENTION`). State transitions trigger notifications (critical for down endpoints, warning for degraded, high for cert_mismatch).
|
||||
|
||||
**State Machine:** Healthy → Degraded (configurable threshold, default 2 consecutive failures) → Down (default 5 failures). The `cert_mismatch` status is special — it fires whenever the observed certificate fingerprint differs from the expected (deployed) fingerprint, catching silent rollbacks and unauthorized cert replacements. Recovery from degraded/down transitions back to healthy and resets the failure counter.
|
||||
|
||||
**API:** 8 endpoints for list (with filters: status, certificate_id, network_scan_target_id, enabled), get, create, update, delete, history (with limit param), acknowledge (incident marking), and summary (aggregate status counts).
|
||||
|
||||
**Auto-Create:** When a deployment job completes with successful verification (M25), the system automatically creates a health check with the deployed certificate's fingerprint as the expected value. Network scan targets can also opt-in to auto-create health checks for discovered endpoints.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---|---|---|
|
||||
| `CERTCTL_HEALTH_CHECK_ENABLED` | `false` | Enable/disable the feature |
|
||||
| `CERTCTL_HEALTH_CHECK_INTERVAL` | `60s` | Scheduler tick interval |
|
||||
| `CERTCTL_HEALTH_CHECK_DEFAULT_INTERVAL` | `300s` | Default per-endpoint check interval (5 min) |
|
||||
| `CERTCTL_HEALTH_CHECK_DEFAULT_TIMEOUT` | `5000ms` | TLS connection timeout per probe |
|
||||
| `CERTCTL_HEALTH_CHECK_MAX_CONCURRENT` | `20` | Max concurrent TLS probes |
|
||||
| `CERTCTL_HEALTH_CHECK_HISTORY_RETENTION` | `30 days` | Purge probe history older than this |
|
||||
| `CERTCTL_HEALTH_CHECK_AUTO_CREATE` | `true` | Auto-create checks from deployments |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 1050+ tests across six layers (service, handler, integration, connector, frontend, and scheduler). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
|
||||
certctl is extensively tested across eight layers with CI-enforced coverage gates that act as regression floors. The goal is high-confidence regression prevention at the service and handler layers (where the most complex business logic lives), combined with integration tests that exercise the full request path from HTTP to database.
|
||||
|
||||
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
|
||||
**Service layer unit tests** (`internal/service/*_test.go`) — Mock-based tests across all service files covering certificate CRUD, revocation (all RFC 5280 reason codes, OCSP/CRL generation, bulk revocation by filter with partial-failure tolerance), agent lifecycle, job state machine, policy evaluation, renewal/issuance flow (both keygen modes), notification deduplication, team/owner/agent group CRUD, issuer service CRUD with connection testing, and the issuer connector adapter. Mock repositories are simple structs with function fields — no heavy mocking frameworks.
|
||||
|
||||
**Handler layer tests** (`internal/api/handler/*_test.go`) — ~257 test functions across 11 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (50 tests including revocation, DER CRL, and OCSP), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), agent groups (12 tests), teams (26 tests), and owners (21 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs, name length limits), error propagation from the service layer, method-not-allowed responses, and pagination parameters.
|
||||
**Handler layer tests** (`internal/api/handler/*_test.go`) — Every handler file has a corresponding test file using Go's `httptest` package: certificates (including revocation, bulk revocation by profile/owner/agent/issuer, DER CRL, OCSP), agents, jobs (including approve/reject), notifications, policies, profiles, issuers, targets, agent groups, teams, owners, discovery, network scan, verification, export, EST, digest, stats, and metrics. Tests cover the happy path, input validation, error propagation, method-not-allowed, pagination, and bulk operation partial-failure scenarios.
|
||||
|
||||
**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths, 19 M11b endpoint tests, and 8 revocation endpoint tests (M15a+M15b): nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, expired certificate lifecycle, team/owner/agent group CRUD validation, revocation success, already-revoked rejection, not-found revocation, JSON CRL retrieval, DER CRL retrieval, OCSP response retrieval, and short-lived cert exemption. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector. A third file, `e2e_test.go`, contains 8 cross-milestone test functions with 48+ subtests that exercise features across milestones end-to-end: M10 agent metadata via heartbeat, M11 profiles/teams/owners/agent-groups CRUD, M12 issuer registry verification, M13 GUI operation endpoints, M14 stats and metrics, M15 revocation and CRL, M16 notification channels, and M20 enhanced query API (sorting, cursor pagination, sparse fields, time-range filters).
|
||||
**Integration tests** (`internal/integration/`) — Three test files exercising the full stack from HTTP request through router, handler, service, and repository layers. `lifecycle_test.go` covers the complete certificate lifecycle (team/owner creation through deployment and status reporting). `negative_test.go` covers error paths, endpoint validation, and revocation scenarios. `e2e_test.go` exercises cross-milestone features end-to-end (agent metadata, profiles, issuer registry, GUI operations, stats, revocation, notifications, enhanced query API).
|
||||
|
||||
**Frontend tests** (`web/src/api/client.test.ts`, `web/src/api/utils.test.ts`) — 86 Vitest tests covering the API client, stats/metrics endpoints, and utility functions. The API client tests mock `globalThis.fetch` and verify all endpoint functions (certificates, agents, jobs, policies, issuers, targets, notifications, audit, stats, metrics, health) send correct HTTP methods, URLs, headers, and request bodies. They also test API key management (store/retrieve/clear), auth header propagation, 401 event dispatching, and error handling (server messages, error fields, status text fallback). The stats/metrics endpoint tests verify correct query parameter handling and response shape validation. The utility tests use `vi.useFakeTimers()` for deterministic date testing and cover `formatDate`, `formatDateTime`, `timeAgo`, `daysUntil`, and `expiryColor`. The test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
||||
**Go integration tests** (`deploy/test/integration_test.go`) — Runs against the live Docker Compose test environment with real CA backends (Local CA, Pebble ACME, step-ca). Covers health checks, agent heartbeat, issuance, renewal, revocation, CRL/OCSP, EST enrollment, S/MIME, discovery, network scanning, and deployment verification using `crypto/x509` for cert parsing and `crypto/tls` for live TLS verification.
|
||||
|
||||
**CLI tests** (`internal/cli/client_test.go`) — 14 tests covering all 10 CLI subcommands with httptest mock servers, PEM parsing for bulk import, auth header verification, and JSON/table output formatting.
|
||||
**Frontend tests** (`web/src/api/`) — Vitest tests covering the full API client (all endpoint functions with fetch mocking), stats/metrics endpoints, utility functions, and auth flows. Test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
||||
|
||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, race detection, static analysis, vulnerability scanning, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs `go test -race` on service, handler, middleware, and scheduler packages to catch data races. It runs `golangci-lint` with 11 linters (errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx) configured in `.golangci.yml`. It runs `govulncheck ./...` to scan dependencies for known CVEs. Coverage thresholds are enforced per-layer: service 60%, handler 60%, domain 40%, middleware 50%. These thresholds act as regression floors — they can only go up. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, HAProxy, Traefik, and Caddy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
||||
**Connector tests** (`internal/connector/`) — Issuer connectors (Local CA self-signed/sub-CA modes, ACME DNS-01/DNS-PERSIST-01, step-ca, OpenSSL, Vault PKI, DigiCert, Sectigo, Google CAS, AWS ACM PCA — all with httptest mock servers or injectable interface mocks). Target connectors (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS with mock PowerShell executor, F5 BIG-IP with mock iControl client, Postfix/Dovecot, SSH with mock SSH client, Windows Certificate Store with mock PowerShell executor, Java Keystore with mock command executor, Kubernetes Secrets with mock K8s client, shared certutil package). Notifier connectors (Slack, Teams, PagerDuty, OpsGenie).
|
||||
|
||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 10 tests for script-based DNS-01 and DNS-PERSIST-01 challenges (6 DNS-01 tests + 4 DNS-PERSIST-01 tests covering `PresentPersist` success, no-script error, script failure, and wildcard domain handling). The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Traefik and Caddy connectors have tests covering file-based deployment and (for Caddy) dual-mode API/file configuration. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
||||
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Idempotency guards (`sync/atomic.Bool`), `WaitForCompletion` success and timeout paths, and multi-loop concurrency safety.
|
||||
|
||||
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Tests for idempotency guards (`sync/atomic.Bool` CompareAndSwap prevents concurrent loop ticks), `WaitForCompletion` success and timeout paths, and multi-loop idempotency.
|
||||
**Fuzz tests** (`internal/validation/`, `internal/domain/`) — Go native fuzz tests for command validation (`ValidateShellCommand`, `ValidateDomainName`, `ValidateACMEToken`) and revocation domain parsing.
|
||||
|
||||
**Fuzz tests** (`internal/validation/command_fuzz_test.go`, `internal/domain/revocation_fuzz_test.go`) — Go native fuzz tests (`testing/fuzz`) for command validation functions and revocation domain parsing. These exercise `ValidateShellCommand`, `ValidateDomainName`, and `ValidateACMEToken` with random inputs to discover edge cases.
|
||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs. Go: build, vet, `go test -race`, `golangci-lint` (11 linters), `govulncheck`, test with coverage, per-layer coverage threshold enforcement (service 55%, handler 60%, domain 40%, middleware 30%). Frontend: TypeScript type check, Vitest, Vite production build.
|
||||
|
||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests — a `testcontainers-go` scaffolding for isolated PostgreSQL instances is planned. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for V3). The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
||||
For detailed test procedures, smoke tests, and the release sign-off checklist, see the [Testing Guide](testing-guide.md). For setting up the Docker Compose test environment with real CA backends, see [Test Environment](test-env.md).
|
||||
|
||||
## What's Next
|
||||
|
||||
@@ -984,3 +1183,5 @@ certctl uses a layered testing approach aligned with the handler → service →
|
||||
- [Compliance Mapping](compliance.md) — SOC 2, PCI-DSS 4.0, and NIST SP 800-57 alignment
|
||||
- [MCP Server Guide](mcp.md) — AI-native access to the API
|
||||
- [OpenAPI Spec](openapi.md) — Full API reference and SDK generation
|
||||
- [Testing Guide](testing-guide.md) — Test procedures and release sign-off
|
||||
- [Test Environment](test-env.md) — Docker Compose test environment setup
|
||||
|
||||
@@ -27,7 +27,7 @@ Result:
|
||||
|
||||
Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows:
|
||||
- **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly)
|
||||
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (coming in v2.1), private CA)
|
||||
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (planned), private CA)
|
||||
- **Unified renewal and deployment** across both worlds
|
||||
- **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail
|
||||
|
||||
@@ -39,8 +39,7 @@ Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or sel
|
||||
```bash
|
||||
cd /opt/certctl
|
||||
docker compose up -d
|
||||
# Dashboard: http://localhost:3000
|
||||
# API: http://localhost:8080
|
||||
# Dashboard & API: https://localhost:8443 (self-signed cert — pin with --cacert ./deploy/test/certs/ca.crt)
|
||||
```
|
||||
|
||||
**Option B: Kubernetes** (recommended for prod)
|
||||
@@ -60,7 +59,8 @@ chmod +x /usr/local/bin/certctl-agent
|
||||
|
||||
# Config
|
||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||
CERTCTL_SERVER_URL=https://certctl-control-plane:8080
|
||||
CERTCTL_SERVER_URL=https://certctl-control-plane:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt
|
||||
CERTCTL_API_KEY=your-api-key
|
||||
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
|
||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||
@@ -83,18 +83,20 @@ Agents scan configured directories and report back all existing certs. In the da
|
||||
Set up the same issuer certctl uses for non-Kubernetes certs:
|
||||
- **ACME** (Let's Encrypt, for public certs)
|
||||
- **step-ca** (Smallstep, for internal certs)
|
||||
- **Vault PKI** (coming in v2.1) (HashiCorp Vault, for enterprise PKI)
|
||||
- **Vault PKI** (HashiCorp Vault, for enterprise PKI)
|
||||
- **Private CA** (your own internal root CA)
|
||||
|
||||
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
|
||||
|
||||
### 5. Create Policies for Non-Kubernetes Certs
|
||||
|
||||
Go to **Policies** → **New Policy**:
|
||||
- Issuer: shared (ACME, step-ca, Vault (coming in v2.1), private CA)
|
||||
- Profile: serverAuth for NGINX/Apache/HAProxy, clientAuth for mTLS, emailProtection for S/MIME
|
||||
- Renewal Threshold: 30 days (default, adjust per SLA)
|
||||
- Scope: agent groups (VMs, bare metal, appliances)
|
||||
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||
- **Name:** e.g., "VM Certificate Policy"
|
||||
- **Type:** `expiration_window` or `key_algorithm` (enforce renewal thresholds or crypto requirements)
|
||||
- **Severity:** `high`
|
||||
- **Config:** set your enforcement parameters
|
||||
|
||||
Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other compliance rules across your fleet.
|
||||
|
||||
### 6. View Unified Inventory
|
||||
|
||||
@@ -114,7 +116,7 @@ Go to **Policies** → **New Policy**:
|
||||
If cert-manager and certctl both use the same CA:
|
||||
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
|
||||
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
|
||||
- **Vault PKI** (coming in v2.1): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||
- **Vault PKI**: cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||
|
||||
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
|
||||
|
||||
@@ -137,7 +139,7 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
|
||||
2. Explore [Agents and Targets](./architecture.md#agents-and-targets) for deployment architecture
|
||||
3. Read about [Discovery Scanning](./quickstart.md#discovery) to auto-find certs
|
||||
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||
1. Run through the [Quick Start](./quickstart.md) for a 5-minute demo
|
||||
2. Try the [Multi-Issuer example](../examples/multi-issuer/multi-issuer.md) — manages public and internal certs from one dashboard
|
||||
3. Explore [Architecture](./architecture.md#agents) for deployment patterns
|
||||
4. Check the [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||
|
||||
+18
-12
@@ -72,7 +72,7 @@ certctl implements tiered key storage with different protection profiles based o
|
||||
- Configured via: `CERTCTL_CA_CERT_PATH=/path/to/ca.crt` and `CERTCTL_CA_KEY_PATH=/path/to/ca.key`
|
||||
|
||||
**NIST Gap: HSM Storage**
|
||||
NIST SP 800-57 Part 1 recommends Hardware Security Module (HSM) storage for high-value keys (CA signing keys). certctl V2 uses filesystem storage on the server. HSM support is planned for V5 roadmap, enabling integration with:
|
||||
NIST SP 800-57 Part 1 recommends Hardware Security Module (HSM) storage for high-value keys (CA signing keys). certctl V2 uses filesystem storage on the server. HSM support is planned for certctl Pro (V3), enabling integration with:
|
||||
- AWS CloudHSM
|
||||
- Azure Dedicated HSM
|
||||
- Thales Luna, Gemalto SafeNet, YubiHSM (on-premises)
|
||||
@@ -210,15 +210,17 @@ NIST SP 800-57 Part 1 Section 6.2 addresses secure key distribution to minimize
|
||||
- Proxy agent executes deployment via appliance API
|
||||
|
||||
**Revocation Distribution**
|
||||
- Certificate Revocation List (CRL) via `GET /api/v1/crl/{issuer_id}`
|
||||
- Returns DER-encoded X.509 CRL signed by issuing CA
|
||||
- Certificate Revocation List (CRL) via `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615)
|
||||
- Returns DER-encoded X.509 CRL signed by issuing CA (`Content-Type: application/pkix-crl`)
|
||||
- 24-hour validity period
|
||||
- Includes all revoked serials, reasons, and revocation timestamps
|
||||
- Served unauthenticated so relying parties without certctl API credentials can fetch it
|
||||
- Subject to URL caching; OCSP preferred for real-time revocation
|
||||
- OCSP via `GET /api/v1/ocsp/{issuer_id}/{serial}`
|
||||
- Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure)
|
||||
- OCSP via `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960)
|
||||
- Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure, `Content-Type: application/ocsp-response`)
|
||||
- Signed by issuing CA (or delegated OCSP signing cert)
|
||||
- Responds with good/revoked/unknown status
|
||||
- Served unauthenticated — the RFC 6960 relying-party model does not assume API credentials
|
||||
- Real-time, more bandwidth-efficient than CRL polling
|
||||
|
||||
## Revocation and Compromise (NIST SP 800-57 Part 3)
|
||||
@@ -272,20 +274,23 @@ NIST SP 800-57 Part 3 covers revocation (Section 2.5) when keys are suspected co
|
||||
- OCSP responder queries revocation table in real-time
|
||||
- Short-lived certificate exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
|
||||
|
||||
**Bulk Revocation for Large-Scale Compromise Response** (V2.2) — NIST SP 800-57 Part 3 emphasizes rapid revocation when keys are compromised. `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria (profile, owner, agent, issuer) in a single operation. This enables operators to execute fleet-wide revocation for key compromise events affecting multiple certificates. Each bulk revocation creates individual jobs reusing the existing revocation pipeline, ensuring every certificate is recorded in the audit trail with the incident reason.
|
||||
|
||||
**Revocation Audit Trail**
|
||||
All revocation events logged:
|
||||
- Event type: `certificate_revoked`
|
||||
- Event type: `certificate_revoked` or `bulk_revocation_initiated` (for fleet operations)
|
||||
- Actor: authenticated user or service
|
||||
- Reason code: RFC 5280 enum
|
||||
- Reason code: RFC 5280 enum (or incident justification for bulk operations)
|
||||
- Timestamp: RFC3339
|
||||
- Issuer notification status: success or error reason
|
||||
- Filter criteria: profile_id, owner_id, agent_id, issuer_id (for bulk revocation)
|
||||
|
||||
## Alignment Summary Table
|
||||
|
||||
| NIST SP 800-57 Area | Status | Coverage | Notes |
|
||||
|---|---|---|---|
|
||||
| **Key Generation** | ✅ Aligned | 100% | Agent-side ECDSA P-256 using crypto/rand; server mode flagged as demo-only |
|
||||
| **Key Storage** | ⚠️ Partially Aligned | 80% | Filesystem with 0600 perms; HSM support planned V5 |
|
||||
| **Key Storage** | ⚠️ Partially Aligned | 80% | Filesystem with 0600 perms; HSM support planned V3 Pro |
|
||||
| **Cryptoperiods** | ✅ Aligned | 100% | Profile-enforced max_ttl; threshold-based renewal alerting |
|
||||
| **Key States** | ✅ Aligned | 100% | Full lifecycle tracking with immutable audit trail |
|
||||
| **Algorithms** | ✅ Aligned | 100% | NIST-approved algorithms only; post-quantum tracking in progress |
|
||||
@@ -301,13 +306,14 @@ All revocation events logged:
|
||||
- [x] RFC 5280 revocation support
|
||||
- [x] Immutable audit trail
|
||||
|
||||
### V2.2 (Planned: 2026)
|
||||
- Bulk revocation by profile/owner/agent/issuer (fleet-level revocation for incident response)
|
||||
|
||||
### V3 (Planned: 2026)
|
||||
- Role-based access control (limit revocation/approval to authorized operators)
|
||||
- Bulk revocation by profile/owner/agent (fleet-level revocation policy)
|
||||
|
||||
### V5 (Planned: 2027+)
|
||||
- HSM support for CA key storage
|
||||
- PKCS#11 integration for hardware tokens
|
||||
### V3 Pro (Planned)
|
||||
- HSM support for CA key storage and agent key storage (TPM 2.0, PKCS#11)
|
||||
- FIPS 140-2/3 validated crypto module (BoringCrypto build or external FIPS library)
|
||||
- Key destruction API (explicit secure erasure of agent keys)
|
||||
- Key escrow / recovery mechanism (backup encrypted private keys for disaster recovery)
|
||||
|
||||
+20
-14
@@ -92,9 +92,11 @@ Your QSA will request evidence that your certificate and key management systems
|
||||
|
||||
- **Certificate Status Tracking** — Four statuses: Active (deployed, not yet expired), Expiring (within threshold, awaiting renewal), Expired (past not-after date), Revoked (revoked via RFC 5280 revocation API). Dashboard charts show status distribution.
|
||||
|
||||
- **Revocation Infrastructure** (M15a, M15b):
|
||||
- CRL endpoint: `GET /api/v1/crl` (JSON format) or `GET /api/v1/crl/{issuer_id}` (DER X.509 CRL, 24h validity, signed by issuing CA)
|
||||
- OCSP responder: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns DER-encoded OCSP response: good/revoked/unknown)
|
||||
- **Revocation Infrastructure** (M15a, M15b, M-006):
|
||||
- Revocation API: `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes
|
||||
- CRL endpoint: `GET /.well-known/pki/crl/{issuer_id}` — DER X.509 CRL, 24h validity, signed by issuing CA, served unauthenticated (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`)
|
||||
- OCSP responder: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` — DER-encoded OCSP response (good/revoked/unknown), served unauthenticated (RFC 6960, `Content-Type: application/ocsp-response`)
|
||||
- Bulk revocation (V2.2): `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) for fleet-wide incident response
|
||||
- Short-lived cert exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
|
||||
|
||||
- **Stats API** (M14) — Real-time visibility:
|
||||
@@ -107,7 +109,7 @@ Your QSA will request evidence that your certificate and key management systems
|
||||
- Discovered certificate report: `GET /api/v1/discovered-certificates` JSON export showing all certs on systems, fingerprints, and status.
|
||||
- Managed certificate inventory: `GET /api/v1/certificates` with filters (`?status=Expiring` for upcoming renewals).
|
||||
- Expiration alert configuration: policy JSON showing `alert_thresholds_days` for each environment.
|
||||
- CRL/OCSP availability proof: HTTP GET requests to `/api/v1/crl` and `/api/v1/ocsp/{issuer}/{serial}` with signed responses.
|
||||
- CRL/OCSP availability proof: unauthenticated HTTP GET requests to `/.well-known/pki/crl/{issuer_id}` (DER, `application/pkix-crl`) and `/.well-known/pki/ocsp/{issuer_id}/{serial}` (DER, `application/ocsp-response`) with signed responses.
|
||||
- Audit trail for certificate creation/renewal/revocation: `GET /api/v1/audit?type=certificate_issued,certificate_renewed,certificate_revoked`.
|
||||
- Dashboard charts showing expiration timeline, renewal success trends, status distribution.
|
||||
|
||||
@@ -326,11 +328,14 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
- Issuer notified (best-effort; ACME lacks standard revocation, Local CA skips issuer step).
|
||||
- Revocation notifications sent to owner via email/webhook/Slack/Teams/PagerDuty.
|
||||
|
||||
- **CRL and OCSP Publication** (M15b) — Revoked certificates published in:
|
||||
- CRL: `GET /api/v1/crl` (JSON format) or `GET /api/v1/crl/{issuer_id}` (DER X.509, signed by CA, 24h validity)
|
||||
- OCSP: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain)
|
||||
- **CRL and OCSP Publication** (M15b, M-006) — Revoked certificates published in:
|
||||
- CRL: `GET /.well-known/pki/crl/{issuer_id}` (DER X.509 signed by CA, 24h validity, RFC 5280 §5 + RFC 8615, `Content-Type: application/pkix-crl`)
|
||||
- OCSP: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain, RFC 6960, `Content-Type: application/ocsp-response`)
|
||||
- Both endpoints are served unauthenticated so relying parties (browsers, TLS appliances) without certctl API keys can verify revocation — this is the RFC-compliant PKI model.
|
||||
- Clients checking certificate status via OCSP or CRL see revoked status within 24 hours.
|
||||
|
||||
- **Bulk Revocation for Incident Response** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. PCI-DSS Req 4 requires rapid response to data transmission security incidents — bulk revocation enables operators to revoke an entire certificate set (e.g., all certs used by a compromised team or endpoint) in minutes rather than hours.
|
||||
|
||||
- **Private Key Destruction on Agent** — When certificate renewed or revoked:
|
||||
- Agent removes old private key file from `CERTCTL_KEY_DIR` when new certificate deployed.
|
||||
- Job status tracking confirms old key is no longer needed.
|
||||
@@ -338,8 +343,8 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Revocation requests: `GET /api/v1/audit?type=certificate_revoked` with RFC 5280 reason codes.
|
||||
- CRL publication: HTTP GET `/api/v1/crl` and parse JSON to show revoked serial numbers and timestamps.
|
||||
- OCSP responder validation: Query `GET /api/v1/ocsp/{issuer}/{serial}` for a known-revoked cert; response includes `revoked` status.
|
||||
- CRL publication: HTTP GET `/.well-known/pki/crl/{issuer_id}` (unauthenticated) returns a DER X.509 CRL — parse with `openssl crl -inform der -noout -text` to show revoked serial numbers, reasons, and timestamps.
|
||||
- OCSP responder validation: Query `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (unauthenticated) for a known-revoked cert; response includes `revoked` status and can be parsed with `openssl ocsp` tooling.
|
||||
- Audit trail: Certificate status transitions (Active → Revoked) recorded in `audit_events`.
|
||||
|
||||
**Operator Responsibility**:
|
||||
@@ -382,12 +387,12 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
- API key transmitted in Authorization header (not URL parameter, not cookie).
|
||||
- Browser to server: TLS.
|
||||
- Agent to server: TLS.
|
||||
- No credential logging (API key hash only, never plaintext).
|
||||
- No credential logging (audit records the per-key actor `Name`, never the Bearer token; logs redact the `Authorization` header).
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- API configuration: `CERTCTL_AUTH_TYPE=api-key` in deployment manifest.
|
||||
- Database schema: `api_keys` table showing SHA-256 hash column, not plaintext.
|
||||
- API audit log: `GET /api/v1/audit?action=api_call` showing Bearer token validation (no plaintext keys logged).
|
||||
- Key inventory: `CERTCTL_API_KEYS_NAMED` env var (format `name:key:admin,...`) — seeds the in-memory `NamedAPIKey{Name, Key, Admin}` struct at `internal/api/middleware/middleware.go:29`. Keys are constant-time-compared (`subtle.ConstantTimeCompare`) against the Bearer token. No database table stores them; protect the env var contents at rest via a secrets manager (Vault / AWS Secrets Manager / Kubernetes Secrets / Docker Secrets).
|
||||
- API audit log: `GET /api/v1/audit?action=api_call` showing per-key actor names (`Name` field of matched `NamedAPIKey`) on every call, with zero plaintext or hashed key material recorded.
|
||||
- TLS certificate on control plane: `openssl s_client -connect {server}:8443` showing valid certificate, TLS 1.2+, strong cipher.
|
||||
- GUI login flow: browser network tab showing Authorization header (token value redacted in compliance report).
|
||||
|
||||
@@ -557,6 +562,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
- **Alert Notifications** (M3, M16a) — Configurable escalation:
|
||||
- Email alerts: certificate approaching expiration, renewal failure, revocation notification.
|
||||
- Webhook: custom HTTP POST to your monitoring system (Slack, Teams, PagerDuty, OpsGenie, custom webhook).
|
||||
- **Retry & Dead-Letter Queue** (I-005) — Transient notifier failures (SMTP timeout, webhook 5xx) are retried with exponential backoff (`2^n` minutes capped at 1h, 5-attempt budget) before landing in the terminal `dead` status. Operators monitor DLQ depth via the `certctl_notification_dead_total` Prometheus counter and requeue via the Notifications page Dead letter tab once the underlying outage is resolved. Closes the pre-I-005 silent-drop gap where a single 5xx could lose a compliance-relevant alert without evidence.
|
||||
- Deduplication: one alert per threshold/certificate per day (avoid alert fatigue).
|
||||
|
||||
- **Audit Trail Filtering and Export** (M13) — Compliance reporting:
|
||||
@@ -717,12 +723,12 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
| PCI-DSS Requirement | certctl Feature | API/UI Evidence | Database/Config | Audit Trail | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| **4.2.1** Strong Crypto | TLS cert issuance, ACME/step-ca/Local CA, RSA 2048+/ECDSA P-256 | `GET /api/v1/certificates` (key_type, key_size) | Certificate profiles | `GET /api/v1/audit?type=certificate_issued` | Available |
|
||||
| **4.2.2** Cert Inventory & Validation | Managed cert CRUD, discovery (M18b), expiration alerting, CRL/OCSP | `GET /api/v1/certificates`, `GET /api/v1/discovered-certificates`, `GET /api/v1/crl`, `GET /api/v1/ocsp/{issuer}/{serial}` | `managed_certificates`, `discovered_certificates` tables | `GET /api/v1/audit?type=certificate_*` | Available |
|
||||
| **4.2.2** Cert Inventory & Validation | Managed cert CRUD, discovery (M18b), expiration alerting, CRL/OCSP | `GET /api/v1/certificates`, `GET /api/v1/discovered-certificates`, `GET /.well-known/pki/crl/{issuer_id}`, `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (both unauthenticated, RFC 5280 / RFC 6960) | `managed_certificates`, `discovered_certificates` tables | `GET /api/v1/audit?type=certificate_*` | Available |
|
||||
| **3.6** Key Documentation | Profiles, owner/team tracking, issuer config, audit trail | `GET /api/v1/profiles`, `GET /api/v1/issuers`, certificate detail with owner/team | Profiles, certificate owner/team fields, issuer config | `GET /api/v1/audit?resource_type=certificate` | Available |
|
||||
| **3.7.1** Key Generation | Agent-side ECDSA P-256, server keygen (demo only) | Agent logs, renewal job detail, CSR audit | `CERTCTL_KEYGEN_MODE=agent` (config), job_type=AwaitingCSR | `GET /api/v1/audit?type=certificate_issued` with CSR hash | Available |
|
||||
| **3.7.2** Key Storage | Agent `/var/lib/certctl/keys` (0600), env var secrets, .env excluded | Deployment manifest (env var refs), agent key dir listing | `.env` file (git-ignored), `CERTCTL_KEY_DIR`, `CERTCTL_CA_KEY_PATH` | No API audit (keys off-platform) | Available |
|
||||
| **3.7.3** Key Rotation | Auto renewal, expiration thresholds, renewal jobs | Dashboard renewal trends, `GET /api/v1/jobs?type=Renewal`, certificate versions | Renewal policies, certificate version history | `GET /api/v1/audit?type=certificate_renewed` | Available |
|
||||
| **3.7.4** Key Destruction | Revocation API (RFC 5280), CRL/OCSP, private key cleanup | `POST /api/v1/certificates/{id}/revoke`, `GET /api/v1/crl`, OCSP endpoint | `certificate_revocations` table, CRL publication | `GET /api/v1/audit?type=certificate_revoked` | Available |
|
||||
| **3.7.4** Key Destruction | Revocation API (RFC 5280), CRL/OCSP, private key cleanup | `POST /api/v1/certificates/{id}/revoke`, unauthenticated `GET /.well-known/pki/crl/{issuer_id}` and `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` | `certificate_revocations` table, CRL publication | `GET /api/v1/audit?type=certificate_revoked` | Available |
|
||||
| **8.3** Strong Authentication | API key (SHA-256 hash, TLS), GUI login, 401 redirect | GUI login screenshot, API key auth header, TLS cert | API key hash in database | `GET /api/v1/audit` showing API calls | Available |
|
||||
| **8.6** Acct Management | Credentials out of source, .env excluded, env var config | Code review (no hardcoded secrets), `.gitignore` check | Deployment manifests showing env var refs only | No account lifecycle audit (outside scope) | Available in part |
|
||||
| **10.2** Audit Logging | API audit middleware (M19), certificate lifecycle events | `GET /api/v1/audit` with filter/pagination | `audit_events` table (every API call) | Real-time via API | Available |
|
||||
|
||||
+28
-17
@@ -44,7 +44,8 @@ Each section includes:
|
||||
|
||||
**certctl Implementation** (V2 — Community Edition):
|
||||
|
||||
- **API Key Authentication** — All API calls require a Bearer token (hashed with SHA-256, stored securely, validated with constant-time comparison) or are rejected with 401 Unauthorized. Environment: `CERTCTL_AUTH_TYPE` (default `api-key`; `none` requires explicit opt-in with log warning)
|
||||
- **API Key Authentication** — All `/api/v1/*` calls require a Bearer token (hashed with SHA-256, stored securely, validated with constant-time comparison) or are rejected with 401 Unauthorized. Environment: `CERTCTL_AUTH_TYPE` (default `api-key`; `none` requires explicit opt-in with log warning)
|
||||
- **Standards-based enrollment and PKI distribution endpoints** — EST (`/.well-known/est/*`, RFC 7030), SCEP (`/scep`, `/scep/*`, RFC 8894), and CRL/OCSP (`/.well-known/pki/crl/{issuer_id}`, `/.well-known/pki/ocsp/{issuer_id}/{serial}`, RFC 5280 §5 / RFC 6960 / RFC 8615) are served unauthenticated at the HTTP layer because these protocols cannot present certctl Bearer tokens. Authentication is enforced in-protocol: EST relies on CSR signature verification plus profile policy (RFC 7030 §3.2.3 says EST auth is deployment-specific; §4.1.1 makes `/cacerts` explicitly anonymous); SCEP requires a shared `challengePassword` in the PKCS#10 CSR attributes (OID 1.2.840.113549.1.9.7, RFC 8894 §3.2), validated with `crypto/subtle.ConstantTimeCompare`; CRL and OCSP are intentionally anonymous for relying-party accessibility. CWE-306 (missing authentication for a critical function) is closed for SCEP by `preflightSCEPChallengePassword` in `cmd/server/main.go`, which refuses to start the control plane when `CERTCTL_SCEP_ENABLED=true` is set without `CERTCTL_SCEP_CHALLENGE_PASSWORD`. The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes these prefixes through `noAuthHandler` (RequestID + structuredLogger + Recovery only, no auth or rate-limit middleware) and is pinned by the 27-subtest regression harness at `cmd/server/finalhandler_test.go`.
|
||||
- **GUI Authentication** — Web dashboard includes login screen requiring API key entry. Failed auth redirects to login on 401. Auth context persists across page navigation. Logout clears session.
|
||||
- **Configurable CORS** — API restricts cross-origin requests via `CERTCTL_CORS_ORIGINS` allowlist or wildcard. Preflight caching prevents chatty browser auth flows.
|
||||
- **Token Bucket Rate Limiting** — Per-IP rate limiting (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`) returns 429 Too Many Requests with Retry-After header. Prevents credential stuffing and brute-force attacks.
|
||||
@@ -58,6 +59,11 @@ Each section includes:
|
||||
- Auth info endpoint: `GET /api/v1/auth/info` (returns current auth mode, served without auth so GUI detects mode)
|
||||
- Rate limiting middleware: `internal/api/middleware/rate_limit.go`
|
||||
- CORS configuration: `cmd/server/main.go`, search for `CERTCTL_CORS_ORIGINS`
|
||||
- Final handler dispatch (authenticated vs. unauthenticated routing): `cmd/server/main.go:buildFinalHandler`
|
||||
- SCEP preflight gate (CWE-306 closure): `cmd/server/main.go:preflightSCEPChallengePassword`
|
||||
- SCEP service-layer defense-in-depth (rejects enrollment on empty challenge password, `crypto/subtle.ConstantTimeCompare`): `internal/service/scep.go`
|
||||
- Final handler dispatch regression harness (27 subtests): `cmd/server/finalhandler_test.go`
|
||||
- OpenAPI spec `security: []` overrides on unauthenticated paths: `api/openapi.yaml` (EST `/cacerts`, `/simpleenroll`, `/simplereenroll`, `/csrattrs`; SCEP `/scep` GET+POST; PKI `/crl/{issuer_id}`, `/ocsp/{issuer_id}/{serial}`)
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
@@ -110,7 +116,7 @@ Each section includes:
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **API Key Policy** — All API access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup.
|
||||
- **API Key Policy** — All `/api/v1/*` access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup. The standards-based enrollment and PKI distribution endpoints (EST, SCEP, CRL, OCSP) are served unauthenticated at the HTTP layer per their respective RFCs; see CC6.1 for the full authentication contract and CWE-306 closure via `preflightSCEPChallengePassword`.
|
||||
- **Agent Authentication** — Agents authenticate to the server via API keys (same mechanism as users). Agent credentials are separate from user API keys.
|
||||
- **Private Key Policy** — Agent-side key generation is the default (`CERTCTL_KEYGEN_MODE=agent`). Server-side keygen (`CERTCTL_KEYGEN_MODE=server`) requires explicit configuration and logs a warning: "server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only".
|
||||
- **Password Policy** — Not applicable; certctl uses API keys exclusively. Password management is delegated to your organization's IAM system if you integrate OIDC/SSO (V3).
|
||||
@@ -183,15 +189,20 @@ Each section includes:
|
||||
|
||||
- **Health Endpoint** — `GET /health` returns 200 OK with service status. Consumed by Docker health checks and Kubernetes probes.
|
||||
- **Readiness Endpoint** — `GET /ready` returns 200 OK when the database is connected and migrations are applied.
|
||||
- **Background Scheduler Monitoring** — 7 background loops run on a fixed schedule:
|
||||
- Renewal loop: every 1 hour, scans for certificates approaching renewal threshold
|
||||
- Job processor loop: every 30 seconds, picks up pending/waiting jobs and advances their state
|
||||
- Health check loop: every 2 minutes, pings agents to detect downtime
|
||||
- Notification dispatcher loop: every 1 minute, sends queued alerts
|
||||
- Short-lived cert expiry loop: every 30 seconds, marks expired short-lived credentials
|
||||
- Network scanner loop: every 6 hours, scans enabled TLS endpoints for certificate discovery
|
||||
- Digest emailer loop: every 24 hours, sends scheduled certificate digest email to configured recipients
|
||||
Each loop includes error handling and logs failures via structured slog.
|
||||
- **Background Scheduler Monitoring** — 12 background loops (8 always-on + 4 opt-in) run on a fixed schedule. Authoritative topology in `docs/architecture.md`:
|
||||
- Renewal loop (always-on, 1 hour): scans for certificates approaching renewal threshold
|
||||
- Job processor loop (always-on, 30 seconds): picks up pending/waiting jobs and advances their state
|
||||
- Job retry loop (always-on, 5 minutes, `CERTCTL_SCHEDULER_RETRY_INTERVAL`): retries Failed jobs (I-001)
|
||||
- Job timeout reaper loop (always-on, 10 minutes, `CERTCTL_JOB_TIMEOUT_INTERVAL`): fails AwaitingCSR/AwaitingApproval jobs past timeout (I-003)
|
||||
- Agent health check loop (always-on, 2 minutes): pings agents to detect downtime
|
||||
- Notification dispatcher loop (always-on, 1 minute): sends queued alerts
|
||||
- Notification retry loop (always-on, 2 minutes, `CERTCTL_NOTIFICATION_RETRY_INTERVAL`): exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005)
|
||||
- Short-lived cert expiry loop (always-on, 30 seconds): marks expired short-lived credentials
|
||||
- Network scanner loop (opt-in, 6 hours, `CERTCTL_NETWORK_SCAN_ENABLED`): scans enabled TLS endpoints for certificate discovery
|
||||
- Digest emailer loop (opt-in, 24 hours, `CERTCTL_DIGEST_INTERVAL`): sends scheduled certificate digest email to configured recipients
|
||||
- Endpoint health loop (opt-in, 60 seconds, `CERTCTL_HEALTH_CHECK_INTERVAL`): continuous TLS health probes (M48)
|
||||
- Cloud discovery loop (opt-in, 6 hours, `CERTCTL_CLOUD_DISCOVERY_INTERVAL`): cloud secret manager certificate discovery (M50)
|
||||
Each loop includes `atomic.Bool` idempotency guards, error handling, and structured slog failure logs.
|
||||
- **Metrics Endpoints** — Two formats for monitoring integration:
|
||||
- `GET /api/v1/metrics` — JSON object with gauges, counters, and uptime for custom dashboards
|
||||
- `GET /api/v1/metrics/prometheus` — Prometheus exposition format (`text/plain; version=0.0.4`) for native scraping by Prometheus, Grafana Agent, Datadog, and other OpenMetrics-compatible collectors
|
||||
@@ -282,12 +293,13 @@ Each section includes:
|
||||
- `certificateHold` — temporary revocation (can be "unhold" by reissue)
|
||||
- `privilegeWithdrawn` — access rights revoked
|
||||
Revocation is **immediate** (no approval workflow). The certificate is marked `Revoked` in inventory, an audit event is logged, and optional issuer notification is best-effort. All revoked certs are excluded from active deployments.
|
||||
- **CRL Endpoint** — `GET /api/v1/crl` returns a JSON-formatted Certificate Revocation List (serial, reason, timestamp for each revoked cert). `GET /api/v1/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA (useful for legacy clients that don't support OCSP).
|
||||
- **OCSP Responder** — `GET /api/v1/ocsp/{issuer_id}/{serial}` returns a signed OCSP response indicating whether a cert is good, revoked, or unknown. Clients (browsers, TLS libraries) query this endpoint to verify cert validity in real-time.
|
||||
- **CRL Endpoint** — `GET /.well-known/pki/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`), served unauthenticated for relying parties that don't hold certctl API credentials.
|
||||
- **OCSP Responder** — `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` returns a signed OCSP response indicating whether a cert is good, revoked, or unknown (RFC 6960, `Content-Type: application/ocsp-response`). Also unauthenticated. Clients (browsers, TLS libraries) query this endpoint to verify cert validity in real-time.
|
||||
- **Revocation Notifications** — When a cert is revoked, notifications are sent to:
|
||||
- Certificate owner (email)
|
||||
- Configured webhooks (if you have a SIEM that subscribes)
|
||||
- Slack/Teams channels (if notifiers are configured)
|
||||
- **Bulk Revocation for Fleet-Wide Incidents** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. Essential for incident response: key compromise affecting multiple certs, CA distrust events, decommissioning a team's infrastructure. Each bulk revocation creates individual jobs reusing the existing revocation pipeline, ensuring audit trail and notifications for every certificate.
|
||||
- **Short-Lived Cert Exemption** — Certificates with TTL < 1 hour (configured in profile) skip CRL/OCSP publication. Expiry is the revocation mechanism for short-lived certs (e.g., Kubernetes pod certs, session tokens).
|
||||
- **Deployment Rollback** — If a revoked cert is still deployed (shouldn't happen, but race conditions exist), operators can manually redeploy a previous version via the GUI. Rollback is audited.
|
||||
|
||||
@@ -302,7 +314,6 @@ Each section includes:
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **Bulk Revocation** — Revoke all certs issued by a specific profile, owner, or agent in a single API call (useful for large-scale incidents like CA compromise)
|
||||
- **Revocation Automation** — Trigger revocation based on external events (e.g., employee termination, security breach alert from CT Log monitoring)
|
||||
|
||||
**Operator Responsibility**:
|
||||
@@ -453,15 +464,15 @@ Each section includes:
|
||||
| | Metrics JSON Endpoint | `GET /api/v1/metrics` (gauges, counters, uptime) | ✅ | ✅ | Set thresholds, configure alerting |
|
||||
| | Stats API (time-series) | `GET /api/v1/stats/*` (summary, status, expiration, jobs, issuance) | ✅ | ✅ | Integrate into dashboards, SLO tracking |
|
||||
| | Structured Logging | `slog` middleware with request IDs | ✅ | ✅ | Aggregate logs to SIEM, define retention policy |
|
||||
| | Background Scheduler | 7 loops (renewal 1h, jobs 30s, health 2m, notifications 1m, short-lived 30s, network scan 6h, digest 24h) | ✅ | ✅ | Alert on scheduler loop failures |
|
||||
| | Background Scheduler | 12 loops (8 always-on: renewal 1h, jobs 30s, job retry 5m I-001, job timeout 10m I-003, health 2m, notifications 1m, notif retry 2m I-005, short-lived 30s; 4 opt-in: network scan 6h, digest 24h, endpoint health 60s M48, cloud discovery 6h M50) | ✅ | ✅ | Alert on scheduler loop failures |
|
||||
| **CC7.2** Anomaly Detection | Immutable API Audit Trail | `internal/api/middleware/audit.go`, `GET /api/v1/audit` | ✅ | Enhanced (SIEM export) | Integrate into SIEM, search for anomalies, archive long-term |
|
||||
| | Expiration Threshold Alerting | Configurable per-policy (default 30/14/7/0 days) | ✅ | ✅ | Configure thresholds, integrate notifications |
|
||||
| | Status Auto-Transitions | Active → Expiring (30d) → Expired (0d) | ✅ | ✅ | Monitor status changes in audit trail |
|
||||
| | Notification Routing | Email, Slack, Teams, PagerDuty, OpsGenie | ✅ | ✅ | Configure notifiers, on-call integration |
|
||||
| | Deployment Rollback | Redeploy previous cert version via GUI | ✅ | ✅ | Audit rollback decisions |
|
||||
| **CC7.3** Incident Response | Revocation API (RFC 5280 reasons) | `POST /api/v1/certificates/{id}/revoke` | ✅ | Enhanced (bulk revocation) | Establish incident response policy |
|
||||
| | CRL Endpoint (JSON + DER) | `GET /api/v1/crl`, `GET /api/v1/crl/{issuer_id}` | ✅ | ✅ | Ensure CRL/OCSP accessible to all clients |
|
||||
| | OCSP Responder | `GET /api/v1/ocsp/{issuer_id}/{serial}` | ✅ | ✅ | Test revocation in staging |
|
||||
| | CRL Endpoint (DER, RFC 5280 §5) | `GET /.well-known/pki/crl/{issuer_id}` (unauthenticated, `application/pkix-crl`) | ✅ | ✅ | Ensure CRL/OCSP accessible to all clients without API keys |
|
||||
| | OCSP Responder (RFC 6960) | `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (unauthenticated, `application/ocsp-response`) | ✅ | ✅ | Test revocation in staging |
|
||||
| | Revocation Notifications | Email, webhook, Slack/Teams on revocation | ✅ | ✅ | Integrate into on-call, document justification separately |
|
||||
| | Short-Lived Cert Exemption | TTL < 1h skip CRL/OCSP | ✅ | ✅ | Configure profiles appropriately |
|
||||
| **CC7.4** Risk Mitigation | Renewal Job Tracking | Job state machine (Pending → Running → Completed/Failed) | ✅ | ✅ | Monitor renewal success rate |
|
||||
|
||||
+21
-7
@@ -123,11 +123,13 @@ At no point does the private key leave the agent. This is a fundamental security
|
||||
|
||||
Agents also report **metadata** about themselves — their operating system, CPU architecture, IP address, hostname, and version — with every heartbeat. This gives ops teams fleet-wide visibility (e.g., "how many agents are running on ARM?", "which agents are still on v1.0.0?") and powers **agent groups** — dynamic device grouping where policies can be scoped to specific agent criteria like OS type, architecture, or network subnet.
|
||||
|
||||
**Retiring an agent.** When you decommission a server, the certctl record for its agent needs to be retired, not deleted. certctl uses a **soft-delete** model: `DELETE /api/v1/agents/{id}` stamps the row with a retired-at timestamp and a reason, instead of removing it. This is deliberate — an audit trail of "who owned this certificate, on which host, for which team" stays intact forever, and the downstream deployment_targets, certificates, and jobs keep valid foreign keys. Retired agents are filtered out of default list views and the dashboard's agent counter, but remain visible through a separate retired-agents view for compliance reconciliation. If the agent still has active deployment targets, deployed certificates, or pending jobs, retirement is blocked by default so you don't silently orphan those rows; the API responds with the exact counts so you can retire or reassign each dependency explicitly. A force-retire escape hatch (`?force=true&reason=...`) is available for true decommission scenarios — it transactionally retires the downstream targets, cancels pending jobs, and records the cascade in the audit trail with the reason you provided. Four internal sentinel agents that back the network scanner and the cloud secret-manager discovery sources cannot be retired at all, even with force, because retiring them would orphan their subsystems. Once retired, an agent that still attempts to heartbeat receives `410 Gone` — the agent process reads that as "you've been retired, shut down" and exits cleanly.
|
||||
|
||||
### Deployment Targets
|
||||
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, Apache httpd servers, HAProxy load balancers, F5 BIG-IP appliances, Microsoft IIS servers. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX or Apache config, building a combined PEM for HAProxy).
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, Apache httpd servers, HAProxy load balancers, Traefik reverse proxies, Caddy servers, Envoy gateways, Postfix/Dovecot mail servers, Microsoft IIS servers, and network appliances. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX or Apache config, building a combined PEM for HAProxy).
|
||||
|
||||
For targets where an agent runs directly on the machine (NGINX, Apache, HAProxy, IIS), the agent deploys certificates locally — no remote access needed. For network appliances where you can't install an agent (F5 BIG-IP, Palo Alto, etc.), a **proxy agent** in the same network zone picks up the deployment job and calls the appliance's API. The server never initiates outbound connections to any target.
|
||||
For targets where an agent runs directly on the machine (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS), the agent deploys certificates locally — no remote access needed. For network appliances where you can't install an agent (F5 BIG-IP, Palo Alto, etc.), a **proxy agent** in the same network zone picks up the deployment job and calls the appliance's API. The server never initiates outbound connections to any target.
|
||||
|
||||
## The Certificate Lifecycle
|
||||
|
||||
@@ -183,11 +185,11 @@ Profiles are managed via the API (`/api/v1/profiles`) and the GUI, and can be as
|
||||
|
||||
For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal.
|
||||
|
||||
### Renewal Timing: Thresholds vs. ARI (RFC 9702)
|
||||
### Renewal Timing: Thresholds vs. ARI (RFC 9773)
|
||||
|
||||
**Traditional approach (thresholds):** By default, certctl uses static renewal thresholds — renew a certificate at a fixed number of days before expiry (default: 30 days). This simple, predictable model works for most use cases: it avoids unnecessary renewals near expiry and gives you a predictable window to catch failures.
|
||||
|
||||
**Advanced approach (ACME ARI):** Some Certificate Authorities support ACME Renewal Information (RFC 9702), which allows the CA to tell certctl the optimal time to renew. Instead of guessing "renew 30 days before expiry," the CA responds with a precise `suggestedWindow` containing start and end times. This is useful when:
|
||||
**Advanced approach (ACME ARI):** Some Certificate Authorities support ACME Renewal Information (RFC 9773), which allows the CA to tell certctl the optimal time to renew. Instead of guessing "renew 30 days before expiry," the CA responds with a precise `suggestedWindow` containing start and end times. This is useful when:
|
||||
- The CA is performing maintenance and wants to batch renewals in a specific window
|
||||
- The CA is coordinating a mass revocation (e.g., due to a compromise) and needs to control renewal timing
|
||||
- You want to avoid thundering herd renewal spikes by accepting the CA's suggested timing
|
||||
@@ -196,6 +198,16 @@ For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApprova
|
||||
|
||||
**Graceful degradation:** If your CA doesn't support ARI (returns 404 from the ARI endpoint), certctl automatically falls back to the traditional threshold-based renewal. No configuration change needed — the fallback is transparent. Errors from the CA are logged as warnings and don't block the renewal process.
|
||||
|
||||
### Shorter Certificate Validity (45-Day and 6-Day Certs)
|
||||
|
||||
The industry is moving toward shorter certificate lifetimes. The CA/Browser Forum's SC-081v3 ballot mandates a phased reduction: 200-day max (March 2026), 100-day max (March 2027), and 47-day max (March 2029). Let's Encrypt has already begun reducing default validity to 45 days, and offers 6-day "shortlived" certificates via ACME profile selection.
|
||||
|
||||
certctl handles shorter-lived certificates correctly out of the box:
|
||||
|
||||
- **45-day certs** with the default 31-day renewal window trigger renewal at day 14 — at roughly 1/3 of the cert's lifetime.
|
||||
- **6-day "shortlived" certs** are always within the renewal window. ARI (RFC 9773) is the expected renewal path for these — the CA directs timing. Short-lived certs also skip CRL/OCSP since expiry is sufficient revocation (per profile TTL < 1 hour exemption).
|
||||
- **ACME profile selection** lets you request specific certificate profiles from your CA. Set `CERTCTL_ACME_PROFILE=shortlived` to get 6-day certificates from Let's Encrypt, or `CERTCTL_ACME_PROFILE=tlsserver` for standard TLS certificates.
|
||||
|
||||
### Certificate Revocation
|
||||
|
||||
When a private key is compromised, a certificate is superseded, or a service is decommissioned, you need to revoke the certificate immediately — not wait for it to expire. Revocation tells clients "stop trusting this certificate right now."
|
||||
@@ -204,9 +216,11 @@ certctl implements revocation using three complementary mechanisms:
|
||||
|
||||
**Revocation API**: `POST /api/v1/certificates/{id}/revoke` marks a certificate as revoked in the inventory, records the revocation in a dedicated `certificate_revocations` table, notifies the issuing CA (best-effort — the revocation succeeds even if the CA is unreachable), creates an audit trail entry, and sends notifications. You can specify an RFC 5280 reason code (keyCompromise, superseded, cessationOfOperation, etc.) or let it default to "unspecified."
|
||||
|
||||
**Certificate Revocation List (CRL)**: certctl serves both a JSON-formatted CRL at `GET /api/v1/crl` and DER-encoded X.509 CRLs per issuer at `GET /api/v1/crl/{issuer_id}`. The DER CRL is signed by the issuing CA's key and has 24-hour validity — clients can download it periodically to check revocation status offline.
|
||||
**Bulk Revocation** (Fleet-Level Incident Response): For large-scale incidents like CA compromise or team infrastructure decommissioning, `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria in a single operation. Filter by profile, owner, team, agent group, or issuer to target the affected certificate set. This is essential for incident response — instead of revoking certificates one-by-one, operators can revoke an entire fleet in minutes. Bulk revocation creates individual revocation jobs that reuse the existing revocation pipeline, ensuring every certificate is audited and notifications are sent.
|
||||
|
||||
**OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}`. It returns signed OCSP responses (good, revoked, or unknown) so clients can verify certificate status without downloading the full CRL.
|
||||
**Certificate Revocation List (CRL)**: certctl serves DER-encoded X.509 CRLs per issuer at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 wire format, RFC 8615 well-known namespace). The endpoint is unauthenticated so any relying party — browser, TLS client, hardware appliance — can fetch it without a certctl API key. The CRL is signed by the issuing CA's key and has 24-hour validity; clients can download it periodically to check revocation status offline. The response carries `Content-Type: application/pkix-crl`.
|
||||
|
||||
**OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960). Like the CRL endpoint, it is unauthenticated and returns signed OCSP responses (good, revoked, or unknown) with `Content-Type: application/ocsp-response`, so clients can verify certificate status without downloading the full CRL.
|
||||
|
||||
Short-lived certificates (those assigned to profiles with TTL under 1 hour) are exempt from CRL and OCSP — their rapid expiry is considered sufficient revocation. This is a deliberate design choice to reduce infrastructure overhead for ephemeral machine-to-machine credentials.
|
||||
|
||||
@@ -242,7 +256,7 @@ The CLI supports both table and JSON output formats (`--format table` or `--form
|
||||
|
||||
### MCP Server (AI Integration)
|
||||
|
||||
certctl includes an MCP (Model Context Protocol) server that exposes 78 MCP tools covering the REST API. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?"
|
||||
certctl includes an MCP (Model Context Protocol) server that exposes the entire REST API as MCP tools. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?"
|
||||
|
||||
The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key.
|
||||
|
||||
|
||||
+595
-53
@@ -11,9 +11,13 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
- [Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)](#built-in-acme-v2-lets-encrypt-sectigo-zerossl)
|
||||
- [Built-in: step-ca (Smallstep Private CA)](#built-in-step-ca-smallstep-private-ca)
|
||||
- [OpenSSL / Custom CA](#openssl--custom-ca)
|
||||
- [Built-in: Vault PKI](#built-in-vault-pki)
|
||||
- [Built-in: DigiCert CertCentral](#built-in-digicert-certcentral)
|
||||
- [Built-in: Sectigo SCM](#built-in-sectigo-scm)
|
||||
- [Built-in: Google CAS](#built-in-google-cas)
|
||||
- [Built-in: AWS ACM Private CA](#built-in-aws-acm-private-ca)
|
||||
- [Revocation Across Issuers](#revocation-across-issuers)
|
||||
- [EST Integration (GetCACertPEM)](#est-integration-getcacertpem)
|
||||
- [Planned Issuers](#planned-issuers)
|
||||
- [Building a Custom Issuer](#building-a-custom-issuer)
|
||||
3. [Target Connector](#target-connector)
|
||||
- [Interface](#interface-1)
|
||||
@@ -21,9 +25,15 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
- [Built-in: Apache httpd](#built-in-apache-httpd)
|
||||
- [Built-in: HAProxy](#built-in-haproxy)
|
||||
- [Built-in: Traefik](#built-in-traefik)
|
||||
- [Built-in: Envoy](#built-in-envoy)
|
||||
- [Built-in: Postfix / Dovecot](#built-in-postfix--dovecot)
|
||||
- [Built-in: Caddy](#built-in-caddy)
|
||||
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
||||
- [IIS (Interface Only, Dual-Mode)](#iis-interface-only-dual-mode)
|
||||
- [F5 BIG-IP (Implemented)](#f5-big-ip-implemented)
|
||||
- [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode)
|
||||
- [SSH (Agentless Deployment)](#ssh-agentless-deployment)
|
||||
- [Windows Certificate Store](#windows-certificate-store)
|
||||
- [Java Keystore (JKS / PKCS#12)](#java-keystore-jks--pkcs12)
|
||||
- [Kubernetes Secrets](#kubernetes-secrets)
|
||||
4. [Notifier Connector](#notifier-connector)
|
||||
- [Interface](#interface-2)
|
||||
5. [Registering a Connector](#registering-a-connector)
|
||||
@@ -51,8 +61,8 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
|
||||
Three types of connectors:
|
||||
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
||||
1. **Issuer Connector** — Obtains certificates from CAs. 9 built-in: Local CA (self-signed + sub-CA), ACME v2 (HTTP-01, DNS-01, DNS-PERSIST-01, ARI, EAB, profile selection), step-ca, OpenSSL/Custom CA, Vault PKI, DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM Private CA
|
||||
2. **Target Connector** — Deploys certificates to infrastructure. 14 built-in: NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (local + WinRM), F5 BIG-IP (proxy agent), SSH (agentless), Windows Certificate Store, Java Keystore, Kubernetes Secrets
|
||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
||||
|
||||
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
||||
@@ -145,10 +155,12 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
|
||||
|
||||
**Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`.
|
||||
|
||||
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation via `GET /api/v1/crl/{issuer_id}` with 24-hour validity. An embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` returns signed OCSP responses for issued certificates (good/revoked/unknown status). Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
|
||||
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`) with 24-hour validity. An embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`) returns signed OCSP responses for issued certificates (good/revoked/unknown status). Both endpoints are reachable by relying parties with no certctl API credentials, which is how standard TLS clients, browsers, and hardware appliances consume these resources. Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
|
||||
|
||||
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
|
||||
|
||||
**MaxTTL enforcement (M11c):** When a certificate profile defines a maximum TTL, the Local CA caps the `NotAfter` field to `min(validity_days, maxTTL)`. This ensures certificates never exceed the profile's configured lifetime regardless of the issuer's `validity_days` setting.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
@@ -171,7 +183,7 @@ The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x
|
||||
|
||||
**DNS-PERSIST-01 (standing record):** Creates a one-time persistent TXT record at `_validation-persist.<domain>` containing the CA's issuer domain and your ACME account URI. Once set, this record authorizes unlimited future certificate issuances without per-renewal DNS updates. Based on [draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) and CA/Browser Forum ballot SC-088v3. If the CA doesn't offer dns-persist-01 yet, the connector falls back to dns-01 automatically.
|
||||
|
||||
**ACME Renewal Information (ARI, RFC 9702):** Instead of using fixed renewal thresholds (e.g., renew 30 days before expiry), certctl can ask the CA when it should renew. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. The ARI protocol lets the CA specify a `suggestedWindow` (start and end times) for when you should renew — useful for distributing load during maintenance windows or coordinating mass revocation scenarios. Cert ID is computed as `base64url(SHA-256(DER cert))`. If the CA doesn't support ARI (404 response), certctl automatically falls back to threshold-based renewal with no operator intervention required.
|
||||
**ACME Renewal Information (ARI, RFC 9773):** Instead of using fixed renewal thresholds (e.g., renew 30 days before expiry), certctl can ask the CA when it should renew. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. The ARI protocol lets the CA specify a `suggestedWindow` (start and end times) for when you should renew — useful for distributing load during maintenance windows or coordinating mass revocation scenarios. Cert ID is computed as `base64url(SHA-256(DER cert))`. If the CA doesn't support ARI (404 response), certctl automatically falls back to threshold-based renewal with no operator intervention required.
|
||||
|
||||
HTTP-01 configuration:
|
||||
```json
|
||||
@@ -241,6 +253,9 @@ Environment variables for the default ACME connector:
|
||||
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and dns-persist-01)
|
||||
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01)
|
||||
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` — CA issuer domain for persistent record (dns-persist-01 only, e.g., `letsencrypt.org`)
|
||||
- `CERTCTL_ACME_PROFILE` — Certificate profile for the newOrder request. Let's Encrypt supports `tlsserver` (standard TLS, default) and `shortlived` (6-day certs). Leave empty for the CA's default profile.
|
||||
|
||||
**Certificate Profiles:** Let's Encrypt (GA January 2026) supports ACME certificate profile selection. Set `CERTCTL_ACME_PROFILE=shortlived` to request 6-day certificates — ideal for ephemeral workloads where short validity substitutes for revocation. The `tlsserver` profile produces standard TLS certificates. When the profile field is empty (default), the CA uses its default profile, maintaining full backward compatibility.
|
||||
|
||||
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
||||
|
||||
@@ -272,7 +287,9 @@ Environment variables:
|
||||
|
||||
The connector is registered in the issuer registry under `iss-stepca`. step-ca also works with the existing ACME connector (point `iss-acme-*` at step-ca's ACME directory URL for ACME-based issuance).
|
||||
|
||||
**Note:** step-ca-issued certificates rely on step-ca's own CRL/OCSP infrastructure. certctl's local CRL/OCSP endpoints (`GET /api/v1/crl/{issuer_id}` and `GET /api/v1/ocsp/{issuer_id}/{serial}`) are populated from step-ca's revocation data if available, but clients should validate against step-ca's endpoints for the authoritative status.
|
||||
**Note:** step-ca-issued certificates rely on step-ca's own CRL/OCSP infrastructure. certctl's local CRL/OCSP endpoints (`GET /.well-known/pki/crl/{issuer_id}` and `GET /.well-known/pki/ocsp/{issuer_id}/{serial}`, served unauthenticated per RFC 5280 §5 / RFC 6960 / RFC 8615) are populated from step-ca's revocation data if available, but clients should validate against step-ca's endpoints for the authoritative status.
|
||||
|
||||
**MaxTTL enforcement (M11c):** When a certificate profile defines a maximum TTL, the step-ca connector caps the `NotAfter` field to ensure the issued certificate does not exceed the profile limit, regardless of the step-ca provisioner's own maximum.
|
||||
|
||||
Location: `internal/connector/issuer/stepca/stepca.go`
|
||||
|
||||
@@ -301,29 +318,197 @@ Each issuer handles revocation differently:
|
||||
- **step-ca**: Calls step-ca's `/revoke` API endpoint. Clients should check step-ca's own CRL/OCSP for authoritative status.
|
||||
- **OpenSSL/Custom CA**: Invokes the configured revoke script (`CERTCTL_OPENSSL_REVOKE_SCRIPT`) with the serial number as an argument.
|
||||
|
||||
### EST Integration (GetCACertPEM)
|
||||
### EST/SCEP Integration (GetCACertPEM)
|
||||
|
||||
The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used by the EST server's `/.well-known/est/cacerts` endpoint (RFC 7030) to distribute the CA chain to enrolling devices. Each issuer handles this differently:
|
||||
The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used by both the EST server's `/.well-known/est/cacerts` endpoint (RFC 7030) and the SCEP server's `GetCACert` operation (RFC 8894) to distribute the CA chain to enrolling devices. Each issuer handles this differently:
|
||||
|
||||
- **Local CA**: Returns the CA certificate PEM (self-signed or sub-CA cert). This is the primary EST issuer.
|
||||
- **Local CA**: Returns the CA certificate PEM (self-signed or sub-CA cert). This is the primary EST/SCEP issuer.
|
||||
- **ACME**: Returns error — ACME CAs provide chains per-issuance, not statically.
|
||||
- **step-ca**: Returns error — step-ca serves its own `/root` endpoint for CA distribution.
|
||||
- **OpenSSL/Custom CA**: Returns error — custom script-based CAs have no CA cert access through certctl.
|
||||
|
||||
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||
Note: EST and SCEP are not connectors — they are protocol handlers (`internal/api/handler/est.go` and `internal/api/handler/scep.go`) that delegate certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID` or `CERTCTL_SCEP_ISSUER_ID`. Both share a common `internal/pkcs7` package for PKCS#7 response encoding. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||
|
||||
### Coming in V2.1
|
||||
### Built-in: Vault PKI
|
||||
|
||||
The following issuer connectors are planned for the v2.1.0 release:
|
||||
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
|
||||
|
||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine (`/v1/{mount}/sign/{role}` API) for organizations using Vault as their internal CA. Token auth, configurable mount and role.
|
||||
- **DigiCert** — Commercial CA integration via DigiCert CertCentral REST API. Async order model (submit → poll for completion). OV/EV certificate support.
|
||||
**Configuration:**
|
||||
|
||||
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_VAULT_ADDR` | — | Vault server address (e.g., `https://vault.internal:8200`) |
|
||||
| `CERTCTL_VAULT_TOKEN` | — | Vault auth token with permissions on the PKI mount |
|
||||
| `CERTCTL_VAULT_MOUNT` | `pki` | PKI secrets engine mount path |
|
||||
| `CERTCTL_VAULT_ROLE` | — | PKI role name for certificate signing |
|
||||
| `CERTCTL_VAULT_TTL` | `8760h` | Certificate validity period (TTL) |
|
||||
|
||||
The connector is registered in the issuer registry under `iss-vault`. Vault issues certificates synchronously via the `/v1/{mount}/sign/{role}` API with `X-Vault-Token` header authentication. The issued certificate is parsed to extract serial number, validity dates, and chain information.
|
||||
|
||||
**Note:** CRL and OCSP are managed by Vault itself. Clients should validate certificate status against Vault's own CRL/OCSP endpoints (`GET /v1/{mount}/crl` and Vault's OCSP responder). certctl does not generate local CRL/OCSP for Vault-issued certificates. Revocation is recorded locally but Vault is the authoritative source.
|
||||
|
||||
**MaxTTL enforcement (M11c):** When a certificate profile defines a maximum TTL, the Vault connector overrides the TTL string in the signing request to ensure the issued certificate does not exceed the profile limit. This is applied before Vault's own role-level max TTL.
|
||||
|
||||
Location: `internal/connector/issuer/vault/vault.go`
|
||||
|
||||
### Built-in: DigiCert CertCentral
|
||||
|
||||
The DigiCert connector integrates with DigiCert's CertCentral REST API for ordering and managing certificates from DigiCert's commercial CA. It supports both Domain Validated (DV) and Organization/Extended Validated (OV/EV) certificates, with async order processing.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_DIGICERT_API_KEY` | — | DigiCert API key (X-DC-DEVKEY header) |
|
||||
| `CERTCTL_DIGICERT_ORG_ID` | — | DigiCert organization ID |
|
||||
| `CERTCTL_DIGICERT_PRODUCT_TYPE` | `ssl_basic` | Certificate product (e.g., `ssl_basic`, `ssl_plus`, `ssl_ev`) |
|
||||
| `CERTCTL_DIGICERT_BASE_URL` | `https://www.digicert.com/services/v2` | DigiCert API base URL |
|
||||
|
||||
The connector submits certificate orders to DigiCert's `/order/certificate/create` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by DigiCert) and poll-based completion. The connector periodically checks order status via `/order/certificate/{order_id}` until the certificate is available.
|
||||
|
||||
**Authentication:** API key passed via `X-DC-DEVKEY` header, with organization ID in request body.
|
||||
|
||||
**Note:** CRL and OCSP are managed by DigiCert. Clients should validate certificate status against DigiCert's infrastructure. certctl records the revocation locally but does not notify DigiCert for revocation — use DigiCert's dashboard for revocation management.
|
||||
|
||||
Location: `internal/connector/issuer/digicert/digicert.go`
|
||||
|
||||
### Built-in: Sectigo SCM
|
||||
|
||||
The Sectigo connector integrates with Sectigo Certificate Manager's REST API for ordering and managing DV, OV, and EV certificates. Like DigiCert, it uses an async order model: submit an enrollment, receive an sslId, then poll for completion.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SECTIGO_CUSTOMER_URI` | — | Sectigo customer URI (organization identifier) |
|
||||
| `CERTCTL_SECTIGO_LOGIN` | — | API account login |
|
||||
| `CERTCTL_SECTIGO_PASSWORD` | — | API account password |
|
||||
| `CERTCTL_SECTIGO_ORG_ID` | — | Organization ID (integer) |
|
||||
| `CERTCTL_SECTIGO_CERT_TYPE` | — | Certificate type ID (integer, from `/ssl/v1/types`) |
|
||||
| `CERTCTL_SECTIGO_TERM` | `365` | Certificate validity in days |
|
||||
| `CERTCTL_SECTIGO_BASE_URL` | `https://cert-manager.com/api` | Sectigo API base URL |
|
||||
|
||||
The connector submits certificate enrollments to Sectigo's `/ssl/v1/enroll` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by Sectigo) and poll-based completion. The connector periodically checks enrollment status via `/ssl/v1/{sslId}` and downloads the PEM bundle via `/ssl/v1/collect/{sslId}/pem` when issued.
|
||||
|
||||
**Authentication:** Three custom headers on every request — `customerUri`, `login`, and `password`.
|
||||
|
||||
**Note:** CRL and OCSP are managed by Sectigo. certctl records revocations locally and notifies Sectigo via `/ssl/v1/revoke/{sslId}`.
|
||||
|
||||
Location: `internal/connector/issuer/sectigo/sectigo.go`
|
||||
|
||||
### Built-in: Google CAS
|
||||
|
||||
Google Cloud Certificate Authority Service — managed private CA on GCP. Synchronous issuance via CAS REST API with OAuth2 service account auth.
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
| `CERTCTL_GOOGLE_CAS_PROJECT` | Yes | — | GCP project ID |
|
||||
| `CERTCTL_GOOGLE_CAS_LOCATION` | Yes | — | GCP region (e.g., `us-central1`) |
|
||||
| `CERTCTL_GOOGLE_CAS_CA_POOL` | Yes | — | CA pool name |
|
||||
| `CERTCTL_GOOGLE_CAS_CREDENTIALS` | Yes | — | Path to service account JSON |
|
||||
| `CERTCTL_GOOGLE_CAS_TTL` | No | `8760h` | Default certificate TTL |
|
||||
|
||||
**Authentication:** OAuth2 service account. The connector reads a service account JSON file, signs a JWT with the private key, and exchanges it for an access token at Google's token endpoint. Tokens are cached and refreshed automatically (5 min before expiry).
|
||||
|
||||
**Note:** CRL and OCSP are managed by Google CAS directly. certctl records revocations locally and notifies Google CAS via the revoke endpoint.
|
||||
|
||||
Location: `internal/connector/issuer/googlecas/googlecas.go`
|
||||
|
||||
### Built-in: AWS ACM Private CA
|
||||
|
||||
AWS Certificate Manager Private Certificate Authority — managed private CA on AWS. Synchronous issuance via ACM PCA API with standard AWS credential chain (env vars, IAM roles, instance profiles, SSO).
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
| `CERTCTL_AWS_PCA_REGION` | Yes | — | AWS region (e.g., `us-east-1`) |
|
||||
| `CERTCTL_AWS_PCA_CA_ARN` | Yes | — | ARN of the ACM Private CA |
|
||||
| `CERTCTL_AWS_PCA_SIGNING_ALGORITHM` | No | `SHA256WITHRSA` | Signing algorithm |
|
||||
| `CERTCTL_AWS_PCA_VALIDITY_DAYS` | No | `365` | Certificate validity in days |
|
||||
| `CERTCTL_AWS_PCA_TEMPLATE_ARN` | No | — | Optional certificate template ARN |
|
||||
|
||||
**Supported signing algorithms:** SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA, SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA.
|
||||
|
||||
**Authentication:** Standard AWS credential chain. The connector uses `aws-sdk-go-v2/config.LoadDefaultConfig()` which supports environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), IAM roles (EC2/ECS), instance profiles, and SSO credentials.
|
||||
|
||||
**Note:** CRL and OCSP are managed by AWS ACM PCA directly. certctl records revocations locally and notifies AWS via the RevokeCertificate API with RFC 5280 reason mapping.
|
||||
|
||||
Location: `internal/connector/issuer/awsacmpca/awsacmpca.go`
|
||||
|
||||
### Built-in: Entrust Certificate Services
|
||||
|
||||
Entrust CA Gateway REST API with mutual TLS (mTLS) client certificate authentication. Supports synchronous issuance (200 OK with PEM) and approval-pending flows (201 Accepted with async polling).
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
| `CERTCTL_ENTRUST_API_URL` | Yes | — | Entrust CA Gateway base URL |
|
||||
| `CERTCTL_ENTRUST_CLIENT_CERT_PATH` | Yes | — | Path to mTLS client certificate PEM |
|
||||
| `CERTCTL_ENTRUST_CLIENT_KEY_PATH` | Yes | — | Path to mTLS client private key PEM |
|
||||
| `CERTCTL_ENTRUST_CA_ID` | Yes | — | Certificate Authority ID (from `GET /certificate-authorities`) |
|
||||
| `CERTCTL_ENTRUST_PROFILE_ID` | No | — | Optional enrollment profile ID |
|
||||
|
||||
**Authentication:** Mutual TLS — the client certificate and key are loaded via `tls.LoadX509KeyPair()` and attached to the HTTP transport. No API key or token required.
|
||||
|
||||
**Issuance model:** Enrollment via `POST /v1/certificate-authorities/{caId}/enrollments`. Returns 200 with PEM immediately for auto-approved enrollments, or 201 Accepted with a tracking ID for approval-pending orders. `GetOrderStatus` polls the enrollment endpoint.
|
||||
|
||||
**Note:** CRL and OCSP are managed by Entrust. certctl records revocations locally and notifies Entrust via `PUT /v1/certificate-authorities/{caId}/certificates/{serial}/revoke`.
|
||||
|
||||
Location: `internal/connector/issuer/entrust/entrust.go`
|
||||
|
||||
### Built-in: GlobalSign Atlas HVCA
|
||||
|
||||
GlobalSign Atlas High Volume CA REST API with dual authentication: mTLS for the TLS handshake and API key/secret headers for request authorization. Region-aware base URLs (EMEA, APAC, Americas).
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
| `CERTCTL_GLOBALSIGN_API_URL` | Yes | — | Atlas HVCA API URL (region-specific) |
|
||||
| `CERTCTL_GLOBALSIGN_API_KEY` | Yes | — | API key for request authentication |
|
||||
| `CERTCTL_GLOBALSIGN_API_SECRET` | Yes | — | API secret for request authentication |
|
||||
| `CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH` | Yes | — | Path to mTLS client certificate PEM |
|
||||
| `CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH` | Yes | — | Path to mTLS client private key PEM |
|
||||
| `CERTCTL_GLOBALSIGN_SERVER_CA_PATH` | No | system trust store | PEM bundle used to verify the Atlas API server certificate. Set this for private/lab Atlas deployments whose server TLS chain is not in the host's default trust bundle. |
|
||||
|
||||
**Authentication:** Dual — mTLS client certificate for TLS handshake plus `X-API-Key` and `X-API-Secret` headers on every request.
|
||||
|
||||
**TLS verification:** The connector always verifies the server certificate. When `server_ca_path` is set, the PEM bundle at that path is used as the trust anchor; otherwise the host's system trust store is used. TLS 1.2 is the minimum protocol version.
|
||||
|
||||
**Issuance model:** `POST /v2/certificates` returns a serial number. Certificate PEM is available after validation completes. Typically resolves within seconds for DV. `GetOrderStatus` polls the certificate endpoint.
|
||||
|
||||
**Note:** CRL and OCSP are managed by GlobalSign. certctl records revocations locally and notifies GlobalSign via `PUT /v2/certificates/{serial}/revoke`.
|
||||
|
||||
Location: `internal/connector/issuer/globalsign/globalsign.go`
|
||||
|
||||
### Built-in: EJBCA (Keyfactor)
|
||||
|
||||
EJBCA REST API for self-hosted open-source and enterprise CAs. Supports dual authentication: mTLS (default) or OAuth2 Bearer token, selectable via configuration.
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
| `CERTCTL_EJBCA_API_URL` | Yes | — | EJBCA REST API base URL |
|
||||
| `CERTCTL_EJBCA_AUTH_MODE` | No | `mtls` | Auth mode: `mtls` or `oauth2` |
|
||||
| `CERTCTL_EJBCA_CLIENT_CERT_PATH` | mTLS | — | Path to client certificate PEM (mTLS mode) |
|
||||
| `CERTCTL_EJBCA_CLIENT_KEY_PATH` | mTLS | — | Path to client key PEM (mTLS mode) |
|
||||
| `CERTCTL_EJBCA_TOKEN` | OAuth2 | — | Bearer token (oauth2 mode) |
|
||||
| `CERTCTL_EJBCA_CA_NAME` | Yes | — | EJBCA CA name |
|
||||
| `CERTCTL_EJBCA_CERT_PROFILE` | No | — | EJBCA certificate profile |
|
||||
| `CERTCTL_EJBCA_EE_PROFILE` | No | — | EJBCA end-entity profile |
|
||||
|
||||
**Authentication:** Configurable via `auth_mode`. In mTLS mode, client certificate and key are loaded for the TLS handshake. In OAuth2 mode, the token is sent as `Authorization: Bearer {token}`.
|
||||
|
||||
**Issuance model:** `POST /v1/certificate/pkcs10enroll` with base64-encoded CSR. Returns base64-encoded certificate PEM. EJBCA 9.3+ creates end-entity and issues cert in a single call. Approval-pending enrollments return 201.
|
||||
|
||||
**Revocation note:** EJBCA requires both issuer DN and serial number for revocation. The connector stores these as a composite `OrderID` in `issuer_dn::serial` format.
|
||||
|
||||
**Note:** CRL and OCSP are managed by the EJBCA instance. certctl records revocations locally and notifies EJBCA via `PUT /v1/certificate/{issuer_dn}/{serial}/revoke`.
|
||||
|
||||
Location: `internal/connector/issuer/ejbca/ejbca.go`
|
||||
|
||||
### ADCS Integration
|
||||
|
||||
Active Directory Certificate Services integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
||||
|
||||
### Building a Custom Issuer
|
||||
|
||||
Here's the structure for a HashiCorp Vault PKI issuer:
|
||||
Here's a simplified example showing the connector pattern (using a hypothetical Vault-like CA):
|
||||
|
||||
```go
|
||||
package vault
|
||||
@@ -547,51 +732,334 @@ When `mode` is `"api"`, the connector posts the certificate to the admin API end
|
||||
|
||||
Location: `internal/connector/target/caddy/caddy.go`
|
||||
|
||||
### F5 BIG-IP (Interface Only)
|
||||
### Built-in: Envoy
|
||||
|
||||
The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it.
|
||||
The Envoy connector uses file-based certificate delivery — it writes certificate and key files to a directory that Envoy watches via its SDS (Secret Discovery Service) file-based configuration or static `filename` references in the bootstrap config. When files change, Envoy automatically picks up the new certificates without requiring a reload command.
|
||||
|
||||
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status.
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"cert_dir": "/etc/envoy/certs",
|
||||
"cert_filename": "cert.pem",
|
||||
"key_filename": "key.pem",
|
||||
"chain_filename": "chain.pem",
|
||||
"sds_config": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `cert_dir` | string | (required) | Directory where Envoy watches for certificate files |
|
||||
| `cert_filename` | string | `cert.pem` | Filename for the certificate (leaf + chain unless `chain_filename` is set) |
|
||||
| `key_filename` | string | `key.pem` | Filename for the private key |
|
||||
| `chain_filename` | string | (empty) | If set, chain is written to a separate file instead of appended to the cert |
|
||||
| `sds_config` | bool | `false` | If true, writes an `sds.json` file for Envoy's file-based SDS provider |
|
||||
|
||||
When `sds_config` is `true`, the connector writes an SDS JSON file (`{cert_dir}/sds.json`) containing a `tls_certificate` resource that points to the cert and key file paths. Envoy's file-based SDS (`path_config_source`) watches this file for changes, providing automatic hot-reload of certificates. This is the recommended approach for production Envoy deployments using dynamic TLS configuration.
|
||||
|
||||
When `sds_config` is `false` (the default), the connector simply writes cert and key files. Use this mode when Envoy's bootstrap config references the cert/key files directly via static `filename` fields in the TLS context.
|
||||
|
||||
Location: `internal/connector/target/envoy/envoy.go`
|
||||
|
||||
### Built-in: Postfix / Dovecot
|
||||
|
||||
The Postfix/Dovecot connector is a dual-mode mail server TLS connector. It writes certificate, key, and chain files to configured paths and reloads the mail service. The `mode` field selects between Postfix MTA and Dovecot IMAP/POP3, which determines default file paths and reload commands.
|
||||
|
||||
This connector pairs with certctl's S/MIME certificate support (email protection EKU, email SAN routing) for a complete email infrastructure story — TLS for transport encryption, S/MIME for end-to-end message signing and encryption.
|
||||
|
||||
**Postfix configuration:**
|
||||
```json
|
||||
{
|
||||
"mode": "postfix",
|
||||
"cert_path": "/etc/postfix/certs/cert.pem",
|
||||
"key_path": "/etc/postfix/certs/key.pem",
|
||||
"chain_path": "/etc/postfix/certs/chain.pem",
|
||||
"reload_command": "postfix reload",
|
||||
"validate_command": "postfix check"
|
||||
}
|
||||
```
|
||||
|
||||
**Dovecot configuration:**
|
||||
```json
|
||||
{
|
||||
"mode": "dovecot",
|
||||
"cert_path": "/etc/dovecot/certs/cert.pem",
|
||||
"key_path": "/etc/dovecot/certs/key.pem",
|
||||
"chain_path": "/etc/dovecot/certs/chain.pem",
|
||||
"reload_command": "doveadm reload",
|
||||
"validate_command": "doveconf -n"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default (Postfix) | Default (Dovecot) | Description |
|
||||
|-------|------|-------------------|-------------------|-------------|
|
||||
| `mode` | string | `postfix` | `dovecot` | Service mode — determines defaults |
|
||||
| `cert_path` | string | `/etc/postfix/certs/cert.pem` | `/etc/dovecot/certs/cert.pem` | Path for certificate file |
|
||||
| `key_path` | string | `/etc/postfix/certs/key.pem` | `/etc/dovecot/certs/key.pem` | Path for private key (0600 permissions) |
|
||||
| `chain_path` | string | (empty) | (empty) | If set, chain written separately; otherwise appended to cert |
|
||||
| `reload_command` | string | `postfix reload` | `doveadm reload` | Command to reload the mail service |
|
||||
| `validate_command` | string | `postfix check` | `doveconf -n` | Optional config validation before reload |
|
||||
|
||||
All commands are validated against shell injection via `validation.ValidateShellCommand()`. File permissions: cert/chain 0644, key 0600.
|
||||
|
||||
Location: `internal/connector/target/postfix/postfix.go`
|
||||
|
||||
### F5 BIG-IP (Implemented)
|
||||
|
||||
The F5 BIG-IP target connector deploys certificates to F5 load balancers via the iControl REST API. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated certctl agent in the same network zone polls for F5 deployment jobs and executes iControl REST calls on behalf of the control plane. Minimum supported BIG-IP version: 12.0+.
|
||||
|
||||
The deployment flow uses F5's transaction API for atomic updates: authenticate via token auth, upload cert/key/chain PEM files, install as crypto objects, update the SSL client profile within a transaction, and commit. If the transaction fails, F5 rolls back automatically and the connector cleans up uploaded crypto objects. Updating an SSL profile automatically takes effect on all bound virtual servers — no separate virtual server binding step is needed.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `host` | string | *(required)* | F5 BIG-IP management hostname or IP |
|
||||
| `port` | int | `443` | iControl REST API port |
|
||||
| `username` | string | *(required)* | Administrative username |
|
||||
| `password` | string | *(required)* | Administrative password |
|
||||
| `partition` | string | `Common` | F5 partition for crypto objects and profiles |
|
||||
| `ssl_profile` | string | *(required)* | SSL client profile name to update |
|
||||
| `insecure` | bool | `true` | Skip TLS verification for management interface (self-signed certs common) |
|
||||
| `timeout` | int | `30` | HTTP timeout in seconds |
|
||||
|
||||
Configuration (defined, not yet functional):
|
||||
```json
|
||||
{
|
||||
"host": "f5.internal.example.com",
|
||||
"port": 443,
|
||||
"username": "admin",
|
||||
"password": "...",
|
||||
"partition": "Common",
|
||||
"ssl_profile": "/Common/clientssl_api"
|
||||
"ssl_profile": "clientssl_api",
|
||||
"insecure": true,
|
||||
"timeout": 30
|
||||
}
|
||||
```
|
||||
|
||||
Note: F5 credentials are stored on the proxy agent, not on the control plane server. This limits the credential blast radius to the proxy agent's network zone.
|
||||
F5 credentials are stored on the proxy agent, not on the control plane server. This limits the credential blast radius to the proxy agent's network zone. Config fields are validated against regex patterns to prevent injection.
|
||||
|
||||
Location: `internal/connector/target/f5/f5.go`
|
||||
|
||||
### IIS (Interface Only, Dual-Mode)
|
||||
### IIS (Implemented, Dual-Mode)
|
||||
|
||||
The IIS target connector supports two planned deployment modes:
|
||||
The IIS target connector supports two deployment modes — agent-local (recommended) and proxy agent WinRM for agentless targets.
|
||||
|
||||
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
||||
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. The agent handles PEM-to-PFX conversion via `go-pkcs12`, computes SHA-1 thumbprint from the certificate, and executes parameterized PowerShell scripts for injection-safe binding management. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
||||
|
||||
**Proxy agent WinRM (for agentless targets):** For Windows servers where you don't want to install an agent, a nearby Windows agent acts as a proxy and reaches the IIS box via WinRM. The proxy agent picks up the deployment job, transfers the PFX bundle over WinRM, and runs the PowerShell commands remotely. WinRM credentials are stored on the proxy agent, not on the control plane.
|
||||
**Proxy agent WinRM (for agentless targets):** For Windows servers where you don't want to install an agent, a Linux or Windows proxy agent in the same network zone connects via WinRM (Windows Remote Management) and executes PowerShell commands remotely. The PFX bundle is base64-encoded, transferred inline in the WinRM session, decoded to a temp file on the remote host, imported, and the temp file is cleaned up in a `try/finally` block. WinRM credentials are configured on the target, not on the control plane. Uses the `masterzen/winrm` Go library with support for Basic, NTLM, and Kerberos authentication.
|
||||
|
||||
Configuration (defined, not yet functional):
|
||||
**Agent-local configuration:**
|
||||
```json
|
||||
{
|
||||
"mode": "local",
|
||||
"hostname": "iis-server.example.com",
|
||||
"site_name": "Default Web Site",
|
||||
"cert_store": "WebHosting",
|
||||
"winrm_host": "",
|
||||
"winrm_username": "",
|
||||
"winrm_password": "",
|
||||
"winrm_use_https": true
|
||||
"port": 443,
|
||||
"sni": true,
|
||||
"ip_address": "*",
|
||||
"binding_info": "www.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
When `mode` is `"local"`, the `winrm_*` fields are ignored. When `mode` is `"proxy"`, the agent connects to the remote IIS server via WinRM using the provided credentials.
|
||||
**WinRM proxy configuration:**
|
||||
```json
|
||||
{
|
||||
"hostname": "iis-server.example.com",
|
||||
"site_name": "Default Web Site",
|
||||
"cert_store": "WebHosting",
|
||||
"port": 443,
|
||||
"sni": true,
|
||||
"ip_address": "*",
|
||||
"mode": "winrm",
|
||||
"winrm": {
|
||||
"winrm_host": "iis-server.example.com",
|
||||
"winrm_port": 5985,
|
||||
"winrm_username": "Administrator",
|
||||
"winrm_password": "...",
|
||||
"winrm_https": false,
|
||||
"winrm_insecure": false,
|
||||
"winrm_timeout": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Location: `internal/connector/target/iis/iis.go`
|
||||
**Configuration Fields:**
|
||||
- `hostname` (string, required): IIS server hostname or FQDN
|
||||
- `site_name` (string, required): IIS website name (e.g., "Default Web Site")
|
||||
- `cert_store` (string, required): Certificate store for import (e.g., "WebHosting", "My")
|
||||
- `port` (number, default 443): HTTPS binding port
|
||||
- `sni` (boolean, default false): Enable Server Name Indication (SNI)
|
||||
- `ip_address` (string, default "*"): Specific IP to bind to, or "*" for all IPs
|
||||
- `binding_info` (string, optional): Host header for SNI bindings
|
||||
- `mode` (string, default "local"): Deployment mode — `local` (agent-local PowerShell) or `winrm` (remote via WinRM)
|
||||
|
||||
**WinRM fields (required when `mode` is `winrm`):**
|
||||
- `winrm.winrm_host` (string, required): Remote Windows server hostname or IP
|
||||
- `winrm.winrm_port` (number, default 5985 HTTP / 5986 HTTPS): WinRM listener port
|
||||
- `winrm.winrm_username` (string, required): Windows account with admin privileges
|
||||
- `winrm.winrm_password` (string, required): Account password
|
||||
- `winrm.winrm_https` (boolean, default false): Use HTTPS transport
|
||||
- `winrm.winrm_insecure` (boolean, default false): Skip TLS certificate verification
|
||||
- `winrm.winrm_timeout` (number, default 60): Operation timeout in seconds
|
||||
|
||||
**Security Model:**
|
||||
- PFX files are transient — generated with random passwords, deleted after import
|
||||
- In WinRM mode, PFX data is base64-encoded and transferred inline (no SMB/file share needed), with remote temp file cleanup in `try/finally`
|
||||
- PowerShell commands use parameterized values — IIS names and cert stores are regex-validated before script execution
|
||||
- Field names are validated against `^[a-zA-Z0-9 _\-\.]+$` to prevent PowerShell injection
|
||||
- Certificate thumbprints computed via SHA-1 for IIS binding lookups
|
||||
|
||||
Location: `internal/connector/target/iis/iis.go`, `internal/connector/target/iis/winrm.go`
|
||||
|
||||
### SSH (Agentless Deployment)
|
||||
|
||||
The SSH target connector enables agentless certificate deployment to any Linux/Unix server via SSH/SFTP. Instead of installing the certctl agent binary on every target, a single "proxy agent" in the same network zone deploys certificates to remote servers over SSH. This is ideal for environments where installing agents on every server is impractical.
|
||||
|
||||
**Key authentication (recommended):**
|
||||
```json
|
||||
{
|
||||
"host": "web-server.internal",
|
||||
"port": 22,
|
||||
"user": "certctl",
|
||||
"auth_method": "key",
|
||||
"private_key_path": "/home/certctl/.ssh/id_ed25519",
|
||||
"cert_path": "/etc/ssl/certs/cert.pem",
|
||||
"key_path": "/etc/ssl/private/key.pem",
|
||||
"chain_path": "/etc/ssl/certs/chain.pem",
|
||||
"reload_command": "systemctl reload nginx",
|
||||
"timeout": 30
|
||||
}
|
||||
```
|
||||
|
||||
**Password authentication:**
|
||||
```json
|
||||
{
|
||||
"host": "legacy-server.internal",
|
||||
"user": "deploy",
|
||||
"auth_method": "password",
|
||||
"password": "s3cret",
|
||||
"cert_path": "/etc/ssl/cert.pem",
|
||||
"key_path": "/etc/ssl/key.pem",
|
||||
"reload_command": "systemctl reload apache2"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `host` | string | *(required)* | SSH hostname or IP address |
|
||||
| `port` | number | 22 | SSH port |
|
||||
| `user` | string | *(required)* | SSH username |
|
||||
| `auth_method` | string | `"key"` | `"key"` or `"password"` |
|
||||
| `private_key_path` | string | | Path to SSH private key file (key auth) |
|
||||
| `private_key` | string | | Inline SSH private key PEM (alternative to path) |
|
||||
| `password` | string | | SSH password (password auth) |
|
||||
| `passphrase` | string | | Passphrase for encrypted private keys |
|
||||
| `cert_path` | string | *(required)* | Remote path for certificate file |
|
||||
| `key_path` | string | *(required)* | Remote path for private key file |
|
||||
| `chain_path` | string | | Remote path for chain file (if empty, chain appended to cert) |
|
||||
| `cert_mode` | string | `"0644"` | File permissions for cert (octal) |
|
||||
| `key_mode` | string | `"0600"` | File permissions for private key (octal) |
|
||||
| `reload_command` | string | | Command to execute after deployment |
|
||||
| `timeout` | number | 30 | SSH connection timeout in seconds |
|
||||
|
||||
**Security:**
|
||||
- Key-based authentication is recommended over password authentication
|
||||
- Reload commands are validated against shell injection (same validation as Postfix/Dovecot connectors)
|
||||
- Host field is regex-validated to prevent shell metacharacters
|
||||
- Private keys are written with 0600 permissions by default
|
||||
- Host key verification is intentionally skipped (same rationale as network scanner and F5 connector — deploying to known, operator-configured infrastructure)
|
||||
- Encrypted private keys supported via passphrase
|
||||
|
||||
Location: `internal/connector/target/ssh/ssh.go`
|
||||
|
||||
### Windows Certificate Store
|
||||
|
||||
The Windows Certificate Store connector imports certificates into the Windows cert store via PowerShell, without managing IIS site bindings. Use this for non-IIS Windows services that read certificates from the cert store (Exchange, RDP, SQL Server, ADFS, etc.). Same injectable `PowerShellExecutor` pattern as the IIS connector, with optional WinRM proxy mode.
|
||||
|
||||
```json
|
||||
{
|
||||
"store_name": "My",
|
||||
"store_location": "LocalMachine",
|
||||
"friendly_name": "Production API Cert",
|
||||
"remove_expired": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `store_name` | string | `"My"` | Windows cert store name (My, Root, WebHosting, etc.) |
|
||||
| `store_location` | string | `"LocalMachine"` | `"LocalMachine"` or `"CurrentUser"` |
|
||||
| `friendly_name` | string | | Optional friendly name for the imported certificate |
|
||||
| `remove_expired` | boolean | `false` | Remove expired certs with same CN after import |
|
||||
| `mode` | string | `"local"` | `"local"` (agent-local) or `"winrm"` (remote) |
|
||||
| `winrm_host` | string | | WinRM hostname (required for winrm mode) |
|
||||
| `winrm_port` | number | 5985 | WinRM port (5985 HTTP, 5986 HTTPS) |
|
||||
| `winrm_username` | string | | WinRM username (required for winrm mode) |
|
||||
| `winrm_password` | string | | WinRM password (required for winrm mode) |
|
||||
| `winrm_https` | boolean | `false` | Use HTTPS for WinRM |
|
||||
| `winrm_insecure` | boolean | `false` | Skip TLS verification for WinRM |
|
||||
|
||||
Location: `internal/connector/target/wincertstore/wincertstore.go`
|
||||
|
||||
### Java Keystore (JKS / PKCS#12)
|
||||
|
||||
The Java Keystore connector deploys certificates to JKS or PKCS#12 keystores via the `keytool` CLI. This enables TLS cert deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service. Flow: PEM to temp PKCS#12, then `keytool -importkeystore` into the target keystore.
|
||||
|
||||
```json
|
||||
{
|
||||
"keystore_path": "/opt/tomcat/conf/keystore.p12",
|
||||
"keystore_password": "changeit",
|
||||
"keystore_type": "PKCS12",
|
||||
"alias": "server",
|
||||
"reload_command": "systemctl restart tomcat"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `keystore_path` | string | *(required)* | Absolute path to the keystore file |
|
||||
| `keystore_password` | string | *(required)* | Keystore password |
|
||||
| `keystore_type` | string | `"PKCS12"` | `"PKCS12"` or `"JKS"` |
|
||||
| `alias` | string | `"server"` | Key entry alias in the keystore |
|
||||
| `reload_command` | string | | Optional command to run after keystore update |
|
||||
| `create_keystore` | boolean | `true` | Create keystore if it doesn't exist |
|
||||
| `keytool_path` | string | `"keytool"` | Override keytool binary path |
|
||||
|
||||
**Security:**
|
||||
- Reload commands validated against shell injection via `validation.ValidateShellCommand()`
|
||||
- Alias validated against injection (alphanumeric, hyphens, underscores only)
|
||||
- Path traversal prevention on keystore path
|
||||
- Transient PKCS#12 temp file cleaned up after import (even on error)
|
||||
|
||||
Location: `internal/connector/target/javakeystore/javakeystore.go`
|
||||
|
||||
### Kubernetes Secrets
|
||||
|
||||
The Kubernetes Secrets connector deploys certificates as `kubernetes.io/tls` Secrets, compatible with Ingress controllers (nginx-ingress, Traefik, HAProxy), service meshes (Istio, Linkerd), and any Kubernetes workload that reads TLS Secrets.
|
||||
|
||||
```json
|
||||
{
|
||||
"namespace": "production",
|
||||
"secret_name": "api-tls",
|
||||
"labels": {"app": "api-gateway"},
|
||||
"kubeconfig_path": "/home/agent/.kube/config"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `namespace` | string | *(required)* | Kubernetes namespace (DNS-1123, max 63 chars) |
|
||||
| `secret_name` | string | *(required)* | Secret name (DNS subdomain, max 253 chars) |
|
||||
| `labels` | object | | Additional labels to apply to the Secret |
|
||||
| `kubeconfig_path` | string | | Path to kubeconfig for out-of-cluster agents |
|
||||
|
||||
**Deployment modes:**
|
||||
- **In-cluster (default):** Agent runs as a Pod with a ServiceAccount. Authentication via auto-mounted token. Requires RBAC (`secrets.get`, `secrets.create`, `secrets.update`, `secrets.list`) — see Helm chart.
|
||||
- **Out-of-cluster:** Agent runs outside the cluster with `kubeconfig_path` pointing to a kubeconfig file. Useful for proxy agent pattern.
|
||||
|
||||
**Secret format:** Standard `kubernetes.io/tls` with `tls.crt` (cert + chain PEM) and `tls.key` (private key PEM). Managed labels (`app.kubernetes.io/managed-by: certctl`) and annotations (`certctl.io/deployed-at`, `certctl.io/certificate-id`) are applied automatically.
|
||||
|
||||
**Validation:** After deployment, the connector reads the Secret back and compares the certificate serial number to verify successful deployment.
|
||||
|
||||
Location: `internal/connector/target/k8ssecret/k8ssecret.go`
|
||||
|
||||
## Notifier Connector
|
||||
|
||||
@@ -658,7 +1126,7 @@ The digest HTML template includes:
|
||||
- Expiring certificates table (color-coded by urgency: 7d, 14d, 30d)
|
||||
- Auto-refresh and responsive email layout
|
||||
|
||||
**Scheduler Integration:** The 7th scheduler loop runs on configurable interval (default 24 hours). It does NOT run on startup — waits for first scheduled tick. Operation timeout is 5 minutes. Each loop execution is guarded by `sync/atomic.Bool` idempotency.
|
||||
**Scheduler Integration:** The opt-in digest scheduler loop runs on configurable interval (default 24 hours). It does NOT run on startup — waits for first scheduled tick. Operation timeout is 5 minutes. Each loop execution is guarded by `sync/atomic.Bool` idempotency. See `docs/architecture.md` for the full scheduler topology (12 loops, 8 always-on + 4 opt-in).
|
||||
|
||||
Configuration:
|
||||
|
||||
@@ -673,13 +1141,30 @@ API Endpoints:
|
||||
- **`GET /api/v1/digest/preview`** — Render digest HTML for preview (no email sent)
|
||||
- **`POST /api/v1/digest/send`** — Trigger digest send immediately (outside of schedule)
|
||||
|
||||
> **Note (HTTPS-only as of v2.2):** The `curl` examples in this section
|
||||
> and below all target the HTTPS-only control plane. Extract the
|
||||
> docker-compose self-signed bootstrap CA bundle once and reuse it on
|
||||
> every call:
|
||||
>
|
||||
> ```bash
|
||||
> export CA=/tmp/certctl-ca.crt
|
||||
> docker compose -f deploy/docker-compose.yml exec -T certctl-server \
|
||||
> cat /etc/certctl/tls/ca.crt > "$CA"
|
||||
> ```
|
||||
>
|
||||
> Then pass `--cacert "$CA"` (or `-k` for one-off smoke tests, never in
|
||||
> production). The same pattern is documented in
|
||||
> [`quickstart.md`](quickstart.md). Pre-U-2 these examples used `http://`
|
||||
> and silently failed against the HTTPS listener; post-U-2 they speak
|
||||
> HTTPS with the operator-managed CA bundle.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
# Preview digest
|
||||
curl http://localhost:8443/api/v1/digest/preview | jq '.html'
|
||||
curl --cacert "$CA" https://localhost:8443/api/v1/digest/preview | jq '.html'
|
||||
|
||||
# Send digest immediately
|
||||
curl -X POST http://localhost:8443/api/v1/digest/send
|
||||
curl --cacert "$CA" -X POST https://localhost:8443/api/v1/digest/send
|
||||
```
|
||||
|
||||
Each notifier is enabled by its configuration env var:
|
||||
@@ -826,24 +1311,24 @@ The agent scans these directories on startup and every 6 hours, looking for cert
|
||||
|
||||
```bash
|
||||
# List discovered certificates (filter by agent, status)
|
||||
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-01&status=new" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-01&status=new" | jq .
|
||||
|
||||
# Get discovery detail
|
||||
curl -s http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID | jq .
|
||||
|
||||
# Claim a discovered cert (link to managed certificate)
|
||||
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
||||
|
||||
# Dismiss a discovery
|
||||
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/dismiss | jq .
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/dismiss | jq .
|
||||
|
||||
# View discovery scan history
|
||||
curl -s http://localhost:8443/api/v1/discovery-scans | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovery-scans | jq .
|
||||
|
||||
# Summary counts (new, claimed, dismissed)
|
||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovery-summary | jq .
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
@@ -872,7 +1357,7 @@ Network scan targets can be managed from the **Network Scans** dashboard page (c
|
||||
|
||||
```bash
|
||||
# Create a scan target for your internal network (or use the dashboard's "+ New Target" button)
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Production Web Servers",
|
||||
@@ -897,31 +1382,31 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
|
||||
```bash
|
||||
# List all scan targets
|
||||
curl -s http://localhost:8443/api/v1/network-scan-targets | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/network-scan-targets | jq .
|
||||
|
||||
# Create a scan target
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "DMZ", "cidrs": ["172.16.0.0/24"], "ports": [443]}' | jq .
|
||||
|
||||
# Get a specific target (includes last_scan_at, last_scan_certs_found)
|
||||
curl -s http://localhost:8443/api/v1/network-scan-targets/nst-dmz | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/network-scan-targets/nst-dmz | jq .
|
||||
|
||||
# Trigger an immediate scan (doesn't wait for scheduler)
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-dmz/scan | jq .
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets/nst-dmz/scan | jq .
|
||||
|
||||
# Update scan configuration
|
||||
curl -s -X PUT http://localhost:8443/api/v1/network-scan-targets/nst-dmz \
|
||||
curl --cacert "$CA" -s -X PUT https://localhost:8443/api/v1/network-scan-targets/nst-dmz \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ports": [443, 8443, 9443], "timeout_ms": 3000}' | jq .
|
||||
|
||||
# Delete a scan target
|
||||
curl -s -X DELETE http://localhost:8443/api/v1/network-scan-targets/nst-dmz
|
||||
curl --cacert "$CA" -s -X DELETE https://localhost:8443/api/v1/network-scan-targets/nst-dmz
|
||||
```
|
||||
|
||||
### Scheduler Integration
|
||||
|
||||
When `CERTCTL_NETWORK_SCAN_ENABLED=true`, the server runs a 6th scheduler loop (alongside renewal, jobs, health, notifications, and short-lived expiry). It scans all enabled targets at the configured interval (default 6h). Each target tracks `last_scan_at`, `last_scan_duration_ms`, and `last_scan_certs_found` for monitoring scan health.
|
||||
When `CERTCTL_NETWORK_SCAN_ENABLED=true`, the server runs the opt-in network scanner scheduler loop alongside the always-on loops (renewal, jobs, job retry, job timeout, agent health, notifications, notification retry, short-lived expiry). It scans all enabled targets at the configured interval (default 6h). Each target tracks `last_scan_at`, `last_scan_duration_ms`, and `last_scan_certs_found` for monitoring scan health. See `docs/architecture.md` for the full 12-loop scheduler topology.
|
||||
|
||||
### Use Cases
|
||||
|
||||
@@ -931,6 +1416,63 @@ When `CERTCTL_NETWORK_SCAN_ENABLED=true`, the server runs a 6th scheduler loop (
|
||||
- **Migration assessment** — Scan a network range before onboarding to certctl management
|
||||
- **Expiration monitoring** — Discover soon-to-expire certs on network endpoints before they cause outages
|
||||
|
||||
## Cloud Secret Manager Discovery
|
||||
|
||||
certctl extends the existing filesystem and network discovery pipeline to cloud secret managers. Certificates stored in cloud vaults are automatically discovered, inventoried, and available for triage in the Discovery page.
|
||||
|
||||
Each cloud source runs as a pluggable `DiscoverySource` with its own sentinel agent ID. Discovered certificates flow through the same `ProcessDiscoveryReport` pipeline used by filesystem and network discovery — dedup by fingerprint, audit trail, status tracking.
|
||||
|
||||
### AWS Secrets Manager
|
||||
|
||||
Discovers certificates stored as secrets in AWS Secrets Manager. Filters by tag (`type=certificate` by default) and optional name prefix.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `CERTCTL_CLOUD_DISCOVERY_ENABLED` | Enable cloud discovery scheduler | `false` |
|
||||
| `CERTCTL_AWS_SM_DISCOVERY_ENABLED` | Enable AWS SM source | `false` |
|
||||
| `CERTCTL_AWS_SM_REGION` | AWS region (e.g., `us-east-1`) | — |
|
||||
| `CERTCTL_AWS_SM_TAG_FILTER` | Tag key=value filter | `type=certificate` |
|
||||
| `CERTCTL_AWS_SM_NAME_PREFIX` | Secret name prefix filter | — |
|
||||
|
||||
Source path format: `aws-sm://{region}/{secret-name}`. Sentinel agent: `cloud-aws-sm`.
|
||||
|
||||
### Azure Key Vault
|
||||
|
||||
Discovers certificates from Azure Key Vault using OAuth2 client credentials authentication. No Azure SDK dependency — uses stdlib HTTP with Azure AD token exchange.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `CERTCTL_AZURE_KV_DISCOVERY_ENABLED` | Enable Azure KV source | `false` |
|
||||
| `CERTCTL_AZURE_KV_VAULT_URL` | Vault URL (e.g., `https://myvault.vault.azure.net`) | — |
|
||||
| `CERTCTL_AZURE_KV_TENANT_ID` | Azure AD tenant ID | — |
|
||||
| `CERTCTL_AZURE_KV_CLIENT_ID` | Azure AD application (client) ID | — |
|
||||
| `CERTCTL_AZURE_KV_CLIENT_SECRET` | Azure AD application secret | — |
|
||||
|
||||
Source path format: `azure-kv://{cert-name}/{version}`. Sentinel agent: `cloud-azure-kv`.
|
||||
|
||||
### GCP Secret Manager
|
||||
|
||||
Discovers certificates stored in GCP Secret Manager. Filters by label (`type=certificate`). Uses JWT-based OAuth2 service account auth — no Google SDK dependency.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `CERTCTL_GCP_SM_DISCOVERY_ENABLED` | Enable GCP SM source | `false` |
|
||||
| `CERTCTL_GCP_SM_PROJECT` | GCP project ID | — |
|
||||
| `CERTCTL_GCP_SM_CREDENTIALS` | Path to service account JSON file | — |
|
||||
|
||||
Source path format: `gcp-sm://{project}/{secret-name}`. Sentinel agent: `cloud-gcp-sm`.
|
||||
|
||||
### Cloud Discovery Scheduler
|
||||
|
||||
All enabled cloud sources run on a shared opt-in cloud discovery scheduler loop (see `docs/architecture.md` for the full 12-loop scheduler topology). The interval is configurable:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `CERTCTL_CLOUD_DISCOVERY_ENABLED` | Master switch | `false` |
|
||||
| `CERTCTL_CLOUD_DISCOVERY_INTERVAL` | Scan interval | `6h` |
|
||||
|
||||
The loop runs immediately on startup and then on each tick. Each source runs sequentially within the loop. Errors from one source do not prevent other sources from running.
|
||||
|
||||
## What's Next
|
||||
|
||||
- [Architecture Guide](architecture.md) — Understanding the full system design
|
||||
|
||||
+29
-21
@@ -50,14 +50,17 @@ docker compose -f deploy/docker-compose.yml up -d --build
|
||||
docker compose -f deploy/docker-compose.yml ps
|
||||
```
|
||||
|
||||
Open **http://localhost:8443** in your browser alongside your terminal. You'll watch changes appear in the dashboard as you make API calls.
|
||||
Open **https://localhost:8443** in your browser alongside your terminal. The default compose stack ships a self-signed cert; your browser will show a warning the first time — click through (or trust `deploy/test/certs/ca.crt` in your OS keychain). You'll watch changes appear in the dashboard as you make API calls.
|
||||
|
||||
Set up a base variable for convenience:
|
||||
Set up base variables for convenience:
|
||||
|
||||
```bash
|
||||
API="http://localhost:8443"
|
||||
API="https://localhost:8443"
|
||||
CA="$PWD/deploy/test/certs/ca.crt" # pin the self-signed CA for curl
|
||||
```
|
||||
|
||||
Every `curl` in this guide uses `--cacert "$CA"` so the TLS handshake verifies against the compose-stack CA instead of the system trust store.
|
||||
|
||||
## How the pieces fit together
|
||||
|
||||
Before we start, here's the high-level flow of what we're about to do:
|
||||
@@ -307,8 +310,8 @@ flowchart TD
|
||||
A --> F["ACME\n(Let's Encrypt)"]
|
||||
A --> G["step-ca\n(implemented)"]
|
||||
A --> H["OpenSSL / Custom CA\n(script-based)"]
|
||||
A --> J["DigiCert API\n(planned)"]
|
||||
A --> K["Vault PKI\n(planned)"]
|
||||
A --> J["DigiCert API\n(implemented)"]
|
||||
A --> K["Vault PKI\n(implemented)"]
|
||||
A --> L["Entrust / GlobalSign\n(planned)"]
|
||||
A --> M["Google CAS / EJBCA\n(planned)"]
|
||||
```
|
||||
@@ -724,22 +727,24 @@ curl -s -X POST $API/api/v1/certificates/mc-demo-payments/revoke \
|
||||
6. Creates an audit trail entry
|
||||
7. Sends revocation notifications via configured channels
|
||||
|
||||
Check the CRL (Certificate Revocation List):
|
||||
Check the CRL (Certificate Revocation List) — served unauthenticated under the RFC 8615 well-known namespace so relying parties without a certctl API key can still verify revocation (RFC 5280 §5):
|
||||
|
||||
```bash
|
||||
# JSON-formatted CRL
|
||||
curl -s $API/api/v1/crl | jq .
|
||||
|
||||
# DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection)
|
||||
curl -s $API/api/v1/crl/iss-local -o /tmp/crl.der
|
||||
# DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection).
|
||||
# Note: no -H "Authorization: Bearer ..." — the endpoint is deliberately
|
||||
# unauthenticated. Content-Type is application/pkix-crl.
|
||||
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
|
||||
openssl crl -inform DER -in /tmp/crl.der -text -noout
|
||||
```
|
||||
|
||||
Check OCSP status:
|
||||
Check OCSP status (RFC 6960, also unauthenticated, `application/ocsp-response`):
|
||||
|
||||
```bash
|
||||
# Replace SERIAL with the actual serial number from the certificate version
|
||||
curl -s $API/api/v1/ocsp/iss-local/SERIAL | jq .
|
||||
# Replace SERIAL with the actual serial number from the certificate version.
|
||||
# The embedded OCSP responder returns a signed DER response — parse it with
|
||||
# `openssl ocsp -respin` or similar tooling.
|
||||
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/ocsp/iss-local/SERIAL -o /tmp/ocsp.der
|
||||
openssl ocsp -respin /tmp/ocsp.der -noverify -resp_text | head -40
|
||||
```
|
||||
|
||||
**Why RFC 5280 reason codes:** The reason code isn't just metadata — it tells clients *why* the certificate was revoked. A `keyCompromise` revocation means the private key was exposed and the certificate should be distrusted immediately. A `superseded` revocation means a newer certificate replaced it — less urgent. CRLs and OCSP responses include the reason code so client software can make informed trust decisions.
|
||||
@@ -944,7 +949,8 @@ certctl includes a standalone CLI tool for command-line users:
|
||||
cd cmd/cli && go build -o certctl-cli .
|
||||
|
||||
# Export credentials
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_SERVER_URL="https://localhost:8443"
|
||||
export CERTCTL_SERVER_CA_BUNDLE_PATH="$PWD/deploy/test/certs/ca.crt"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
# List certificates (JSON or table format)
|
||||
@@ -981,14 +987,15 @@ export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
## Part 15: MCP Server for AI Integration (M18a)
|
||||
|
||||
certctl exposes 78 MCP tools covering the REST API via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants:
|
||||
certctl exposes the full REST API via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants:
|
||||
|
||||
```bash
|
||||
# Build the MCP server
|
||||
cd cmd/mcp-server && go build -o mcp-server .
|
||||
|
||||
# Export credentials
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_SERVER_URL="https://localhost:8443"
|
||||
export CERTCTL_SERVER_CA_BUNDLE_PATH="$PWD/deploy/test/certs/ca.crt"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
# Start the MCP server (listens on stdin/stdout)
|
||||
@@ -1046,7 +1053,7 @@ docker compose -f deploy/docker-compose.yml run -e CERTCTL_DISCOVERY_DIRS=/tmp/c
|
||||
Or with the CLI flag:
|
||||
|
||||
```bash
|
||||
certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/certs --server http://localhost:8443 --api-key test-key-123
|
||||
certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/certs --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-123
|
||||
```
|
||||
|
||||
### Network Discovery (Server-Side)
|
||||
@@ -1153,7 +1160,7 @@ flowchart TB
|
||||
API["REST API\nGo net/http"]
|
||||
SVC["Service Layer\nBusiness Logic"]
|
||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
||||
SCHED["Scheduler\n7 background loops"]
|
||||
SCHED["Scheduler\n12 background loops\n(8 always-on + 4 opt-in)"]
|
||||
CONN["Connector Registry\nIssuer + Target + Notifier"]
|
||||
end
|
||||
|
||||
@@ -1189,7 +1196,8 @@ Here's a single script that runs the entire demo end-to-end. Save it as `demo.sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
API="http://localhost:8443"
|
||||
API="https://localhost:8443"
|
||||
CA="$PWD/deploy/test/certs/ca.crt" # pin the self-signed CA for curl
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@@ -1297,7 +1305,7 @@ echo " 5. Revoked the certificate with RFC 5280 reason codes"
|
||||
echo " 6. Checked dashboard stats and metrics"
|
||||
echo " 7. All actions recorded in the audit trail"
|
||||
echo ""
|
||||
echo -e "Open ${GREEN}http://localhost:8443${NC} to see everything in the dashboard."
|
||||
echo -e "Open ${GREEN}https://localhost:8443${NC} to see everything in the dashboard."
|
||||
echo "Look for certificate: $CERT_ID"
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Deployment Examples
|
||||
|
||||
Five turnkey docker-compose scenarios, each runnable in under 5 minutes. Pick the one closest to your setup.
|
||||
|
||||
## Which Example Should I Use?
|
||||
|
||||
| I need to... | Example | Issuer | Target |
|
||||
|--------------|---------|--------|--------|
|
||||
| Get Let's Encrypt certs for NGINX on a public server | [ACME + NGINX](#acme--nginx) | ACME (HTTP-01) | NGINX |
|
||||
| Issue wildcard certs without opening port 80 | [Wildcard DNS-01](#wildcard-dns-01) | ACME (DNS-01) | Any |
|
||||
| Run an internal CA for services behind a firewall | [Private CA + Traefik](#private-ca--traefik) | Local CA | Traefik |
|
||||
| Use Smallstep step-ca as my PKI backend | [step-ca + HAProxy](#step-ca--haproxy) | step-ca | HAProxy |
|
||||
| Manage both public and internal certs from one dashboard | [Multi-Issuer](#multi-issuer) | ACME + Local CA | Mixed |
|
||||
|
||||
**Already using another tool?** See the migration sections below each example for Certbot, acme.sh, and cert-manager users.
|
||||
|
||||
---
|
||||
|
||||
## ACME + NGINX
|
||||
|
||||
**Scenario:** You have one or more public-facing domains, NGINX as the reverse proxy, and want automated Let's Encrypt certificates with HTTP-01 challenges.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent + NGINX, all on one Docker network. The agent generates keys locally (ECDSA P-256), submits CSRs to the server, receives signed certs from Let's Encrypt, and deploys them to NGINX with automatic reload.
|
||||
|
||||
**Prerequisites:** A domain pointing to your server, ports 80 and 443 open, Docker Compose v20.10+.
|
||||
|
||||
```bash
|
||||
cd examples/acme-nginx
|
||||
cp .env.example .env # Edit with your domain and email
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including how HTTP-01 challenges work, adding multiple domains, switching to staging for testing, and a production checklist — is in the [example README](../examples/acme-nginx/acme-nginx.md).
|
||||
|
||||
**Migrating from Certbot?** certctl discovers your existing `/etc/letsencrypt/live/` certificates automatically. You keep your ACME account, disable the Certbot cron, and certctl takes over renewal with centralized visibility and deployment verification. The step-by-step process is in [Migrating from Certbot](migrate-from-certbot.md).
|
||||
|
||||
---
|
||||
|
||||
## Wildcard DNS-01
|
||||
|
||||
**Scenario:** You need wildcard certificates (`*.example.com`) or your servers aren't reachable from the internet (no port 80). DNS-01 validates ownership by creating a TXT record at your DNS provider.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent. Includes a Cloudflare DNS hook script as a working reference — swap in your own DNS provider (Route53, Azure DNS, Google Cloud DNS, or any provider with an API).
|
||||
|
||||
**Prerequisites:** A domain, API credentials for your DNS provider, Docker Compose.
|
||||
|
||||
```bash
|
||||
cd examples/acme-wildcard-dns01
|
||||
cp .env.example .env # Edit with domain, email, DNS provider credentials
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including DNS-PERSIST-01 (set a TXT record once, never touch DNS again on renewals), adapting scripts for other providers, and propagation troubleshooting — is in the [example README](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md).
|
||||
|
||||
**Migrating from acme.sh?** Your existing `dns_*` hook scripts are compatible with certctl's DNS-01 — they use the same pattern (shell scripts creating TXT records). The migration guide covers script adaptation, discovery of existing acme.sh certificates, and phasing out the acme.sh cron. See [Migrating from acme.sh](migrate-from-acmesh.md).
|
||||
|
||||
---
|
||||
|
||||
## Private CA + Traefik
|
||||
|
||||
**Scenario:** Internal services that don't need public CA validation. You run your own certificate authority — either a self-signed root for development, or a subordinate CA chained to your enterprise root (e.g., Active Directory Certificate Services).
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent + Traefik. The Local CA issuer signs certificates directly. Traefik watches a cert directory and auto-reloads when new files appear.
|
||||
|
||||
**Prerequisites:** Docker Compose. For sub-CA mode, you'll need a CA certificate and key signed by your enterprise root.
|
||||
|
||||
```bash
|
||||
cd examples/private-ca-traefik
|
||||
docker compose up -d # Self-signed mode (no .env needed for demo)
|
||||
```
|
||||
|
||||
The full walkthrough — including sub-CA setup with `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH`, creating certificates via the API, monitoring deployments, and production hardening — is in the [example README](../examples/private-ca-traefik/private-ca-traefik.md).
|
||||
|
||||
---
|
||||
|
||||
## step-ca + HAProxy
|
||||
|
||||
**Scenario:** You use Smallstep's step-ca as your private PKI and want automated lifecycle management for certificates deployed to HAProxy load balancers.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent + step-ca (with JWK provisioner) + HAProxy. certctl issues certs via step-ca's native `/sign` API, combines them into HAProxy's expected PEM format (cert + chain + key in one file), and reloads HAProxy.
|
||||
|
||||
**Prerequisites:** Docker Compose.
|
||||
|
||||
```bash
|
||||
cd examples/step-ca-haproxy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including step-ca provisioner configuration, integrating with an existing step-ca instance, HAProxy PEM format details, and advanced features (approval workflows, policy-based renewal, multi-instance HAProxy) — is in the [example README](../examples/step-ca-haproxy/step-ca-haproxy.md).
|
||||
|
||||
---
|
||||
|
||||
## Multi-Issuer
|
||||
|
||||
**Scenario:** You manage both public-facing services (needing Let's Encrypt or another public CA) and internal services (using a private CA) and want a single dashboard for everything.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent configured with both an ACME issuer and a Local CA issuer. Demonstrates issuer assignment via profiles — public services get ACME certs, internal services get Local CA certs, all visible in one inventory.
|
||||
|
||||
**Prerequisites:** Docker Compose. For real ACME certs, a public domain and port 80 access.
|
||||
|
||||
```bash
|
||||
cd examples/multi-issuer
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including profile-based issuer assignment, testing with ACME staging, Local CA enterprise sub-CA mode, and scaling beyond Docker Compose — is in the [example README](../examples/multi-issuer/multi-issuer.md).
|
||||
|
||||
**Using cert-manager for Kubernetes?** certctl complements cert-manager — cert-manager handles in-cluster certs, certctl handles everything outside: VMs, bare metal, network appliances, Windows servers. They can share the same CA (ACME, step-ca, Vault PKI). See [certctl for cert-manager Users](certctl-for-cert-manager-users.md).
|
||||
|
||||
---
|
||||
|
||||
## Beyond These Examples
|
||||
|
||||
These 5 scenarios cover the most common deployment patterns, but certctl supports a broader set of issuer and target backends — see `docs/features.md`'s Issuer Connectors and Target Connectors sections for the live catalogs (rebuild via `ls -d internal/connector/issuer/*/ | wc -l` and `ls -d internal/connector/target/*/ | wc -l`). Once you have the basics running, you can mix and match:
|
||||
|
||||
**Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon).
|
||||
|
||||
**Targets:** NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell or WinRM proxy), Postfix, Dovecot, F5 BIG-IP (coming soon).
|
||||
|
||||
See [Connector Reference](connectors.md) for configuration details on every issuer and target.
|
||||
+1287
-1270
File diff suppressed because it is too large
Load Diff
+13
-7
@@ -29,15 +29,18 @@ The binary has zero runtime dependencies beyond the certctl server it connects t
|
||||
|
||||
## Configuration
|
||||
|
||||
The MCP server reads two environment variables:
|
||||
The MCP server reads three environment variables:
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | No | `http://localhost:8443` | URL of the certctl REST API |
|
||||
| `CERTCTL_SERVER_URL` | No | `https://localhost:8443` | URL of the certctl REST API (HTTPS-only as of v2.2) |
|
||||
| `CERTCTL_API_KEY` | No | (empty) | API key for authentication (passed as `Bearer` token) |
|
||||
| `CERTCTL_SERVER_CA_BUNDLE_PATH` | Yes (for self-signed / internal CA) | (empty) | Path to PEM CA bundle that signed the server cert. Required when the server cert isn't rooted in the system trust store (the default compose stack ships a self-signed cert at `deploy/test/certs/ca.crt`). |
|
||||
|
||||
If your certctl server has auth enabled (the default), you must provide the API key. The MCP server passes it through to every HTTP request.
|
||||
|
||||
Since v2.2 the certctl control plane is HTTPS-only. If the server cert is self-signed or chained to an internal CA, set `CERTCTL_SERVER_CA_BUNDLE_PATH` so the MCP server can verify the TLS handshake. Never set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true` outside local development — it disables all certificate validation.
|
||||
|
||||
## Setting Up with Claude Desktop
|
||||
|
||||
Add this to your Claude Desktop MCP configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows):
|
||||
@@ -48,7 +51,8 @@ Add this to your Claude Desktop MCP configuration file (`~/Library/Application S
|
||||
"certctl": {
|
||||
"command": "/path/to/certctl-mcp",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_SERVER_URL": "https://localhost:8443",
|
||||
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/certctl/deploy/test/certs/ca.crt",
|
||||
"CERTCTL_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
@@ -67,7 +71,8 @@ In Cursor, go to Settings → MCP Servers and add:
|
||||
"certctl": {
|
||||
"command": "/path/to/certctl-mcp",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_SERVER_URL": "https://localhost:8443",
|
||||
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/certctl/deploy/test/certs/ca.crt",
|
||||
"CERTCTL_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
@@ -84,7 +89,8 @@ Add certctl as an MCP server in your project's `.mcp.json`:
|
||||
"certctl": {
|
||||
"command": "/path/to/certctl-mcp",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_SERVER_URL": "https://localhost:8443",
|
||||
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/certctl/deploy/test/certs/ca.crt",
|
||||
"CERTCTL_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
@@ -94,7 +100,7 @@ Add certctl as an MCP server in your project's `.mcp.json`:
|
||||
|
||||
## Available Tools
|
||||
|
||||
The MCP server registers 78 tools organized across 16 resource domains:
|
||||
The MCP server exposes the full REST API organized across 16 resource domains:
|
||||
|
||||
| Domain | Tools | Examples |
|
||||
|--------|-------|---------|
|
||||
@@ -153,7 +159,7 @@ flowchart LR
|
||||
AI <-->|"stdio"| MCP
|
||||
MCP -->|"HTTP + Bearer token"| SERVER
|
||||
|
||||
MCP ~~~ TOOLS["78 tools · 16 domains\nTyped input structs"]
|
||||
MCP ~~~ TOOLS["REST API via MCP · 16 domains\nTyped input structs"]
|
||||
```
|
||||
|
||||
The MCP server is intentionally thin:
|
||||
|
||||
+36
-29
@@ -34,7 +34,7 @@ cd certctl/deploy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Access the dashboard at `http://localhost:8443` with API key from `.env` file.
|
||||
Access the dashboard at `https://localhost:8443` with the API key from `.env`. The default compose stack ships a self-signed cert; pin with `--cacert ./deploy/test/certs/ca.crt` when calling the API from the host.
|
||||
|
||||
### 2. Deploy Agents
|
||||
|
||||
@@ -99,18 +99,23 @@ Environment="CERTCTL_DISCOVERY_DIRS=/etc/acme.sh"
|
||||
In the **Discovery** page:
|
||||
1. Review the "Unmanaged" certificates found by the agent
|
||||
2. Click **Claim** on each acme.sh certificate
|
||||
3. Map to the certificate ID (certctl auto-generates suggestions)
|
||||
3. Enter the managed certificate ID to link it (e.g., `mc-api-prod`)
|
||||
|
||||
Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status.
|
||||
|
||||
### 5. Create an ACME Issuer
|
||||
|
||||
In **Issuers** → **Configure New Issuer:**
|
||||
In **Issuers** → **+ New Issuer:**
|
||||
|
||||
- **Type:** ACME v2
|
||||
- **Directory URL:** `https://acme-v02.api.letsencrypt.org/directory` (production) or staging for testing
|
||||
- **Email:** Same email as your acme.sh account (required for ACME ToS)
|
||||
- **Challenge Type:** DNS-01 (to match acme.sh's DNS validation)
|
||||
1. Select **ACME** from the issuer type grid
|
||||
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and config
|
||||
|
||||
Or configure via environment variables:
|
||||
```bash
|
||||
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||
export CERTCTL_ACME_EMAIL=your-email@example.com # same as your acme.sh account
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||
```
|
||||
|
||||
### 6. Adapt Your DNS Provider Scripts
|
||||
|
||||
@@ -182,26 +187,28 @@ curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_record
|
||||
-H "X-Auth-Key: ${CF_KEY}"
|
||||
```
|
||||
|
||||
Configure in the ACME issuer:
|
||||
Configure the ACME issuer via environment variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"challenge_type": "dns-01",
|
||||
"dns_present_script": "/etc/certctl/dns/cloudflare-present.sh",
|
||||
"dns_cleanup_script": "/etc/certctl/dns/cloudflare-cleanup.sh",
|
||||
"dns_propagation_wait": 30
|
||||
}
|
||||
```bash
|
||||
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cloudflare-cleanup.sh
|
||||
```
|
||||
|
||||
Or create the issuer through the dashboard: **Issuers** → **+ New Issuer** → select **ACME** → fill in the config fields.
|
||||
|
||||
### 7. Create Renewal Policies
|
||||
|
||||
In **Policies:**
|
||||
In **Policies** → **+ New Policy:**
|
||||
|
||||
- **Certificate Profile:** Select the issuer and challenge type from step 5
|
||||
- **Renewal Threshold:** 30 days before expiry (or match your acme.sh cron settings)
|
||||
- **Agent Group:** Select which agents should renew certificates
|
||||
- **Name:** e.g., "ACME DNS-01 Policy"
|
||||
- **Type:** `expiration_window` (enforces renewal thresholds)
|
||||
- **Severity:** `high`
|
||||
- **Config:** set your renewal window (default: 30 days before expiry)
|
||||
|
||||
Set one policy per domain or domain pattern.
|
||||
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails on top.
|
||||
|
||||
### 8. Phase Out acme.sh Cron
|
||||
|
||||
@@ -252,17 +259,17 @@ Benefits:
|
||||
|
||||
To enable:
|
||||
|
||||
```json
|
||||
{
|
||||
"challenge_type": "dns-persist-01",
|
||||
"dns_persist_issuer_domain": "acme-v02.api.letsencrypt.org"
|
||||
}
|
||||
```bash
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01
|
||||
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN=letsencrypt.org
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||
```
|
||||
|
||||
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
||||
|
||||
## Support
|
||||
## Next Steps
|
||||
|
||||
See [Connector Configuration](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts).
|
||||
|
||||
See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale.
|
||||
- Try the [Wildcard DNS-01 example](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md) — a working docker-compose with Cloudflare hooks you can adapt for your DNS provider
|
||||
- See [Connector Reference](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts)
|
||||
- See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale
|
||||
- See all [Deployment Examples](./examples.md) for other scenarios (ACME+NGINX, private CA, step-ca, multi-issuer)
|
||||
|
||||
@@ -22,7 +22,7 @@ Option A: Docker Compose (quickest for evaluation)
|
||||
```bash
|
||||
cd /opt/certctl
|
||||
docker compose up -d
|
||||
# Dashboard & API: https://localhost:8443
|
||||
# Dashboard & API: https://localhost:8443 (self-signed cert — use --cacert ./deploy/test/certs/ca.crt for the default compose stack)
|
||||
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
|
||||
```
|
||||
|
||||
@@ -45,7 +45,8 @@ chmod +x /usr/local/bin/certctl-agent
|
||||
# Create config
|
||||
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
|
||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||
CERTCTL_SERVER_URL=https://certctl-control-plane.example.com:8080
|
||||
CERTCTL_SERVER_URL=https://certctl-control-plane.example.com:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt
|
||||
CERTCTL_API_KEY=your-api-key-here
|
||||
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
|
||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||
@@ -71,24 +72,34 @@ The control plane now knows about all 50 certs and where they live.
|
||||
|
||||
### 4. Configure ACME Issuer
|
||||
|
||||
Go to **Issuers** → **Add Issuer**:
|
||||
- Type: ACME
|
||||
- Directory URL: `https://acme-v02.api.letsencrypt.org/directory` (production)
|
||||
- Email: your Let's Encrypt account email
|
||||
- Challenge Type: `http-01` (if you have HTTP access) or `dns-01` (for wildcard/internal certs)
|
||||
- For DNS-01, provide your DNS provider's script hook (Cloudflare, Route53, Azure DNS, etc.)
|
||||
Go to **Issuers** → **+ New Issuer**:
|
||||
1. Select **ACME** from the issuer type grid
|
||||
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and any required config
|
||||
|
||||
Test the connection. certctl uses the same Let's Encrypt account; no new credentials needed.
|
||||
Alternatively, configure via environment variables before starting the server:
|
||||
```bash
|
||||
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=http-01 # or dns-01 for wildcard certs
|
||||
```
|
||||
|
||||
For DNS-01, also set:
|
||||
```bash
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/present.sh
|
||||
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cleanup.sh
|
||||
```
|
||||
|
||||
certctl uses the same Let's Encrypt account; no new credentials needed.
|
||||
|
||||
### 5. Create Renewal Policies
|
||||
|
||||
Go to **Policies** → **New Policy**:
|
||||
- Profile: ACME (or create a new one with `serverAuth` EKU)
|
||||
- Issuer: the ACME issuer you just created
|
||||
- Renewal Threshold: 30 days before expiry (default, adjust as needed)
|
||||
- Scope: select agent groups or individual agents managing your servers
|
||||
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||
- Name: e.g., "ACME Renewal Policy"
|
||||
- Type: `expiration_window` (to enforce renewal thresholds)
|
||||
- Severity: `high`
|
||||
- Config: set your renewal threshold (default: 30 days before expiry)
|
||||
|
||||
Assign this policy to your discovered certs.
|
||||
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails (key algorithm requirements, expiration windows, etc.).
|
||||
|
||||
### 6. Disable Certbot Cron, One Server at a Time
|
||||
|
||||
@@ -133,11 +144,11 @@ docker compose up -d
|
||||
# Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY
|
||||
```
|
||||
|
||||
Now you get 30/14/7-day warnings before any cert expires, across all 50 servers, in one place.
|
||||
Now you get 30/14/7-day warnings before any cert expires, across all 10 servers, in one place.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 5 minutes vs. cron running once a day).
|
||||
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 60 seconds vs. cron running once a day).
|
||||
- **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently.
|
||||
- **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds.
|
||||
- **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires.
|
||||
@@ -156,6 +167,7 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Try the [ACME + NGINX example](../examples/acme-nginx/acme-nginx.md) — a working docker-compose you can run locally before deploying to production
|
||||
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
||||
- Explore [Network Discovery](./quickstart.md#network-discovery) to find certificates you didn't know about
|
||||
- Set up [Kubernetes cert-manager integration](./cert-manager.md) if you manage in-cluster certs too
|
||||
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
||||
- See all [Deployment Examples](./examples.md) for other scenarios (wildcard DNS-01, private CA, step-ca, multi-issuer)
|
||||
|
||||
+9
-4
@@ -68,8 +68,10 @@ The spec organizes endpoints into 16 tags:
|
||||
The spec declares a `bearerAuth` security scheme applied globally. All endpoints under `/api/v1/` require a Bearer token by default:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your-api-key" \
|
||||
http://localhost:8443/api/v1/certificates
|
||||
# The default compose stack uses a self-signed cert; pin with --cacert
|
||||
curl --cacert ./deploy/test/certs/ca.crt \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
https://localhost:8443/api/v1/certificates
|
||||
```
|
||||
|
||||
Three endpoints are exempt from auth (declared with `security: []` in the spec): `/health`, `/ready`, and `/api/v1/auth/info`. The auth info endpoint tells clients whether authentication is enabled and what type is required — useful for GUIs that need to show/hide a login screen.
|
||||
@@ -150,8 +152,9 @@ Import the spec directly into Postman:
|
||||
|
||||
1. Open Postman → Import → File → select `api/openapi.yaml`
|
||||
2. Postman creates a collection with all 78 documented operations organized by tag
|
||||
3. Set the `baseUrl` variable to `http://localhost:8443`
|
||||
3. Set the `baseUrl` variable to `https://localhost:8443` (HTTPS-only as of v2.2)
|
||||
4. Add an `Authorization: Bearer your-api-key` header to the collection
|
||||
5. Import the demo stack CA bundle (`deploy/test/certs/ca.crt`) into Postman's Settings → Certificates → CA Certificates, or disable certificate verification for the `localhost` host (Settings → General → SSL certificate verification)
|
||||
|
||||
## Key Schemas
|
||||
|
||||
@@ -176,8 +179,10 @@ Use the spec to generate contract tests that verify the API matches the spec:
|
||||
```bash
|
||||
# Using schemathesis for fuzz testing against the spec
|
||||
pip install schemathesis
|
||||
# The default compose stack uses a self-signed cert — export a CA bundle or set REQUESTS_CA_BUNDLE
|
||||
export REQUESTS_CA_BUNDLE=$(pwd)/deploy/test/certs/ca.crt
|
||||
schemathesis run api/openapi.yaml \
|
||||
--base-url http://localhost:8443 \
|
||||
--base-url https://localhost:8443 \
|
||||
--header "Authorization: Bearer your-api-key"
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# QA Test Suite Guide (`qa_test.go`)
|
||||
|
||||
> **Audience:** Anyone running release QA for certctl — whether you're a first-time contributor or the maintainer cutting a release tag.
|
||||
>
|
||||
> **Companion to:** `docs/testing-guide.md` (the *what* to test). This document explains the *how* — the automated test file, what it covers, what it skips, and how to fill the gaps manually.
|
||||
|
||||
---
|
||||
|
||||
## What Is This File?
|
||||
|
||||
`deploy/test/qa_test.go` is a single Go test file (~1700 lines) that automates as much of `docs/testing-guide.md` as possible against a running certctl Docker Compose demo stack. It replaces the legacy `qa-smoke-test.sh` bash script.
|
||||
|
||||
It covers **all 54 Parts** of the testing guide:
|
||||
|
||||
- **~164 automated subtests** — API calls, database queries, source file checks, performance benchmarks
|
||||
- **11 skipped Parts** — with documented reasons (external CAs, Windows, browser-only, etc.)
|
||||
- **Remaining ~282 manual tests** — GUI flows, scheduler timing, Docker log inspection — must be done by a human following `docs/testing-guide.md`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────┐ ┌──────────────────────────┐
|
||||
│ qa_test.go │────▶│ certctl demo stack │
|
||||
│ (//go:build qa) │ │ docker-compose.yml + │
|
||||
│ │ │ docker-compose.demo.yml │
|
||||
│ TestQA(t *testing.T) │ │ │
|
||||
│ ├─ Part01_Infra │ │ ┌─ certctl-server :8443 │
|
||||
│ ├─ Part02_Auth │ │ ├─ postgres :5432 │
|
||||
│ ├─ Part03_CertCRUD │ │ └─ certctl-agent │
|
||||
│ ├─ ... │ └──────────────────────────┘
|
||||
│ └─ Part52_HelmChart │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
Key design choices:
|
||||
|
||||
- **Build tag:** `//go:build qa` — never runs during `go test ./...` or CI. Only runs when explicitly requested.
|
||||
- **Package:** `integration_test` — same package as `integration_test.go` (which uses `//go:build integration` for the test stack). They coexist but never run together.
|
||||
- **Zero internal imports:** Uses only stdlib + `lib/pq` (from `go.mod`). All API interactions are plain HTTP. All JSON is decoded into lightweight local structs (`qaCert`, `qaJob`, etc.) — not the internal domain types.
|
||||
- **Self-cleaning:** Tests that create data use `t.Cleanup()` to delete it afterward. The seed data is not modified.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Docker Compose demo stack running:**
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build -d
|
||||
```
|
||||
Wait ~15 seconds for health checks to pass.
|
||||
|
||||
2. **Go 1.22+** installed (the project uses Go 1.25 in `go.mod`, but 1.22+ works for running tests).
|
||||
|
||||
3. **PostgreSQL port exposed** — the demo stack exposes port 5432 for database verification tests (table counts, schema checks).
|
||||
|
||||
4. **Repository checkout** — source file verification tests (`fileExists`, `fileContains`) read files relative to `qaRepoDir` (default: `../..` from `deploy/test/`).
|
||||
|
||||
## Running the Tests
|
||||
|
||||
### Full suite
|
||||
```bash
|
||||
cd deploy/test
|
||||
go test -tags qa -v -timeout 10m ./...
|
||||
```
|
||||
|
||||
### Single Part
|
||||
```bash
|
||||
go test -tags qa -v -run TestQA/Part03 ./...
|
||||
```
|
||||
|
||||
### Single subtest
|
||||
```bash
|
||||
go test -tags qa -v -run TestQA/Part03_CertCRUD/Create_Minimal ./...
|
||||
```
|
||||
|
||||
### With custom environment
|
||||
```bash
|
||||
CERTCTL_QA_SERVER_URL=https://staging.internal:8443 \
|
||||
CERTCTL_QA_API_KEY=my-staging-key \
|
||||
CERTCTL_QA_DB_URL=postgres://certctl:secret@db.internal:5432/certctl?sslmode=require \
|
||||
CERTCTL_QA_REPO_DIR=/path/to/certctl \
|
||||
go test -tags qa -v -timeout 10m ./...
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CERTCTL_QA_SERVER_URL` | `https://localhost:8443` | certctl server URL (HTTPS-only as of v2.2) |
|
||||
| `CERTCTL_QA_API_KEY` | `change-me-in-production` | API key for Bearer auth |
|
||||
| `CERTCTL_QA_DB_URL` | `postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable` | PostgreSQL connection string |
|
||||
| `CERTCTL_QA_REPO_DIR` | `../..` | Path to certctl repo root (for source file checks) |
|
||||
| `CERTCTL_QA_CA_BUNDLE` | `./certs/ca.crt` | PEM CA bundle pinned for TLS verification. The demo stack's `certctl-tls-init` container writes here. |
|
||||
| `CERTCTL_QA_INSECURE` | `false` | Set to `"true"` to skip TLS verification (e.g. before the init container finishes). Never use outside the demo harness. |
|
||||
|
||||
## Part-by-Part Coverage Map
|
||||
|
||||
This table shows what each Part tests and what's left for manual verification.
|
||||
|
||||
| Part | Testing Guide Section | Automated Subtests | What's Automated | What's Manual |
|
||||
|------|----------------------|-------------------|-----------------|--------------|
|
||||
| 1 | Infrastructure & Deployment | 8 | Table count, health/ready endpoints, seed data counts (certs, agents, issuers, targets, policies) | Docker container health, log inspection, volume mounts |
|
||||
| 2 | Authentication & Security | 4 | No-auth 401, bad-key 401, health-no-auth 200, no private keys in API | CORS preflight, rate limiting (429 + Retry-After), TLS config |
|
||||
| 3 | Certificate Lifecycle | 10 | Create (minimal + full), get, 404, list pagination, status/issuer filters, sparse fields, update, archive | Deployment trigger, version history, certificate detail UI |
|
||||
| 4 | Renewal Workflow | 3 | Trigger renewal, 404 on nonexistent, agent work endpoint | AwaitingCSR flow, agent key generation, full issuance cycle |
|
||||
| 5 | Revocation | 5 | Revoke (default reason), already-revoked, nonexistent, invalid reason, CRL JSON | DER CRL, OCSP responder, revocation notifications |
|
||||
| 6 | Policies & Profiles | 6 | Policy CRUD (create/delete), invalid type 400, profile CRUD, list | Policy violation detection, profile enforcement on CSR |
|
||||
| 7 | Ownership & Teams | 4 | Team CRUD, owner CRUD, agent groups list | Owner notification routing, dynamic group matching |
|
||||
| 8 | Job System | 2 | List jobs, 404 on nonexistent | Job state transitions, approval workflow, cancellation |
|
||||
| 9 | Issuer Connectors | 4 | List, get detail, create (GenericCA), missing name 400 | Test connection, issuer-specific issuance flow |
|
||||
| 10 | Sub-CA Mode | SKIP | — | Requires CA cert+key on disk |
|
||||
| 11 | ACME ARI | SKIP | — | Requires ARI-capable CA |
|
||||
| 12 | Vault PKI | SKIP | — | Requires live Vault server |
|
||||
| 13 | DigiCert | SKIP | — | Requires DigiCert sandbox |
|
||||
| 14 | Target Connectors | 3 | List, create NGINX target, delete 204 | Deploy to real target, validate deployment |
|
||||
| 15–17 | Apache/HAProxy, Traefik/Caddy, IIS | — | (Covered by source checks in Parts 42–46) | Requires real services or Windows |
|
||||
| 18 | Agent Operations | 3 | Heartbeat (register), metadata check, auto-create on heartbeat | Agent binary behavior, key storage, discovery scan |
|
||||
| 19 | Agent Work Routing | 1 | Empty work for agent with no targets | Scoped job assignment, multi-target fan-out |
|
||||
| 20 | Post-Deployment Verification | 1 | 404 on nonexistent job verification | TLS probing, fingerprint comparison |
|
||||
| 21 | EST Server | 2 | CACerts (200 + content-type), CSRAttrs (200/204) | simpleenroll with CSR, simplereenroll, PKCS#7 parsing |
|
||||
| 22 | Certificate Export | 3 | PEM export, PKCS#12 export, 404 on nonexistent | Download mode, file content validation |
|
||||
| 25 | Certificate Discovery | 5 | List discovered, summary, list scan targets, create target, invalid CIDR 400 | Agent filesystem scan, claim/dismiss workflow |
|
||||
| 26 | Enhanced Query API | 4 | Sort descending, cursor pagination, time-range filter, invalid sort field | Field projection correctness, cursor token cycling |
|
||||
| 27 | Request Body Size Limits | 1 | 2MB body rejected (413/400) | Exact limit boundary (1MB) |
|
||||
| 28 | CLI | SKIP | — | Requires compiled `certctl-cli` binary |
|
||||
| 29 | MCP Server | SKIP | — | Requires compiled `mcp-server` binary + stdio |
|
||||
| 30 | Observability | 7 | Dashboard summary, certs by status, expiration timeline, job trends, issuance rate, JSON metrics (uptime + gauges), Prometheus (content-type + 4 metric names) | Chart rendering (GUI), Grafana import |
|
||||
| 31 | Notifications | 2 | List, 404 on nonexistent | Notification content, mark-read, email/Slack delivery |
|
||||
| 32 | Audit Trail | 3 | List events (≥10), PUT immutability, DELETE immutability | Actor attribution, body hash, time range filters |
|
||||
| 33 | Background Scheduler | SKIP | — | Timing-dependent; verify via Docker logs |
|
||||
| 34 | Structured Logging | SKIP | — | Requires Docker log inspection |
|
||||
| 35 | GUI Testing | SKIP | — | Requires browser |
|
||||
| 36–37 | Issuer Catalog, Frontend Audit | SKIP | — | Requires browser |
|
||||
| 38 | Error Handling | 5 | Malformed JSON, missing required field, method not allowed, UTF-8 CN, empty body | Stack trace suppression, error response format |
|
||||
| 39 | Performance | 5 | List certs < 200ms, stats < 500ms, metrics < 200ms, Prometheus < 300ms, audit < 500ms | Load testing, concurrent request handling |
|
||||
| 40 | Documentation | 8 | README, quickstart, architecture, connectors, compliance exist; migration guides exist; 8 issuer types in docs; 11 target types in docs | Content accuracy, link validity |
|
||||
| 41 | Regression | 3 | DELETE 204, per_page max fallback, network scan target seed count | `errors.Is(errors.New())` anti-pattern source scan |
|
||||
| 42 | Envoy Target | 5 | Domain type, connector file, test file, OpenAPI, agent dispatch | Envoy deployment test, SDS config |
|
||||
| 43 | Postfix/Dovecot | 3 | Domain types (Postfix + Dovecot), connector file, OpenAPI | Mail server deployment test |
|
||||
| 44 | SSH Target | 4 | Domain type, connector file, agent dispatch (`sshconn`), OpenAPI | SSH deployment test (requires target host) |
|
||||
| 45 | Windows Certificate Store | 3 | Domain type, connector file, shared certutil package | Windows deployment (requires Windows) |
|
||||
| 46 | Java Keystore | 3 | Domain type, connector file, OpenAPI | JKS deployment (requires keytool) |
|
||||
| 47 | Certificate Digest Email | 3 | Preview endpoint (200/503), service file, adapter file | SMTP delivery, HTML template rendering |
|
||||
| 48 | Dynamic Issuer Config | 4 | Crypto package exists, create ACME issuer via API, config redaction check, migration exists | Test connection flow, registry rebuild |
|
||||
| 49 | Dynamic Target Config | 2 | Create NGINX target via API, migration exists | Test connection via agent heartbeat |
|
||||
| 50 | Onboarding Wizard | 2 | Wizard component exists, docker-compose split (clean vs demo) | Wizard UI flow, step completion |
|
||||
| 51 | ACME Profile Selection | 3 | Profile module exists, frontend config, RFC 9702→9773 renumber check | Profile-aware issuance against real CA |
|
||||
| 52 | Helm Chart | 5 | Chart.yaml, values.yaml, 4 templates exist, securityContext, health probes | `helm template` rendering, `helm install` |
|
||||
| 53 | Kubernetes Secrets Target Connector (M47) | 18 | Config validation (namespace DNS-1123, secret name DNS subdomain, label keys, required fields), deployment (create/update Secret, chain concatenation, error propagation), validation (serial comparison, not-found, empty cert) | GUI target wizard KubernetesSecrets fields (namespace, secret_name, labels, kubeconfig_path), Helm RBAC toggle, TargetDetailPage type label |
|
||||
| 54 | AWS ACM Private CA Issuer Connector (M47) | 23 | Config validation (region, CA ARN regex, signing algorithm whitelist, validity_days, defaults), issuance (full flow, empty CSR, errors), renewal (reuses issuance), revocation (reason mapping, default, errors), GetOrderStatus completed, GetCACertPEM (success/chain/error), GetRenewalInfo nil | GUI issuer wizard AWSACMPCA fields (region, ca_arn, signing_algorithm, validity_days, template_arn), seed data visibility, create issuer flow |
|
||||
|
||||
**Totals:** ~164 automated subtests, 11 fully skipped Parts, ~282 manual tests remaining.
|
||||
|
||||
## Test Categories
|
||||
|
||||
The automated tests fall into four categories:
|
||||
|
||||
### 1. API Integration Tests (majority)
|
||||
Make real HTTP requests to the running server and verify status codes, response structure, and JSON field values. Examples:
|
||||
- `POST /api/v1/certificates` with valid payload → 201
|
||||
- `GET /api/v1/certificates?status=Active` → all returned certs have `status: "Active"`
|
||||
- `DELETE /api/v1/certificates/mc-qa-full` → 204
|
||||
|
||||
### 2. Database Verification Tests
|
||||
Connect directly to PostgreSQL and verify schema state:
|
||||
- Table count ≥ 19 (from migrations 000001–000010)
|
||||
- Useful for catching migration regressions
|
||||
|
||||
### 3. Source File Verification Tests
|
||||
Read files from the repo checkout and verify structure:
|
||||
- Domain types exist in `internal/domain/connector.go` (e.g., `TargetTypeEnvoy`)
|
||||
- Connector implementations exist (e.g., `internal/connector/target/envoy/envoy.go`)
|
||||
- Documentation contains expected content (all issuer/target types listed)
|
||||
- No stale RFC 9702 references (replaced by RFC 9773)
|
||||
|
||||
### 4. Performance Spot Checks
|
||||
Timed API requests with threshold assertions:
|
||||
- `GET /api/v1/certificates?per_page=15` < 200ms
|
||||
- `GET /api/v1/stats/summary` < 500ms
|
||||
- `GET /api/v1/metrics/prometheus` < 300ms
|
||||
|
||||
## What This Test Does NOT Cover
|
||||
|
||||
These gaps must be filled by manual testing per `docs/testing-guide.md`:
|
||||
|
||||
### External CA Integrations (Parts 10–13)
|
||||
- **Sub-CA mode** — requires CA cert+key files on disk
|
||||
- **ACME ARI** — requires a CA that supports RFC 9773 Renewal Information
|
||||
- **Vault PKI** — requires a running HashiCorp Vault instance
|
||||
- **DigiCert / Sectigo / Google CAS** — requires sandbox API credentials
|
||||
|
||||
### Browser/GUI Testing (Parts 35–37, 50)
|
||||
- Dashboard chart rendering (Recharts)
|
||||
- Onboarding wizard step-by-step flow
|
||||
- Issuer catalog card layout and create wizard
|
||||
- Bulk operations UI (multi-select, progress bars)
|
||||
- Discovery triage workflow
|
||||
|
||||
### Real Deployment Testing (Parts 15–17)
|
||||
- NGINX/Apache/HAProxy file write + reload
|
||||
- Traefik/Caddy file provider or API reload
|
||||
- IIS PowerShell/WinRM (requires Windows)
|
||||
- F5 BIG-IP iControl REST (requires appliance or mock)
|
||||
- SSH agentless deployment (requires target host)
|
||||
|
||||
### Agent Binary Behavior (Parts 18, 28–29)
|
||||
- Agent-side ECDSA key generation and CSR submission
|
||||
- Agent filesystem discovery scan
|
||||
- CLI tool (`certctl-cli`) — all 10 subcommands
|
||||
- MCP server (`mcp-server`) — stdio transport
|
||||
|
||||
### Timing-Dependent Tests (Parts 33–34)
|
||||
- Background scheduler loop execution (renewal, jobs, health, notifications, digest, network scan)
|
||||
- Structured logging format verification (requires Docker log parsing)
|
||||
|
||||
## How This Relates to `integration_test.go`
|
||||
|
||||
Both files live in `deploy/test/` in the same Go package (`integration_test`):
|
||||
|
||||
| | `qa_test.go` | `integration_test.go` |
|
||||
|---|---|---|
|
||||
| **Build tag** | `//go:build qa` | `//go:build integration` |
|
||||
| **Target stack** | Demo (`docker-compose.yml` + `docker-compose.demo.yml`) | Test (`docker-compose.test.yml`) |
|
||||
| **Port** | 8443 | Different (test stack config) |
|
||||
| **Seed data** | `seed_demo.sql` (32 certs, 8 agents, realistic history) | Minimal (created by tests) |
|
||||
| **CA backends** | Local CA only (demo mode) | Pebble ACME, step-ca, NGINX |
|
||||
| **Purpose** | Release QA — broad coverage, spot checks | Functional — end-to-end issuance, renewal, revocation against real CAs |
|
||||
| **Run frequency** | Before each release tag | CI on every PR |
|
||||
|
||||
They are complementary. Integration tests prove the machinery works. QA tests prove the product works at release quality.
|
||||
|
||||
## Seed Data Reference
|
||||
|
||||
The QA tests depend on `migrations/seed_demo.sql`. Key IDs used:
|
||||
|
||||
### Certificates (32 total)
|
||||
`mc-api-prod`, `mc-web-prod`, `mc-pay-prod`, `mc-dash-prod`, `mc-data-prod`, `mc-search-prod`, `mc-admin-prod`, `mc-blog-prod`, `mc-docs-prod`, `mc-status-prod`, `mc-grpc-prod`, `mc-vault-prod`, `mc-consul-prod`, `mc-shop-prod`, `mc-auth-prod`, `mc-cdn-prod`, `mc-mail-prod`, `mc-ci-prod`, `mc-legacy-prod`, `mc-old-api`, `mc-wiki-prod`, `mc-api-stg`, `mc-web-stg`, `mc-pay-stg`, `mc-api-dev`, `mc-grafana-prod`, `mc-vpn-prod`, `mc-wildcard-prod`, `mc-compromised`, `mc-edge-eu`, `mc-k8s-ingress`, `mc-smime-bob`
|
||||
|
||||
### Agents (9 total)
|
||||
`ag-web-prod`, `ag-web-staging`, `ag-lb-prod`, `ag-iis-prod`, `ag-data-prod`, `ag-edge-01`, `ag-k8s-prod`, `ag-mac-dev`, `server-scanner` (sentinel)
|
||||
|
||||
### Issuers (9 total)
|
||||
`iss-local`, `iss-acme-le`, `iss-stepca`, `iss-acme-zs`, `iss-openssl`, `iss-vault`, `iss-digicert`, `iss-sectigo`, `iss-googlecas`
|
||||
|
||||
### Targets (8 total)
|
||||
`tgt-nginx-prod`, `tgt-nginx-staging`, `tgt-haproxy-prod`, `tgt-apache-prod`, `tgt-iis-prod`, `tgt-traefik-prod`, `tgt-caddy-prod`, `tgt-nginx-data`
|
||||
|
||||
### Network Scan Targets (4 total)
|
||||
`nst-dc1-web`, `nst-dc2-apps`, `nst-dmz`, `nst-edge`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Server unreachable" on startup
|
||||
The test pings `GET /health` before running anything. If this fails:
|
||||
```bash
|
||||
# Check if the stack is running
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml ps
|
||||
|
||||
# Check server logs
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml logs certctl-server
|
||||
|
||||
# Check if the port is exposed (self-signed cert — pin CA bundle)
|
||||
curl --cacert ./deploy/test/certs/ca.crt -s https://localhost:8443/health
|
||||
```
|
||||
|
||||
### "connect to QA DB" failure
|
||||
The database tests connect directly to PostgreSQL. Ensure port 5432 is exposed:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml port postgres 5432
|
||||
```
|
||||
|
||||
### Performance tests flaking
|
||||
The performance thresholds (200ms, 300ms, 500ms) assume a local Docker stack. On slow CI runners or remote Docker hosts, increase the thresholds or skip Part 39:
|
||||
```bash
|
||||
go test -tags qa -v -run 'TestQA/Part(?!39)' ./...
|
||||
```
|
||||
|
||||
### Source file checks failing
|
||||
The `fileExists` and `fileContains` helpers read from `CERTCTL_QA_REPO_DIR` (default `../..`). If running from a non-standard location:
|
||||
```bash
|
||||
CERTCTL_QA_REPO_DIR=/absolute/path/to/certctl go test -tags qa -v ./...
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When a new feature ships:
|
||||
|
||||
1. **Add a Part section** in `qa_test.go` following the numbering in `docs/testing-guide.md`
|
||||
2. **API tests**: use `c.get()`, `c.post()`, `c.bodyStr()`, `c.getJSON()`, `c.timedGet()`
|
||||
3. **Source checks**: use `fileExists(t, "relative/path")` and `fileContains(t, "path", "substring")`
|
||||
4. **DB checks**: use `openQADB(t)` and `db.queryInt(t, "SELECT ...")`
|
||||
5. **Cleanup**: always use `t.Cleanup()` for data created during tests
|
||||
6. **Skip if external**: use `t.Skip("Requires X — manual test")` with a clear reason
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0** (April 2026) — Initial release covering all 52 Parts of testing-guide.md v2.1. Replaces `qa-smoke-test.sh`.
|
||||
- **v1.1** (April 2026) — Added Parts 53–54 (M47: Kubernetes Secrets target + AWS ACM PCA issuer). 54 Parts total, ~164 automated subtests.
|
||||
+84
-49
@@ -60,6 +60,23 @@ cp deploy/.env.example deploy/.env
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
> **Warning:** Edit `POSTGRES_PASSWORD` *before* the very first `docker compose up`. Postgres seeds the password into its data directory only on first boot of an empty volume — after that, the password is baked into `pg_authid` and the env var is ignored. If you boot once with the default and later change `POSTGRES_PASSWORD` in `.env`, the certctl-server container picks up the new value but postgres still authenticates against the old one, and the server logs `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01). Two ways out: tear down the volume with `docker compose -f deploy/docker-compose.yml down -v` (this **deletes all data**) and bring up fresh, or rotate non-destructively with `docker compose -f deploy/docker-compose.yml exec postgres psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"` and then restart certctl-server with the matching `POSTGRES_PASSWORD`.
|
||||
|
||||
### Docker Compose Environments
|
||||
|
||||
The `deploy/` directory contains four compose files for different use cases:
|
||||
|
||||
| File | Purpose | How to run |
|
||||
|------|---------|------------|
|
||||
| `docker-compose.yml` | **Base platform.** PostgreSQL + certctl server + agent. Clean dashboard with onboarding wizard — use this for production or first-time setup. | `docker compose -f deploy/docker-compose.yml up --build` |
|
||||
| `docker-compose.demo.yml` | **Demo data override.** Layers 180 days of realistic seed data (15 certs, 5 agents, multiple issuers) onto the base. Dashboard charts and tables look populated on first boot. | `docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up --build` |
|
||||
| `docker-compose.dev.yml` | **Development override.** Adds PgAdmin (port 5050), debug-level logging, and a Delve debugger port (40000) for the server. | `docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up --build` |
|
||||
| `docker-compose.test.yml` | **Integration test environment.** 7 containers on a static-IP subnet: PostgreSQL, certctl server+agent, step-ca, Pebble ACME server, challenge test server, and NGINX. Runs the full issuance→deployment→verification flow against real CA backends. Standalone — does not combine with the base file. | `docker compose -f deploy/docker-compose.test.yml up --build` |
|
||||
|
||||
Override files are layered onto the base with multiple `-f` flags. The test environment is self-contained and runs independently. To reset any environment's data, add `down -v` to remove volumes.
|
||||
|
||||
For a deep dive into every service, environment variable, and networking decision, see the [Docker Compose Environments Guide](../deploy/ENVIRONMENTS.md).
|
||||
|
||||
### Kubernetes with Helm
|
||||
|
||||
For production deployments on Kubernetes, use the Helm chart:
|
||||
@@ -90,16 +107,24 @@ certctl-server Up (healthy)
|
||||
certctl-agent Up
|
||||
```
|
||||
|
||||
The control plane is HTTPS-only as of v2.2. The `certctl-tls-init` init container in the shipped `deploy/docker-compose.yml` self-signs a cert on first boot and drops it into a named volume. Extract the CA bundle once and reuse it for every API call in this guide:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8443/health
|
||||
export CA=/tmp/certctl-ca.crt
|
||||
docker compose -f deploy/docker-compose.yml exec -T certctl-server \
|
||||
cat /etc/certctl/tls/ca.crt > "$CA"
|
||||
|
||||
curl --cacert "$CA" https://localhost:8443/health
|
||||
```
|
||||
```json
|
||||
{"status":"healthy"}
|
||||
```
|
||||
|
||||
If you're bringing your own cert (internal CA, cert-manager, operator-supplied Secret), see [`docs/tls.md`](tls.md) for the full provisioning matrix. If you're cutting over an existing install, see [`docs/upgrade-to-tls.md`](upgrade-to-tls.md) for the failure modes (out-of-date `http://…` agents fail at the TLS handshake) and the one-step procedure.
|
||||
|
||||
## Open the Dashboard
|
||||
|
||||
Open **http://localhost:8443** in your browser.
|
||||
Open **https://localhost:8443** in your browser. Your browser will warn about the self-signed cert — that's expected for the demo bootstrap. Trust the CA bundle you just exported, or click through the warning.
|
||||
|
||||
> **Note:** The Docker Compose demo runs with authentication disabled (`CERTCTL_AUTH_TYPE=none`) so you can explore immediately. For production, set `CERTCTL_AUTH_TYPE=api-key` and `CERTCTL_AUTH_SECRET=<your-secret>` in your environment, then pass `Authorization: Bearer <your-secret>` on all API requests. The dashboard will prompt for your API key on first load.
|
||||
>
|
||||
@@ -139,62 +164,64 @@ Everything you see in the dashboard is backed by the REST API. All endpoints liv
|
||||
|
||||
### Core operations
|
||||
|
||||
Every request below uses `--cacert "$CA"` to pin the self-signed CA bundle extracted above. In production, point `$CA` at your internal CA root or the bundle you distributed to the fleet.
|
||||
|
||||
```bash
|
||||
# List all certificates
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates | jq .
|
||||
|
||||
# Filter by status
|
||||
curl -s "http://localhost:8443/api/v1/certificates?status=Expiring" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?status=Expiring" | jq .
|
||||
|
||||
# Filter by environment
|
||||
curl -s "http://localhost:8443/api/v1/certificates?environment=production" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?environment=production" | jq .
|
||||
|
||||
# Get a specific certificate
|
||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates/mc-api-prod | jq .
|
||||
|
||||
# Get deployment targets for a certificate
|
||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
||||
|
||||
# List agents
|
||||
curl -s http://localhost:8443/api/v1/agents | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/agents | jq .
|
||||
|
||||
# Check agent pending work
|
||||
curl -s http://localhost:8443/api/v1/agents/ag-web-prod/work | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/agents/ag-web-prod/work | jq .
|
||||
|
||||
# View audit trail
|
||||
curl -s http://localhost:8443/api/v1/audit | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/audit | jq .
|
||||
|
||||
# View policies and violations
|
||||
curl -s http://localhost:8443/api/v1/policies | jq .
|
||||
curl -s http://localhost:8443/api/v1/policies/pr-require-owner/violations | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/policies | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/policies/pr-require-owner/violations | jq .
|
||||
|
||||
# Notifications
|
||||
curl -s http://localhost:8443/api/v1/notifications | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/notifications | jq .
|
||||
|
||||
# Profiles and agent groups
|
||||
curl -s http://localhost:8443/api/v1/profiles | jq .
|
||||
curl -s http://localhost:8443/api/v1/agent-groups | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/profiles | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/agent-groups | jq .
|
||||
```
|
||||
|
||||
### Sorting, filtering, and pagination
|
||||
|
||||
```bash
|
||||
# Sort by expiration date (ascending)
|
||||
curl -s "http://localhost:8443/api/v1/certificates?sort=notAfter" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?sort=notAfter" | jq .
|
||||
|
||||
# Sort descending (prefix with -)
|
||||
curl -s "http://localhost:8443/api/v1/certificates?sort=-createdAt" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?sort=-createdAt" | jq .
|
||||
|
||||
# Time-range filters (RFC3339)
|
||||
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
|
||||
curl -s "http://localhost:8443/api/v1/certificates?created_after=2026-03-01T00:00:00Z" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?created_after=2026-03-01T00:00:00Z" | jq .
|
||||
|
||||
# Sparse fields — request only what you need
|
||||
curl -s "http://localhost:8443/api/v1/certificates?fields=id,common_name,status,expires_at" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?fields=id,common_name,status,expires_at" | jq .
|
||||
|
||||
# Cursor pagination — efficient for large inventories
|
||||
curl -s "http://localhost:8443/api/v1/certificates?page_size=5" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
|
||||
curl -s "http://localhost:8443/api/v1/certificates?cursor=<next_cursor_value>&page_size=5" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?page_size=5" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?cursor=<next_cursor_value>&page_size=5" | jq .
|
||||
```
|
||||
|
||||
Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commonName`, `name`, `status`, `environment`.
|
||||
@@ -203,22 +230,22 @@ Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commo
|
||||
|
||||
```bash
|
||||
# Dashboard summary
|
||||
curl -s http://localhost:8443/api/v1/stats/summary | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/stats/summary | jq .
|
||||
|
||||
# Certificates by status
|
||||
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
||||
|
||||
# Expiration timeline (next 90 days)
|
||||
curl -s "http://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
|
||||
|
||||
# Job trends (last 30 days)
|
||||
curl -s "http://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
|
||||
|
||||
# JSON metrics
|
||||
curl -s http://localhost:8443/api/v1/metrics | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/metrics | jq .
|
||||
|
||||
# Prometheus format (for Prometheus, Grafana Agent, Datadog)
|
||||
curl -s http://localhost:8443/api/v1/metrics/prometheus
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/metrics/prometheus
|
||||
```
|
||||
|
||||
## Create Your First Certificate
|
||||
@@ -226,7 +253,7 @@ curl -s http://localhost:8443/api/v1/metrics/prometheus
|
||||
Create a certificate record that certctl will track, renew, and deploy automatically.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8443/api/v1/certificates \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "My First Certificate",
|
||||
@@ -249,31 +276,34 @@ CERT_ID="<paste the id from the response>"
|
||||
|
||||
Trigger renewal:
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew | jq .
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/$CERT_ID/renew | jq .
|
||||
```
|
||||
|
||||
Check the result:
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/certificates/$CERT_ID | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates/$CERT_ID | jq .
|
||||
```
|
||||
|
||||
Refresh the dashboard at http://localhost:8443 — your new certificate appears in the inventory.
|
||||
Refresh the dashboard at https://localhost:8443 — your new certificate appears in the inventory.
|
||||
|
||||
### Revoke a certificate
|
||||
|
||||
When a private key is compromised or a service is decommissioned:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "superseded"}' | jq .
|
||||
```
|
||||
|
||||
Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`.
|
||||
|
||||
Confirm via CRL:
|
||||
Confirm via the unauthenticated DER CRL (RFC 5280 §5, RFC 8615):
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/crl | jq .
|
||||
# Fetch the CRL without any API key — relying parties shouldn't need one.
|
||||
# The CRL path is unauthenticated, but it's still served over TLS.
|
||||
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
|
||||
openssl crl -inform der -in /tmp/crl.der -noout -text | head -40
|
||||
```
|
||||
|
||||
### Interactive approval workflow
|
||||
@@ -282,15 +312,15 @@ For high-value certificates where you want human oversight. The demo includes 2
|
||||
|
||||
```bash
|
||||
# List jobs awaiting approval (demo includes 2)
|
||||
curl -s "http://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, certificate_id, status}'
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, certificate_id, status}'
|
||||
|
||||
# Approve a pending job
|
||||
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/jobs/JOB_ID/approve \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Approved for production deployment"}' | jq .
|
||||
|
||||
# Reject a pending job
|
||||
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/jobs/JOB_ID/reject \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Key type does not meet compliance requirements"}' | jq .
|
||||
```
|
||||
@@ -316,7 +346,7 @@ export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/var/lib/certs"
|
||||
export CERTCTL_NETWORK_SCAN_ENABLED=true
|
||||
|
||||
# Create a scan target
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Internal Network",
|
||||
@@ -328,20 +358,20 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
}' | jq .
|
||||
|
||||
# Trigger an immediate scan
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-internal-network/scan | jq .
|
||||
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets/nst-internal-network/scan | jq .
|
||||
```
|
||||
|
||||
### Triage discovered certificates
|
||||
|
||||
```bash
|
||||
# List discovered certs
|
||||
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
|
||||
curl --cacert "$CA" -s "https://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
|
||||
|
||||
# Summary counts
|
||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
||||
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovery-summary | jq .
|
||||
|
||||
# Claim a discovered cert (bring under management)
|
||||
curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
|
||||
curl --cacert "$CA" -s -X POST "https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
||||
```
|
||||
@@ -351,8 +381,9 @@ curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_
|
||||
```bash
|
||||
cd cmd/cli && go build -o certctl-cli .
|
||||
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_SERVER_URL="https://localhost:8443"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
export CERTCTL_SERVER_CA_BUNDLE_PATH="$CA" # or pass --ca-bundle; --insecure for dev self-signed
|
||||
|
||||
./certctl-cli certs list # List certificates
|
||||
./certctl-cli certs get mc-api-prod # Certificate details
|
||||
@@ -385,10 +416,10 @@ export CERTCTL_DIGEST_RECIPIENTS=ops@example.com,security@example.com
|
||||
|
||||
Preview the digest HTML before enabling scheduled delivery:
|
||||
```bash
|
||||
curl http://localhost:8443/api/v1/digest/preview | jq '.html' | grep -o '<html>' # Shows HTML is ready
|
||||
curl --cacert "$CA" https://localhost:8443/api/v1/digest/preview | jq '.html' | grep -o '<html>' # Shows HTML is ready
|
||||
|
||||
# Trigger a digest send immediately (outside of schedule)
|
||||
curl -X POST http://localhost:8443/api/v1/digest/send
|
||||
curl --cacert "$CA" -X POST https://localhost:8443/api/v1/digest/send
|
||||
```
|
||||
|
||||
If no recipients are configured (`CERTCTL_DIGEST_RECIPIENTS` empty), the digest falls back to certificate owner emails. Digests include total certificates, expiring soon, expired, active agents, completed/failed jobs (30-day summary), and a table of expiring certs color-coded by urgency (7/14/30 days).
|
||||
@@ -398,13 +429,14 @@ If no recipients are configured (`CERTCTL_DIGEST_RECIPIENTS` empty), the digest
|
||||
```bash
|
||||
cd cmd/mcp-server && go build -o mcp-server .
|
||||
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_SERVER_URL="https://localhost:8443"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
export CERTCTL_SERVER_CA_BUNDLE_PATH="$CA" # MCP is env-vars-only; no CLI flags
|
||||
|
||||
./mcp-server
|
||||
```
|
||||
|
||||
Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail."
|
||||
Exposes the full REST API via MCP over stdio transport. Ask Claude: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail."
|
||||
|
||||
## Demo Data Reference
|
||||
|
||||
@@ -461,7 +493,10 @@ The `-v` flag removes the PostgreSQL data volume for a clean slate.
|
||||
|
||||
## What's Next
|
||||
|
||||
**Ready to deploy with your stack?** The [Deployment Examples](examples.md) page has 5 turnkey docker-compose scenarios — pick the one closest to your setup and have it running in minutes. It also covers migration paths from Certbot, acme.sh, and cert-manager.
|
||||
|
||||
- **[Deployment Examples](examples.md)** — ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer
|
||||
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA end-to-end
|
||||
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
||||
- **[Connector Guide](connectors.md)** — Build custom connectors for your infrastructure
|
||||
- **[Connector Reference](connectors.md)** — Configuration for all 7 issuers and 10 targets
|
||||
- **[Concepts Guide](concepts.md)** — TLS certificates, CAs, and private keys explained from scratch
|
||||
|
||||
+1088
File diff suppressed because it is too large
Load Diff
+5553
-2657
File diff suppressed because it is too large
Load Diff
+183
@@ -0,0 +1,183 @@
|
||||
# TLS on the Control Plane
|
||||
|
||||
certctl's control plane is HTTPS-only as of v2.2. There is no plaintext `http://` listener, no `auto` mode, no dual-listener bridge, no TLS 1.2 escape hatch. The server refuses to start without a cert+key pair, the agent/CLI/MCP clients reject `http://` URLs at startup, and the Helm chart refuses to render without either an operator-supplied Secret or a cert-manager Certificate CR.
|
||||
|
||||
This doc covers four cert provisioning patterns, SIGHUP-based cert rotation, and the client-side CA-trust configuration agents and the CLI need to talk to the server. If you are upgrading from a pre-HTTPS release and want the step-by-step cutover procedure, read [`upgrade-to-tls.md`](upgrade-to-tls.md) first and come back here for reference.
|
||||
|
||||
## What you get
|
||||
|
||||
The server binds TLS 1.3 only with an explicit curve preference of `[X25519, P-256]`. TLS 1.3 cipher suites are non-negotiable (all three mandatory suites — AES-128-GCM-SHA256, AES-256-GCM-SHA384, CHACHA20-POLY1305-SHA256 — are always offered), so there is no `CipherSuites` knob to misconfigure. No TLS 1.2 fallback is available.
|
||||
|
||||
Two env vars are required on the server:
|
||||
|
||||
- `CERTCTL_SERVER_TLS_CERT_PATH` — filesystem path to the PEM-encoded server certificate
|
||||
- `CERTCTL_SERVER_TLS_KEY_PATH` — filesystem path to the PEM-encoded private key that signs the cert
|
||||
|
||||
Both paths are read during a fail-loud preflight in `cmd/server/main.go` (see `preflightServerTLS` in `cmd/server/tls.go`). If either is unset, unreadable, or the cert+key pair does not round-trip through `tls.LoadX509KeyPair`, the process refuses to start and emits a diagnostic pointing back at this doc. The rationale lives in §3 of the HTTPS-Everywhere milestone: a cert-lifecycle product should not silently bind plaintext.
|
||||
|
||||
## Pattern 1 — Self-signed bootstrap for docker-compose demos
|
||||
|
||||
This is the default for the `deploy/docker-compose.yml` stack. It exists so `docker compose up -d --build` just works on a laptop without the operator standing up a CA first. It is not appropriate for any non-demo environment.
|
||||
|
||||
An init container named `certctl-tls-init` runs once before the server starts. It uses the `alpine/openssl` image and generates an ECDSA-P256 self-signed cert (SHA-256 signature):
|
||||
|
||||
```
|
||||
openssl req -x509 -newkey ec \
|
||||
-pkeyopt ec_paramgen_curve:P-256 \
|
||||
-nodes \
|
||||
-keyout /etc/certctl/tls/server.key \
|
||||
-out /etc/certctl/tls/server.crt \
|
||||
-days 3650 \
|
||||
-subj "/CN=certctl-server" \
|
||||
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
```
|
||||
|
||||
**Why ECDSA-P256 and not ed25519.** The pre-v2.0.48 demo bootstrap used ed25519 (small keys, fast signatures). Apple's TLS stack — Safari Network Framework and the macOS-bundled LibreSSL 3.3.6 `/usr/bin/curl` — does not advertise ed25519 in the ClientHello `signature_algorithms` extension for server certs, so an ed25519 server cert was rejected at handshake with `tls: peer doesn't support any of the certificate's signature algorithms` on the server side (and the generic TLS handshake error on the client side). Homebrew OpenSSL 3.x, Chrome, Firefox, and Linux curl all accepted ed25519 — Apple was the outlier. ECDSA-P256 with SHA-256 is universally supported, so the demo bootstrap uses it by default. To pick up the new algorithm on an existing demo install, tear the volume down and rebuild: `docker compose -f deploy/docker-compose.yml down -v && docker compose -f deploy/docker-compose.yml up -d --build`. **Helm and operator-supplied-Secret users (Patterns 2 and 3) are unaffected** — they bring their own cert, and `cmd/server/tls.go` is algorithm-agnostic (TLS 1.3 with curve preference `[X25519, P-256]` for key exchange — no constraint on the server cert's signature algorithm).
|
||||
|
||||
The cert, its matching key, and a copy of the cert published as `ca.crt` land in a named volume (`certs`) mounted at `/etc/certctl/tls/` in the server container (read-only) and the agent container (read-only). The bootstrap is idempotent — if `server.crt`, `server.key`, and `ca.crt` are already present on the volume, the init container logs `TLS cert already present at …` and exits cleanly.
|
||||
|
||||
Single-cert design. CN is `certctl-server` to match the Docker-network hostname. The SAN list is `[certctl-server, localhost, 127.0.0.1, ::1]`, which covers both container-internal agent→server traffic and operator browser/curl access to `https://localhost:8443`. There is no separate intermediate/root chain — the server cert and the CA bundle are the same PEM. This is the whole point of a demo bootstrap.
|
||||
|
||||
To force regeneration (rotate the demo cert), tear the volume down: `docker compose down -v`. The next `up` re-runs the init container.
|
||||
|
||||
The server's Docker healthcheck and the agent both verify against `/etc/certctl/tls/ca.crt`; no `-k` / `InsecureSkipVerify` anywhere in the default stack.
|
||||
|
||||
## Pattern 2 — Operator-supplied `kubernetes.io/tls` Secret (Helm)
|
||||
|
||||
This is the default path for Helm installs. The operator provisions a Secret of type `kubernetes.io/tls` holding `tls.crt` + `tls.key` (and optionally `ca.crt` for mounting a CA bundle to clients in the same cluster) from whatever source they already trust — their internal CA, a manually-issued cert, step-ca, AWS ACM PCA exported to PEM, or the output of the self-signed bootstrap pattern above copied into a cluster Secret.
|
||||
|
||||
```
|
||||
kubectl create secret tls certctl-server-tls \
|
||||
--cert=server.crt \
|
||||
--key=server.key \
|
||||
--namespace certctl
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
helm install certctl deploy/helm/certctl \
|
||||
--namespace certctl \
|
||||
--set server.tls.existingSecret=certctl-server-tls
|
||||
```
|
||||
|
||||
The Secret is mounted read-only at `/etc/certctl/tls/` in the server pod. The `CERTCTL_SERVER_TLS_CERT_PATH` and `CERTCTL_SERVER_TLS_KEY_PATH` env vars are wired to `tls.crt` and `tls.key` keys inside that mount. If `ca.crt` is absent from the Secret, clients that need a CA bundle should use `tls.crt` as the bundle (self-signed case) or mount a separate ConfigMap with the root chain (operator-CA case).
|
||||
|
||||
If the operator sets neither `server.tls.existingSecret` nor `server.tls.certManager.enabled=true`, `helm template` / `helm install` fails at render-time with a diagnostic pointing at this doc. The guard is implemented in `deploy/helm/certctl/templates/_helpers.tpl` under the `certctl.tls.required` helper. This is deliberate: the HTTPS-only server would crash-loop on an empty path, so we fail earlier at Helm-render time.
|
||||
|
||||
## Pattern 3 — cert-manager `Certificate` CR (Helm, opt-in)
|
||||
|
||||
For clusters that already run cert-manager, the chart can provision a `Certificate` CR that writes into the Secret the server pod reads from. This is opt-in — the default is `server.tls.certManager.enabled: false` — because not every cluster has cert-manager installed, and we refuse to ship a chart that silently depends on an external controller.
|
||||
|
||||
```
|
||||
helm install certctl deploy/helm/certctl \
|
||||
--namespace certctl \
|
||||
--set server.tls.certManager.enabled=true \
|
||||
--set server.tls.certManager.issuerRef.name=my-cluster-issuer \
|
||||
--set server.tls.certManager.issuerRef.kind=ClusterIssuer
|
||||
```
|
||||
|
||||
The rendered `Certificate` (see `deploy/helm/certctl/templates/server-certificate.yaml`) writes `tls.crt` + `tls.key` + `ca.crt` into the Secret named by `server.tls.certManager.secretName` (defaults to `<fullname>-tls`). The server pod reads from that same Secret; the agent DaemonSet mounts the same Secret as its CA bundle source.
|
||||
|
||||
cert-manager handles rotation. certctl-server handles in-place reload — see the SIGHUP section below.
|
||||
|
||||
The chart enforces that if `server.tls.certManager.enabled=true`, `server.tls.certManager.issuerRef.name` must also be set. An empty `issuerRef.name` makes `helm template` fail with a diagnostic naming the missing flag.
|
||||
|
||||
## Pattern 4 — Manually-issued from an internal CA
|
||||
|
||||
For operators running neither Helm nor docker-compose (bare-metal / custom orchestration), the server just needs two files on disk pointed at by `CERTCTL_SERVER_TLS_CERT_PATH` and `CERTCTL_SERVER_TLS_KEY_PATH`. Issue the cert from your internal CA with:
|
||||
|
||||
- CN matching the hostname your agents and operators use to dial the server (e.g., `certctl.prod.example.com`)
|
||||
- SAN list covering every hostname and IP that appears in `CERTCTL_SERVER_URL` values across your agent fleet
|
||||
- Key usage: digital signature + key encipherment
|
||||
- Extended key usage: server auth
|
||||
|
||||
Store the key with mode `0600` and owner matching the UID the server runs as (`1000` in our shipped Dockerfile). The server process reads both files during `preflightServerTLS` at startup and again on every SIGHUP.
|
||||
|
||||
The full CA chain that signed the server cert should be distributed to agents, CLI operators, and MCP clients as their `CERTCTL_SERVER_CA_BUNDLE_PATH` — see the client section below.
|
||||
|
||||
## SIGHUP cert rotation
|
||||
|
||||
The server wraps its cert+key pair in a `*certHolder` (see `cmd/server/tls.go`) that guards the loaded `*tls.Certificate` under a `sync.Mutex`. The `*tls.Config` wires `GetCertificate` to the holder, so every new inbound TLS handshake reads whatever cert the holder currently has.
|
||||
|
||||
Send `SIGHUP` to the server PID and the holder re-reads both files from disk. On success, the next new connection uses the new cert; in-flight requests finish on the previous cert. A log line goes out:
|
||||
|
||||
```
|
||||
TLS cert reloaded via SIGHUP cert_path=/etc/certctl/tls/server.crt key_path=/etc/certctl/tls/server.key
|
||||
```
|
||||
|
||||
On failure (missing file, malformed PEM, key does not sign cert), the old cert is retained and an error logs:
|
||||
|
||||
```
|
||||
TLS cert reload failed; continuing with previous cert cert_path=… key_path=… error=…
|
||||
```
|
||||
|
||||
This is deliberately fail-safe on reload (as opposed to fail-loud on startup). A cert-manager renewal race, a partially-copied file, a typo in a rotation script — none of those should crash a running server and drop every agent connection. The operator sees the error in logs, fixes the underlying issue, and sends another `SIGHUP`.
|
||||
|
||||
Pair with cert-manager, certbot `--post-hook`, or any rotation tool that can fire a signal. For docker-compose, `docker compose kill -s HUP certctl-server` works. For Kubernetes, reload is typically handled by cert-manager updating the Secret and the mounted file changing on the next kubelet sync — no explicit SIGHUP needed if the volume mount is `subPath`-free.
|
||||
|
||||
Startup is a different story. If the cert is missing or malformed at process start, the server exits non-zero rather than binding plaintext or attempting a retry loop. That's the HTTPS-only contract.
|
||||
|
||||
## Client-side TLS: agents, CLI, MCP
|
||||
|
||||
Everything that talks to the server enforces HTTPS on the URL.
|
||||
|
||||
### Agent
|
||||
|
||||
`CERTCTL_SERVER_URL` must be `https://…`. `http://`, bare hostnames, `ftp://`, `ws://`, and empty strings are rejected at startup by `validateHTTPSScheme` in `cmd/agent/main.go` with a diagnostic pointing at `upgrade-to-tls.md`. There is no warning-and-proceed path.
|
||||
|
||||
Two additional env vars control how the agent verifies the server cert:
|
||||
|
||||
- `CERTCTL_SERVER_CA_BUNDLE_PATH` — filesystem path to a PEM-encoded CA bundle that signed the server cert. Loaded into `*tls.Config.RootCAs` on the agent's HTTP client. If unset, the agent falls back to the OS system trust store.
|
||||
- `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` — defaults to `false`. Setting it to `true` skips verification entirely. **Dev-only escape hatch.** The agent logs a prominent warning at startup (`TLS certificate verification is disabled … never enable this in production`). Use this only when dialing a demo server whose cert you haven't bothered to mount into the agent container.
|
||||
|
||||
Equivalent CLI flags: `--ca-bundle <path>` and `--insecure-skip-verify`.
|
||||
|
||||
If both the CA bundle and `InsecureSkipVerify=true` are set, `InsecureSkipVerify` wins — it's the whole point of the flag. Don't do this in production.
|
||||
|
||||
### CLI (`certctl-cli`)
|
||||
|
||||
Same contract as the agent:
|
||||
|
||||
- `CERTCTL_SERVER_URL` defaults to `https://` scheme; `http://` rejected at startup
|
||||
- `--ca-bundle <path>` flag or `CERTCTL_SERVER_CA_BUNDLE_PATH` env var — CA bundle for server cert verification
|
||||
- `--insecure` flag or `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true` — skip verification (dev only)
|
||||
- Error diagnostic on empty URL explicitly mentions both `--server` and `CERTCTL_SERVER_URL` so operators see the right knob to turn
|
||||
|
||||
The CLI shares the URL-scheme validation with the agent; the test pins in `cmd/cli/main_test.go:TestValidateHTTPSScheme` cover the full rejection matrix.
|
||||
|
||||
### MCP server (`certctl-mcp-server`)
|
||||
|
||||
Same three controls as CLI, env-var-driven only (no flags — MCP runs as a stdio subprocess and inherits env from the launching LLM client):
|
||||
|
||||
- `CERTCTL_SERVER_URL` must start with `https://`
|
||||
- `CERTCTL_SERVER_CA_BUNDLE_PATH` optional CA bundle
|
||||
- `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` optional skip
|
||||
|
||||
Claude Desktop / other MCP client configs should set all three in the tool's env block.
|
||||
|
||||
## Troubleshooting: fail-loud preflight errors
|
||||
|
||||
Every preflight failure message ends with `(see docs/tls.md)` so this doc is the first hit when an operator searches. Common failures:
|
||||
|
||||
**`CERTCTL_SERVER_TLS_CERT_PATH is empty: HTTPS-only control plane refuses to start`**
|
||||
Set the env var. For docker-compose this is already set to `/etc/certctl/tls/server.crt` in the shipped compose file — if you're seeing this, check the `certctl-tls-init` service logs to see why the init container didn't populate the volume. For Helm, check that `server.tls.existingSecret` or `server.tls.certManager.enabled=true` is set.
|
||||
|
||||
**`TLS cert file "…" unreadable: …`**
|
||||
The cert path is set but `os.Stat` failed. Check filesystem permissions — the server runs as UID 1000 in our shipped Dockerfile; the cert needs to be readable by that UID. Typos in the path also land here.
|
||||
|
||||
**`TLS cert/key pair invalid (cert="…" key="…"): …`**
|
||||
Both files exist but `tls.LoadX509KeyPair` refused them. Typical causes: the private key does not sign the certificate, the key is encrypted with a passphrase (not supported — remove the passphrase with `openssl pkey` before mounting), or one of the two is DER-encoded instead of PEM. Re-issue the pair from the same CA call and re-mount.
|
||||
|
||||
**Client side: `tls: failed to verify certificate: x509: certificate signed by unknown authority`**
|
||||
The client did not trust the CA that signed the server cert. Either mount the CA bundle via `CERTCTL_SERVER_CA_BUNDLE_PATH`, add the CA to the system trust store on the client host, or (dev only) set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true`.
|
||||
|
||||
**Client side: `tls: first record does not look like a TLS handshake`**
|
||||
The client is speaking plaintext HTTP to an HTTPS server (or vice-versa). Check that `CERTCTL_SERVER_URL` starts with `https://`. If you are upgrading from a pre-v2.2 release and your agents are old, they will surface this error until you roll the DaemonSet — see [`upgrade-to-tls.md`](upgrade-to-tls.md).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [`upgrade-to-tls.md`](upgrade-to-tls.md) — one-step cutover from pre-HTTPS releases
|
||||
- [`quickstart.md`](quickstart.md) — docker-compose walkthrough with HTTPS examples
|
||||
- [`test-env.md`](test-env.md) — integration test environment (also HTTPS-only)
|
||||
- Milestone spec: `prompts/https-everywhere-milestone.md` (authoritative source for locked decisions)
|
||||
@@ -0,0 +1,194 @@
|
||||
# Upgrading to HTTPS-Everywhere (v2.2)
|
||||
|
||||
certctl's control plane is HTTPS-only as of v2.2. There is no `http` mode, no `auto` mode, no dual-listener bind, no N-release migration window. The cutover is a single step. Out-of-date agents that still point at `http://…` fail at the TCP/TLS handshake layer on first connect after the upgrade and stay `Offline` in the dashboard until their env block is updated and the fleet is rolled.
|
||||
|
||||
This doc walks operators through the cutover for the two shipped deployment topologies — docker-compose and Helm — and documents the failure modes and rollback posture explicitly.
|
||||
|
||||
For the deep-dive on cert provisioning patterns, SIGHUP cert reload, and client-side CA-trust configuration, read [`tls.md`](tls.md). This doc is the narrow "how do I upgrade" procedure.
|
||||
|
||||
## Preconditions
|
||||
|
||||
Before you start, confirm:
|
||||
|
||||
- **Shell access** to the server host and every agent host. The cutover requires you to restart the server and update every agent's env block.
|
||||
- **A cert+key source** for the server. Pick one:
|
||||
- An internal CA that can issue a server cert (CN + SAN list covering every hostname / IP agents dial).
|
||||
- A `cert-manager` install in the target Kubernetes cluster, plus a `ClusterIssuer` or `Issuer` you're willing to reference.
|
||||
- Willingness to use the self-signed bootstrap that the shipped `deploy/docker-compose.yml` generates automatically. This is the right choice for dev and demo; it is the wrong choice for production.
|
||||
- **A maintenance window.** Out-of-date agents break at the TLS handshake and stay offline until rolled. Schedule the upgrade so the agent fleet can be updated in the same window as the server.
|
||||
- **Backups.** This is a one-way door (see the Rollback section below). Snapshot your PostgreSQL database before `docker compose down` or `helm upgrade`.
|
||||
|
||||
There is no schema migration tied to this release; the only at-rest state that changes is the `certs` named volume (docker-compose) or the `tls.crt`/`tls.key` Secret (Helm).
|
||||
|
||||
## Procedure — docker-compose operators
|
||||
|
||||
The shipped `deploy/docker-compose.yml` includes a `certctl-tls-init` init container that self-signs an ECDSA-P256 (SHA-256 signature) cert on first boot and drops `server.crt`, `server.key`, and `ca.crt` into a named volume mounted read-only at `/etc/certctl/tls/` on the server and agent containers. No manual cert provisioning is required for the default stack. (Pre-v2.0.48 this was an ed25519 cert; see [`tls.md`](tls.md) Pattern 1 for the rationale and the `down -v && up --build` migration note.)
|
||||
|
||||
1. **Pull the HTTPS-everywhere release.** From the repo root:
|
||||
|
||||
```
|
||||
git pull
|
||||
```
|
||||
|
||||
Confirm you're on a tag or `master` that contains the `certctl-tls-init` service in `deploy/docker-compose.yml`. Grep for it: `grep certctl-tls-init deploy/docker-compose.yml` should hit.
|
||||
|
||||
2. **Stop the old plaintext cluster.**
|
||||
|
||||
```
|
||||
docker compose -f deploy/docker-compose.yml down
|
||||
```
|
||||
|
||||
Do not pass `-v`; keeping the PostgreSQL volume preserves your cert inventory, audit trail, and job history across the upgrade.
|
||||
|
||||
3. **Bring the cluster back up with the HTTPS build.**
|
||||
|
||||
```
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
The `certctl-tls-init` service runs once, generates the self-signed cert into the `certs` volume, and exits with code 0. The server container waits for `certctl-tls-init` via `depends_on: { condition: service_completed_successfully }` and only starts once the cert material is on disk. The server's Docker healthcheck now uses `curl --cacert /etc/certctl/tls/ca.crt -f https://localhost:8443/health`, so the container only becomes healthy once the HTTPS listener is up and serving the bundled cert correctly.
|
||||
|
||||
4. **Verify the HTTPS endpoint from the host.**
|
||||
|
||||
```
|
||||
curl --cacert $(docker compose -f deploy/docker-compose.yml exec -T certctl-server cat /etc/certctl/tls/ca.crt) https://localhost:8443/health
|
||||
```
|
||||
|
||||
Expect `{"status":"ok"}` with HTTP 200. If you get a TLS verification error, the CA bundle wasn't read correctly — re-run the `exec -T` command and pipe the output directly into `--cacert @-` or save it to a local file first. If you get `connection refused`, the server never finished startup — check `docker compose logs certctl-server` for a fail-loud preflight diagnostic pointing at `docs/tls.md`.
|
||||
|
||||
5. **Confirm the bundled agent reconnects.** Agents inside the compose stack pick up the new URL (`CERTCTL_SERVER_URL=https://certctl-server:8443`) and the bundled CA (`CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt`) from their env block automatically — no per-agent change needed. Tail the agent log:
|
||||
|
||||
```
|
||||
docker compose -f deploy/docker-compose.yml logs -f certctl-agent
|
||||
```
|
||||
|
||||
You should see `heartbeat sent` within 30 seconds. In the dashboard (`https://localhost:8443`), the agent should show as `Online`.
|
||||
|
||||
**External agents** running outside the compose network (e.g., the `install-agent.sh`-installed systemd service on a separate host) need their env block updated manually before the cutover — see the Agent env block section below.
|
||||
|
||||
## Procedure — Helm operators
|
||||
|
||||
The Helm chart does not self-sign. It refuses to render (`helm template` exits non-zero) unless you configure one of two cert sources: an operator-supplied Secret, or a cert-manager `Certificate` CR. See [`tls.md`](tls.md) for the full pattern catalog.
|
||||
|
||||
1. **Provision cert material.** Pick one of:
|
||||
|
||||
- **Operator-supplied Secret.** Issue a cert from your internal CA (or any other source) and load it into a `kubernetes.io/tls` Secret in the certctl namespace:
|
||||
|
||||
```
|
||||
kubectl create secret tls certctl-server-tls \
|
||||
--cert=server.crt --key=server.key \
|
||||
--namespace certctl
|
||||
```
|
||||
|
||||
- **cert-manager.** Set `server.tls.certManager.enabled=true` on the upgrade and reference an existing `ClusterIssuer` or `Issuer`:
|
||||
|
||||
```
|
||||
--set server.tls.certManager.enabled=true
|
||||
--set server.tls.certManager.issuerRef.name=my-cluster-issuer
|
||||
--set server.tls.certManager.issuerRef.kind=ClusterIssuer
|
||||
```
|
||||
|
||||
2. **Upgrade the release.**
|
||||
|
||||
```
|
||||
helm upgrade certctl deploy/helm/certctl \
|
||||
--namespace certctl \
|
||||
--set server.tls.existingSecret=certctl-server-tls
|
||||
```
|
||||
|
||||
(Or the `certManager` variant.) If you omit both `server.tls.existingSecret` and `server.tls.certManager.enabled`, the chart fails at render time with a diagnostic pointing at `docs/tls.md`. That guard exists precisely so you catch the missing config at `helm upgrade` time, not at pod-crash-loop time.
|
||||
|
||||
3. **Verify the HTTPS endpoint from inside the cluster.** Port-forward and curl with the CA bundle:
|
||||
|
||||
```
|
||||
kubectl port-forward -n certctl svc/certctl-server 8443:8443 &
|
||||
kubectl get secret -n certctl certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
|
||||
curl --cacert /tmp/certctl-ca.crt https://localhost:8443/health
|
||||
```
|
||||
|
||||
Expect `{"status":"ok"}`. If the Secret does not contain a `ca.crt` key (operator-supplied Secrets often don't), use `tls.crt` as the bundle instead — for a self-signed cert the two files are identical, and for a cert chained to an internal CA you should separately distribute the root CA bundle via ConfigMap or mounted file.
|
||||
|
||||
4. **Update every agent manifest.** Agents outside this Helm release (or in a separately-managed DaemonSet) need their env block updated:
|
||||
|
||||
```
|
||||
- name: CERTCTL_SERVER_URL
|
||||
value: "https://certctl-server.certctl.svc.cluster.local:8443"
|
||||
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
|
||||
value: "/etc/certctl/tls/ca.crt"
|
||||
```
|
||||
|
||||
Mount the server's Secret (or a separate CA-bundle Secret / ConfigMap) at `/etc/certctl/tls/` as a read-only volume. If you bundle the agent via the shipped Helm chart's DaemonSet, the wiring is already done — set `agent.enabled=true` and the chart mounts the same Secret.
|
||||
|
||||
5. **Roll the agent DaemonSet.**
|
||||
|
||||
```
|
||||
kubectl rollout restart ds/certctl-agent -n certctl
|
||||
kubectl rollout status ds/certctl-agent -n certctl
|
||||
```
|
||||
|
||||
Every agent pod restarts with the new URL + CA bundle and reconnects on HTTPS. The dashboard shows agents flip from `Offline` to `Online` as pods finish rolling.
|
||||
|
||||
## Agent env block — external hosts
|
||||
|
||||
Agents installed on bare-metal or VM hosts via `install-agent.sh` (systemd on Linux, launchd on macOS) read config from `/etc/certctl/agent.env` (Linux) or `~/Library/Application Support/certctl/agent.env` (macOS). On cutover, append or update:
|
||||
|
||||
```
|
||||
CERTCTL_SERVER_URL=https://certctl.example.com:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt
|
||||
# CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=false # Dev only. Never set to true in production.
|
||||
```
|
||||
|
||||
Distribute the CA bundle (the same `ca.crt` the server holds, or the root chain if you issued the server cert from an intermediate) to every agent host. The path under `CERTCTL_SERVER_CA_BUNDLE_PATH` must be readable by the UID the agent service runs as.
|
||||
|
||||
Restart the service after editing:
|
||||
|
||||
- Linux: `systemctl restart certctl-agent`
|
||||
- macOS: `launchctl kickstart -k system/com.certctl.agent`
|
||||
|
||||
The agent refuses to start on an `http://` URL and exits with a pre-flight diagnostic that names this doc. That rejection happens before any network call — no spurious half-connected state.
|
||||
|
||||
## Failure mode
|
||||
|
||||
Out-of-date agents still configured with `CERTCTL_SERVER_URL=http://…` fail on first reconnect after the cutover. The failure surfaces as one of:
|
||||
|
||||
- `dial tcp …: connect: connection refused` — the server is no longer listening on a plaintext port. The new release binds only a TLS listener; attempting a plaintext `connect()` gets refused at the kernel level because nothing holds the socket.
|
||||
- `tls: first record does not look like a TLS handshake` — depending on timing and proxy layers (e.g., a load balancer that accepts the TCP connection before forwarding), the client may negotiate TCP, send an HTTP request line, and have the server's TLS stack reject it.
|
||||
|
||||
Agents in this state surface as `Offline` in the dashboard. They stay offline until their env block is updated and the service restarts. There is no graceful 400-with-migration-URL response because there is no HTTP listener to serve one from — the entire plaintext call path is removed by design.
|
||||
|
||||
If you see an unexpected agent stay `Offline` past the cutover window, SSH to the host and check the agent log. On a systemd host:
|
||||
|
||||
```
|
||||
journalctl -u certctl-agent -n 100
|
||||
```
|
||||
|
||||
Look for `URL scheme "http" is not supported: HTTPS-only control plane refuses to start (see docs/upgrade-to-tls.md)`. That's the pre-flight rejection. Update `CERTCTL_SERVER_URL`, restart the service, and the agent reconnects.
|
||||
|
||||
## Rollback
|
||||
|
||||
**There is no rollback window.** The upgrade is a one-way door. The rationale lives in §3.7 of `prompts/https-everywhere-milestone.md`: a cert-lifecycle product that bridges back to plaintext after committing to HTTPS is advertising that its own security posture is negotiable.
|
||||
|
||||
If you need to revert, you have two options:
|
||||
|
||||
1. **Stay on the pre-HTTPS release.** Do not upgrade until you are ready to run HTTPS on the control plane. Pin your `docker-compose.yml` or `helm upgrade` command to the last pre-v2.2 tag.
|
||||
2. **Rollback the release.** `helm rollback certctl <previous-revision>` or `git checkout <previous-tag> && docker compose up -d --build`. This rolls back the server, the compose topology, and the Helm chart in lockstep. Your PostgreSQL volume — cert inventory, audit trail, jobs — survives the rollback; nothing in this milestone changes the database schema.
|
||||
|
||||
Option 2 drops you back to the plaintext world. It should be treated as an emergency measure, not a supported migration path.
|
||||
|
||||
## After the cutover
|
||||
|
||||
Once every agent is `Online`, confirm a few invariants:
|
||||
|
||||
- `curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8443/health` returns `000` with `Connection refused` (no HTTP listener). Plaintext is gone.
|
||||
- `openssl s_client -connect localhost:8443 -tls1_2 </dev/null` fails the handshake. TLS 1.2 is rejected.
|
||||
- `openssl s_client -connect localhost:8443 -tls1_3 </dev/null` succeeds and prints the server's SAN list. TLS 1.3 is live.
|
||||
- A cert rotation test: overwrite the server cert on disk, `kill -HUP` the server PID, confirm the new cert serves on the next `openssl s_client -connect … -showcerts` without a process restart. See the SIGHUP section in [`tls.md`](tls.md).
|
||||
|
||||
Update your runbooks. Every `http://certctl.example.com` URL in internal documentation, monitoring config, and on-call playbooks should become `https://certctl.example.com` plus a CA-trust note.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [`tls.md`](tls.md) — cert provisioning patterns, SIGHUP rotation, troubleshooting
|
||||
- [`quickstart.md`](quickstart.md) — docker-compose walkthrough (post-HTTPS)
|
||||
- [`test-env.md`](test-env.md) — integration test environment (HTTPS-only)
|
||||
- Milestone spec: `prompts/https-everywhere-milestone.md`
|
||||
@@ -0,0 +1,155 @@
|
||||
# Upgrading past G-1 — `CERTCTL_AUTH_TYPE=jwt` removal
|
||||
|
||||
If your certctl deployment currently sets `CERTCTL_AUTH_TYPE=jwt` (or `server.auth.type=jwt` in Helm), the next certctl upgrade will fail-fast at startup with a dedicated diagnostic. This guide explains why, what to switch to, and how to keep JWT/OIDC at your edge.
|
||||
|
||||
For everyone else — operators running `api-key` or `none` — this upgrade is a no-op. Skip to [`upgrade-to-tls.md`](upgrade-to-tls.md) for the v2.2 HTTPS-everywhere migration if you haven't done that one yet.
|
||||
|
||||
## Why we removed it
|
||||
|
||||
Pre-G-1, the config validator at `internal/config/config.go` accepted three values for `CERTCTL_AUTH_TYPE`: `api-key`, `jwt`, and `none`. The startup log line at `cmd/server/main.go` faithfully echoed `"authentication enabled" "type"="jwt"` when an operator picked `jwt`. Reasonable people read that and concluded JWT auth was on.
|
||||
|
||||
It wasn't. Grep `internal/ cmd/` for `NewJWT`, `JWTMiddleware`, or `jwt.Parse` — pre-G-1, there were zero matches in production code. The auth-middleware wiring at `cmd/server/main.go:653` unconditionally called `middleware.NewAuthWithNamedKeys(namedKeys)` regardless of `cfg.Auth.Type`. So `CERTCTL_AUTH_TYPE=jwt` just routed every request through the api-key bearer middleware, comparing the incoming `Authorization: Bearer <something>` against whatever string the operator put in `CERTCTL_AUTH_SECRET`. Real JWT clients got 401 (the api-key middleware saw the JWT string as a literal token and compared bytes). Operators who treated `CERTCTL_AUTH_SECRET` as a JWT signing secret (and therefore handled it less carefully than an api-key) handed an attacker an api-key. Silent auth downgrade — a security finding masquerading as a config option.
|
||||
|
||||
We chose to remove the option rather than implement JWT middleware. Implementing real JWT/OIDC requires jwks vs static-secret rotation, claim mapping (which claim is the actor / the admin flag?), expiry enforcement, audience and issuer validation, key rollover semantics, and regression coverage at the same depth as the existing api-key path. That's a feature, not a fix. The audit-recommended structural fix — and the one that actually closes the hazard — is to fail loudly instead of silently downgrading.
|
||||
|
||||
## What changes at startup
|
||||
|
||||
Post-G-1, a binary started with `CERTCTL_AUTH_TYPE=jwt` exits non-zero before opening the listener:
|
||||
|
||||
```
|
||||
Failed to load configuration: CERTCTL_AUTH_TYPE=jwt is no longer accepted
|
||||
(G-1 silent auth downgrade): no JWT middleware ships with certctl. To use
|
||||
JWT/OIDC, run an authenticating gateway (oauth2-proxy / Envoy ext_authz /
|
||||
Traefik ForwardAuth / Pomerium) in front of certctl and set
|
||||
CERTCTL_AUTH_TYPE=none on the upstream. See docs/architecture.md
|
||||
"Authenticating-gateway pattern" and docs/upgrade-to-v2-jwt-removal.md
|
||||
for the migration walkthrough
|
||||
```
|
||||
|
||||
Helm operators get the same shape at `helm install` / `helm upgrade` template time: `server.auth.type=jwt` is rejected by the chart's `certctl.validateAuthType` template helper before any Kubernetes object is rendered.
|
||||
|
||||
The CI-side regression guard at `.github/workflows/ci.yml` blocks any future PR that re-introduces `"jwt"` as an auth-type literal in production code or spec.
|
||||
|
||||
## Recovery — pick one
|
||||
|
||||
### Option A — switch to `api-key` (you weren't actually using JWT)
|
||||
|
||||
If your `CERTCTL_AUTH_SECRET` was a single high-entropy token and your clients sent it as `Authorization: Bearer <token>`, you were already using api-key auth — you just had `CERTCTL_AUTH_TYPE` set to the wrong string. Flip it:
|
||||
|
||||
```
|
||||
# .env (docker-compose)
|
||||
CERTCTL_AUTH_TYPE=api-key
|
||||
CERTCTL_AUTH_SECRET=<your-existing-token>
|
||||
```
|
||||
|
||||
```
|
||||
# Helm
|
||||
helm upgrade <release> deploy/helm/certctl/ \
|
||||
--reuse-values \
|
||||
--set server.auth.type=api-key \
|
||||
--set server.auth.apiKey=<your-existing-token>
|
||||
```
|
||||
|
||||
No client changes needed — the same Bearer token continues to work. The startup log will now read `"authentication enabled" "type"="api-key"`, which matches what was actually happening pre-G-1.
|
||||
|
||||
### Option B — front certctl with an authenticating gateway
|
||||
|
||||
If you genuinely need JWT, OIDC, mTLS, or SAML, run an authenticating gateway in front of certctl and let the gateway terminate the federated identity protocol. Configure certctl for `CERTCTL_AUTH_TYPE=none`:
|
||||
|
||||
```
|
||||
CERTCTL_AUTH_TYPE=none
|
||||
```
|
||||
|
||||
Then put an oauth2-proxy / Envoy `ext_authz` / Traefik `ForwardAuth` / Pomerium / Authelia (etc.) in the network path between operators and certctl. The gateway validates the identity and proxies the authenticated request to certctl as a same-origin call on a private network.
|
||||
|
||||
### Concrete walkthrough — oauth2-proxy + certctl on docker-compose
|
||||
|
||||
This is the simplest production-grade JWT/OIDC shape. It assumes you have an OIDC provider (Okta, Auth0, Google Workspace, Keycloak, Dex) and a registered client_id / client_secret.
|
||||
|
||||
```yaml
|
||||
# deploy/docker-compose.gateway.yml — overlay on the base compose file
|
||||
services:
|
||||
oauth2-proxy:
|
||||
image: quay.io/oauth2-proxy/oauth2-proxy:latest
|
||||
command:
|
||||
- --provider=oidc
|
||||
- --oidc-issuer-url=https://<your-issuer>/
|
||||
- --client-id=${OIDC_CLIENT_ID}
|
||||
- --client-secret=${OIDC_CLIENT_SECRET}
|
||||
- --cookie-secret=${OAUTH2_PROXY_COOKIE_SECRET} # openssl rand -base64 32
|
||||
- --upstream=http://certctl-server:8443 # internal-network only; certctl listens on 8443
|
||||
- --http-address=0.0.0.0:4180
|
||||
- --email-domain=*
|
||||
- --pass-access-token=true
|
||||
- --pass-authorization-header=true
|
||||
- --set-authorization-header=true # forwards a bearer token upstream
|
||||
- --skip-provider-button=true
|
||||
- --reverse-proxy=true
|
||||
ports:
|
||||
- "443:4180"
|
||||
depends_on:
|
||||
- certctl-server
|
||||
networks:
|
||||
- certctl-network
|
||||
|
||||
certctl-server:
|
||||
environment:
|
||||
CERTCTL_AUTH_TYPE: none # gateway terminates auth — see docs/upgrade-to-v2-jwt-removal.md
|
||||
# ... rest of the certctl env block unchanged
|
||||
```
|
||||
|
||||
Operators hit `https://<your-host>/`, get redirected through the OIDC provider, land back at oauth2-proxy with a session cookie, and oauth2-proxy proxies their request to certctl on the internal Docker network. certctl itself is HTTPS-only on `:8443` (TLS 1.3, see [`tls.md`](tls.md)) but operator browsers never see that hop directly. Bind certctl-server's `:8443` to the internal Docker network only — do NOT publish it to the host. The audit trail will record the actor as the gateway-forwarded identity if you also configure a small bearer-token-mapping shim at the gateway (most production deployments do this with a per-user api-key issued by the gateway after OIDC validation).
|
||||
|
||||
### Traefik ForwardAuth pattern (Kubernetes)
|
||||
|
||||
Same shape, kubernetes-flavored:
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: oidc-forward-auth
|
||||
spec:
|
||||
forwardAuth:
|
||||
address: http://oauth2-proxy.auth.svc.cluster.local:4180
|
||||
trustForwardHeader: true
|
||||
authResponseHeaders:
|
||||
- X-Auth-Request-User
|
||||
- X-Auth-Request-Email
|
||||
- Authorization
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: certctl
|
||||
spec:
|
||||
routes:
|
||||
- match: Host(`certctl.example.com`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: oidc-forward-auth
|
||||
services:
|
||||
- name: certctl-server
|
||||
port: 8443
|
||||
```
|
||||
|
||||
The certctl Helm release runs with `server.auth.type=none`. The Traefik IngressRoute attaches `oidc-forward-auth` as a middleware so every request is OIDC-validated by oauth2-proxy before reaching certctl.
|
||||
|
||||
### Envoy `ext_authz` pattern
|
||||
|
||||
For service-mesh deployments (Istio, Consul, plain Envoy), the `ext_authz` filter calls out to an external authorization service per-request. Same outcome: certctl runs `CERTCTL_AUTH_TYPE=none` and Envoy + your authz service handle JWT/OIDC/mTLS at the mesh edge. See the [Envoy ext_authz docs](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) for the configuration surface.
|
||||
|
||||
## Rollback
|
||||
|
||||
Pre-G-1 binaries silently accepted `CERTCTL_AUTH_TYPE=jwt` and routed through the api-key middleware. Downgrading the binary is the only mechanical rollback path, and it puts you back into the silent-downgrade state — which is exactly what the G-1 audit finding is about. We don't recommend it. If something is forcing your hand, capture the operational issue you're hitting and open a GitHub issue against the certctl repo with the SHAs involved; the Authenticating-gateway pattern was specifically designed to cover the use cases that historically led operators to set `CERTCTL_AUTH_TYPE=jwt`.
|
||||
|
||||
There is no on-disk state that changes with this upgrade — no migrations to roll back, no encrypted config to re-encode, no certificates to re-issue. The change is entirely in the config-validation surface and the helm-chart template guard.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`architecture.md`](architecture.md) — "Authenticating-gateway pattern (JWT, OIDC, mTLS)" section.
|
||||
- [`tls.md`](tls.md) — TLS provisioning patterns. The gateway proxying to certctl-server still needs to trust certctl's TLS cert; same patterns apply.
|
||||
- [`../deploy/helm/certctl/README.md`](../deploy/helm/certctl/README.md) — Helm-chart-flavored guidance.
|
||||
- `internal/config/config.go::ValidAuthTypes` — the single source of truth for what's accepted post-G-1.
|
||||
- `internal/repository/postgres/db.go::wrapPingError` — unrelated; pattern for runtime diagnostic of operator misconfiguration.
|
||||
- `coverage-gap-audit-2026-04-24-v5/unified-audit.md` — the audit finding (`cat-g-jwt_silent_auth_downgrade`).
|
||||
+77
-40
@@ -1,82 +1,119 @@
|
||||
# Why certctl?
|
||||
|
||||
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi."
|
||||
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi." certctl fills that gap: a self-hosted platform that automates the entire certificate lifecycle, works with any CA, deploys to any server, and keeps private keys on your infrastructure. It's free, source-available, and you own everything.
|
||||
|
||||
If you run a personal blog, Certbot works fine. If your company spends $200K/year on Keyfactor, you're covered. But if you're an ops engineer managing 20-500 certificates across NGINX, Apache, HAProxy, and maybe a private CA — the tools available today either don't do enough or cost too much.
|
||||
## The Math That Forces the Decision
|
||||
|
||||
certctl fills that gap.
|
||||
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/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: **200 days** as of March 2026, **100 days** by March 2027, and **47 days** by March 2029.
|
||||
|
||||
## The Problem
|
||||
At 47-day lifespans, a team managing 100 certificates is processing **7+ renewals per week**, every week, forever. At 200 certificates, it's two per day. Manual processes, calendar reminders, and certbot cron jobs don't scale to this — a single missed renewal becomes a production outage at 3 AM. Certificate lifecycle automation is no longer optional; the only question is what tool runs it.
|
||||
|
||||
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/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: 200 days as of March 2026, 100 days by March 2027, and 47 days by March 2029. That means every organization needs automated certificate renewal — not eventually, but now.
|
||||
## The Landscape Today
|
||||
|
||||
The existing options for automation are:
|
||||
If you're evaluating your options, here's what you'll find:
|
||||
|
||||
- **ACME clients** (Certbot, Lego, CertWarden): Handle issuance and renewal for ACME-compatible CAs, but don't manage deployment to target servers, don't provide inventory visibility, don't support non-ACME CAs, and don't offer audit trails or policy enforcement.
|
||||
- **Kubernetes-native** (cert-manager): Works well inside Kubernetes, but if your infrastructure includes bare-metal servers, VMs, or network appliances alongside Kubernetes, you need a separate solution for everything cert-manager can't reach.
|
||||
- **Commercial SaaS** (CertKit, Sectigo CLM): Handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate — costs scale linearly with your infrastructure.
|
||||
- **Enterprise platforms** (Venafi, Keyfactor, AppViewX): Comprehensive but start at $75K/year and require dedicated teams to operate.
|
||||
**ACME clients** (certbot, lego, acme.sh) handle issuance and renewal for Let's Encrypt and similar CAs, but they don't deploy to target servers, don't track inventory, don't support private CAs, and give you no audit trail or policy enforcement. You end up writing glue scripts and hoping they don't break.
|
||||
|
||||
**Kubernetes-native tools** (cert-manager) work well inside the cluster, but most organizations run mixed infrastructure — NGINX on VMs, HAProxy at the edge, IIS on Windows, maybe an F5. You need a separate solution for everything outside Kubernetes.
|
||||
|
||||
**Commercial SaaS platforms** handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate. At 100 certs and 20 agents, SaaS pricing runs $3,000-5,000/year and scales linearly. You're paying rent on your own infrastructure's security.
|
||||
|
||||
**Enterprise platforms** (Venafi, Keyfactor, AppViewX) are comprehensive but start at $75K/year and require dedicated teams to operate. If you have a 50-server environment, the licensing costs more than the servers.
|
||||
|
||||
## What certctl Does Differently
|
||||
|
||||
certctl is a self-hosted certificate lifecycle platform. It handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
||||
certctl handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
||||
|
||||
### 1. Private Keys Never Leave Your Infrastructure
|
||||
|
||||
certctl agents generate private keys locally using ECDSA P-256. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions.
|
||||
certctl agents generate ECDSA P-256 private keys locally. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions — it never crosses the network.
|
||||
|
||||
This isn't a premium feature — it's the default behavior in the free tier. Most competitors either generate keys server-side (creating a single point of compromise) or gate key isolation behind paid tiers.
|
||||
This isn't a premium feature. It's the default behavior, free. Most alternatives either generate keys on the server (creating a single point of compromise) or gate key isolation behind paid tiers.
|
||||
|
||||
### 2. CA-Agnostic Issuer Architecture
|
||||
|
||||
certctl works with any certificate authority, not just ACME providers:
|
||||
certctl works with any certificate authority, not just ACME providers. Nine issuer connectors ship today, all free:
|
||||
|
||||
- **ACME** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01 and DNS-01 challenges, DNS-PERSIST-01 for zero-touch renewals, External Account Binding
|
||||
- **step-ca** (Smallstep) — native /sign API with JWK provisioner authentication
|
||||
- **Local CA** — self-signed or sub-CA mode (chain to your enterprise root CA, e.g. ADCS)
|
||||
- **OpenSSL / Custom CA** — delegate signing to any shell script with configurable timeout
|
||||
- **EST enrollment** (RFC 7030) — device certificate enrollment for WiFi/802.1X, MDM, and IoT
|
||||
- **ACME v2** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01, DNS-01, DNS-PERSIST-01 challenges, External Account Binding, ACME Renewal Information (RFC 9773), certificate profile selection
|
||||
- **HashiCorp Vault PKI** — `/v1/{mount}/sign/{role}` API, token auth
|
||||
- **DigiCert CertCentral** — async order model, OV/EV support
|
||||
- **Sectigo SCM** — async order model, DV/OV/EV support, 3-header auth
|
||||
- **Google Cloud CAS** — Certificate Authority Service, OAuth2 service account auth, CA pool selection
|
||||
- **step-ca** (Smallstep) — native /sign API with JWK provisioner auth
|
||||
- **Local CA** — self-signed or sub-CA mode (chain to ADCS or any enterprise root)
|
||||
- **OpenSSL / Custom CA** — delegate signing to any shell script
|
||||
- **EST enrollment** (RFC 7030) — device certs for WiFi/802.1X, MDM, IoT
|
||||
|
||||
Every issuer connector implements the same interface. Switching CAs or running multiple CAs in parallel requires zero code changes — just configuration.
|
||||
Every connector implements the same interface. Running multiple CAs in parallel — Let's Encrypt for public certs, Vault for internal services, your enterprise CA for legacy systems — is configuration, not code.
|
||||
|
||||
### 3. Post-Deployment Verification
|
||||
|
||||
Every other tool in this space stops at "the deployment command succeeded." certctl goes further: after deploying a certificate to a target, the agent connects back to the target's TLS endpoint and verifies the served certificate matches what was deployed, using SHA-256 fingerprint comparison.
|
||||
Every other tool in this space stops at "the deployment command succeeded." certctl goes further: after deploying a certificate, the agent connects back to the live TLS endpoint and compares the SHA-256 fingerprint of the served certificate against what was deployed.
|
||||
|
||||
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl catches this.
|
||||
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl catches this automatically.
|
||||
|
||||
## What Else Ships Free
|
||||
|
||||
The three differentiators above get the headlines, but the feature surface is wider than most paid platforms:
|
||||
|
||||
**13 deployment targets** — NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell + remote WinRM), F5 BIG-IP (proxy agent + iControl REST), Postfix, Dovecot, SSH (agentless), Windows Certificate Store, and Java Keystore. All use a pluggable connector model. The control plane never initiates outbound connections — agents poll for work, meaning certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
**Network certificate discovery** — active TLS scanning of CIDR ranges finds certificates you didn't know existed. Agents also scan local filesystems for PEM/DER files. Everything feeds into a triage workflow where you claim, dismiss, or import discovered certs into management.
|
||||
|
||||
**Immutable audit trail** — every API call recorded (method, path, actor, body hash, status, latency). Every certificate lifecycle event tracked. Append-only, no update or delete. Mapped to SOC 2, PCI-DSS 4.0, and NIST SP 800-57 compliance frameworks with published evidence guides.
|
||||
|
||||
**Policy engine** — 5 rule types (allowed issuers, allowed domains, required metadata, allowed environments, renewal lead time) with violation tracking and severity levels.
|
||||
|
||||
**PKI compliance** — DER-encoded X.509 CRL signed by issuing CA, embedded OCSP responder, RFC 5280 revocation with all reason codes, short-lived certificate exemption.
|
||||
|
||||
**Prometheus metrics** — `/api/v1/metrics/prometheus` in standard exposition format. Works with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics.
|
||||
|
||||
**MCP server** — the entire REST API is exposed via MCP for AI-assisted certificate management via Claude, Cursor, or any MCP-compatible client. No other certificate platform offers this.
|
||||
|
||||
**Full REST API** — OpenAPI 3.1-documented operations covering the entire platform. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
|
||||
|
||||
**Extensively tested** — Go backend with race detection, static analysis (golangci-lint), and vulnerability scanning (govulncheck) on every commit. CI-enforced per-layer coverage thresholds. Frontend test suite. Every push is gated.
|
||||
|
||||
## How certctl Compares
|
||||
|
||||
### vs. CertKit
|
||||
### vs. ACME Clients
|
||||
|
||||
Closest competitor architecturally — agent-based, private key isolation (Keystore), multi-platform. certctl leads on issuer coverage (ACME + step-ca + Local CA + OpenSSL + EST vs. ACME-only), PKI compliance (CRL, OCSP, RFC 5280 revocation, immutable audit trail — all missing from CertKit today), policy engine (5 rule types vs. none), and network discovery (CIDR TLS scanning vs. none). certctl is source-available (BSL 1.1 → Apache 2.0) with no cert limit; CertKit is proprietary SaaS with a 3-cert free tier. Where CertKit leads: more deployment targets today (adds LiteSpeed, IIS, auto-detection), Windows support, Kubernetes, and polished SaaS onboarding.
|
||||
ACME clients solve one slice of the problem — issuance and renewal from ACME CAs. certctl replaces the ACME client, adds 6 more CA integrations, deploys the cert to the right server, verifies it's live, tracks it in an inventory, alerts on expiry, logs everything to an audit trail, and enforces policy. If you're currently running certbot behind a cron job and a prayer, certctl replaces all of it.
|
||||
|
||||
### vs. KeyTalk
|
||||
### vs. Agent-Based SaaS
|
||||
|
||||
Commercial (proprietary) PKI platform from a Dutch company — on-prem appliance, cloud, or managed service. Broader cert type coverage (TLS, S/MIME, device auth, VPN) and DigiCert + SCEP integrations. No public documentation on policy engine, API surface, or audit capabilities. No free tier, no public pricing. certctl trades breadth of cert types for full transparency — source-available, public API spec, free community edition with no limits.
|
||||
The closest architectural competitors use the same agent model — local key generation, CSR submission, push-based deployment. Where certctl differs: it supports 9 issuer types (not just ACME), provides CRL/OCSP/revocation infrastructure (not just issuance), includes a policy engine and network discovery, and is source-available with no certificate limit. SaaS alternatives are typically proprietary, priced per certificate ($2+/cert/month), and cap their free tiers at 3-5 certificates. certctl is free for any number of certificates, forever.
|
||||
|
||||
### vs. Enterprise Platforms (Venafi, Keyfactor)
|
||||
### vs. Commercial PKI Platforms
|
||||
|
||||
Comprehensive solutions with decades of features — at $75K-$250K+/yr. certctl targets organizations that need 80% of those capabilities at 1% of the cost. The trade-off: no SSO/RBAC yet (coming in certctl Pro), no F5/IIS target connectors yet, no SLA-backed support.
|
||||
On-prem or hosted commercial platforms offer broader cert type coverage (VPN certs, device auth, SCEP) and deeper CA integrations. The trade-off: no free tier, opaque pricing (often €13K+/year for 1,500 certs), proprietary codebases, and no public API documentation. certctl trades breadth of exotic cert types for full transparency — source-available code, fully documented OpenAPI spec, and a free community edition with no artificial limits.
|
||||
|
||||
## Getting Started
|
||||
### vs. Enterprise Platforms
|
||||
|
||||
Venafi and Keyfactor offer decades of features at $75K-$250K+/year. certctl targets organizations that need 80% of those capabilities at a fraction of the cost. What certctl doesn't have yet: SSO/RBAC (coming in certctl Pro), vendor SLA-backed support. What certctl does have that enterprise platforms don't: an MCP server for AI-assisted management, ACME ARI (RFC 9773) for CA-directed renewal timing, and a deployment model that works in 5 minutes instead of 5 months.
|
||||
|
||||
## Who Should Look Elsewhere
|
||||
|
||||
certctl isn't the right tool for everyone:
|
||||
|
||||
- **Single-domain sites** — if you have one certificate on one server, certbot is fine. certctl is designed for managing tens to hundreds of certificates across multiple servers and CAs.
|
||||
- **Pure Kubernetes environments** — if every workload runs in-cluster and you're happy with cert-manager, there's no reason to add another tool. certctl shines when your infrastructure extends beyond Kubernetes.
|
||||
- **Organizations that need a vendor SLA today** — certctl is source-available software maintained by a small team. If you need contractual uptime guarantees and a support hotline, an enterprise platform is the right choice (for now).
|
||||
|
||||
## See It Running
|
||||
|
||||
The demo seeds certificates across multiple issuers, agents, and deployment targets with 180 days of realistic history — jobs, audit events, discovery scans, approval workflows — so you can explore every feature immediately.
|
||||
|
||||
```bash
|
||||
# Clone and start with Docker Compose (includes demo data)
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl/deploy
|
||||
docker compose up -d
|
||||
|
||||
# Open the dashboard
|
||||
open http://localhost:8443
|
||||
cd certctl/deploy && docker compose up -d
|
||||
# Dashboard at https://localhost:8443 (self-signed cert — pin deploy/test/certs/ca.crt)
|
||||
```
|
||||
|
||||
The demo seeds 35 certificates across 5 issuers, 8 agents, 8 deployment targets, 90 days of job history, discovery scan data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
||||
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough, or explore the [5 turnkey examples](../examples/) for specific scenarios (ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer).
|
||||
|
||||
## License
|
||||
|
||||
certctl is licensed under the [Business Source License 1.1](../LICENSE). The licensed work is free to use for any purpose other than offering a competing managed service. The license converts to Apache 2.0 on March 1, 2033.
|
||||
certctl is source-available under the [Business Source License 1.1](../LICENSE). Free for any use except offering a competing managed service. Converts to Apache 2.0 on March 14, 2033.
|
||||
|
||||
The source is available, auditable, and self-hostable. You own your data, your keys, and your deployment.
|
||||
You own your data, your keys, and your deployment.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Deployment Examples
|
||||
|
||||
Five turnkey docker-compose scenarios that show certctl deployed against real CA backends and target shapes. Each subdirectory is self-contained — pick the one closest to your stack and have it running in minutes.
|
||||
|
||||
| Example | Stack | What it shows |
|
||||
|---------|-------|---------------|
|
||||
| [`acme-nginx/`](acme-nginx/acme-nginx.md) | Let's Encrypt + NGINX (HTTP-01) | The default public-CA path: ACME-issued certs deployed to NGINX. |
|
||||
| [`acme-wildcard-dns01/`](acme-wildcard-dns01/acme-wildcard-dns01.md) | Let's Encrypt wildcard (DNS-01) | Wildcard certificates via DNS-01 with pluggable DNS hooks. |
|
||||
| [`private-ca-traefik/`](private-ca-traefik/private-ca-traefik.md) | Local CA + Traefik | Internal-only certs from a private CA, deployed to Traefik. |
|
||||
| [`step-ca-haproxy/`](step-ca-haproxy/step-ca-haproxy.md) | Smallstep step-ca + HAProxy | Self-hosted CA with HAProxy as the deployment target. |
|
||||
| [`multi-issuer/`](multi-issuer/multi-issuer.md) | Let's Encrypt + Local CA | Public + private certs side-by-side from a single dashboard. |
|
||||
|
||||
## Common operational notes
|
||||
|
||||
These notes apply to **every** example. They're called out here so the per-example walkthroughs stay focused on the issuer/target wiring instead of repeating ops boilerplate.
|
||||
|
||||
### Postgres password rotation — first-boot binding trap (U-1)
|
||||
|
||||
Every example file uses `${DB_PASSWORD:-certctl-dev-password}` as the postgres password env var, with the data directory persisted via a named volume. The `postgres:16-alpine` image runs `initdb` exactly once — when `/var/lib/postgresql/data` is empty — and that's the only time `POSTGRES_PASSWORD` is written into `pg_authid`. If you boot once with the default and then change `DB_PASSWORD` (in your shell, in a `.env` file, or in a wrapper script), the certctl-server container picks up the new value but the postgres container continues to authenticate against the old one. The server fails its startup `db.Ping()` with `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01).
|
||||
|
||||
The certctl-server emits guidance pointing at the fix when this fires (see `internal/repository/postgres/db.go::wrapPingError`). The two remediation paths:
|
||||
|
||||
- **Destructive — wipes all certctl data, only acceptable on demo/test setups:**
|
||||
```bash
|
||||
docker compose -f examples/<example>/docker-compose.yml down -v
|
||||
docker compose -f examples/<example>/docker-compose.yml up -d --build
|
||||
```
|
||||
- **Non-destructive — preserves data, rotates `pg_authid` in place:**
|
||||
```bash
|
||||
docker compose -f examples/<example>/docker-compose.yml exec postgres \
|
||||
psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"
|
||||
# Then redeploy with DB_PASSWORD set to <new> in your shell or .env
|
||||
```
|
||||
|
||||
The cleanest practice for a fresh demo: set `DB_PASSWORD` once in your shell **before** the very first `docker compose up`, and don't change it during the demo's lifetime. If you must rotate, use the non-destructive path.
|
||||
|
||||
Same root cause and remediation pattern is documented for the canonical quickstart in [`../docs/quickstart.md`](../docs/quickstart.md), the production compose surface in [`../deploy/ENVIRONMENTS.md`](../deploy/ENVIRONMENTS.md), and the Helm chart in [`../deploy/helm/certctl/README.md`](../deploy/helm/certctl/README.md).
|
||||
|
||||
### TLS for the certctl control plane
|
||||
|
||||
Every example boots certctl with HTTPS-only on port 8443 (TLS 1.3 pinned, no plaintext listener as of v2.2). The shipped `certctl-tls-init` init container generates a self-signed ECDSA-P256 cert on first boot — fine for the example demos, **never** acceptable for a public deployment. For production, swap the init container for cert-manager, an operator-supplied Secret, or your internal CA — see [`../docs/tls.md`](../docs/tls.md) for the full pattern matrix.
|
||||
|
||||
### Tearing down
|
||||
|
||||
To stop services but **keep** the postgres volume (so you can pick up where you left off):
|
||||
```bash
|
||||
docker compose -f examples/<example>/docker-compose.yml down
|
||||
```
|
||||
|
||||
To stop services **and** wipe all data (clean slate for the next run):
|
||||
```bash
|
||||
docker compose -f examples/<example>/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
Note that `down -v` is the only canonical way to recover from the postgres-password trap when the non-destructive `ALTER ROLE` route is unavailable (e.g., you've forgotten the original password).
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This example demonstrates certctl's core use case: **automatically manage TLS certificates for NGINX using Let's Encrypt (ACME HTTP-01 challenges).**
|
||||
|
||||
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
|
||||
|
||||
## What This Does
|
||||
|
||||
- Deploys certctl server (control plane) with PostgreSQL
|
||||
@@ -13,16 +15,18 @@ This example demonstrates certctl's core use case: **automatically manage TLS ce
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Your Domain (example.com)
|
||||
↓ [HTTP-01 validation, port 80]
|
||||
Let's Encrypt ACME
|
||||
↓ [CSR submission]
|
||||
certctl Server (control plane)
|
||||
↓ [API polling]
|
||||
certctl Agent (on NGINX server)
|
||||
↓ [deploy cert+key]
|
||||
NGINX Reverse Proxy
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Your Domain (example.com)"]
|
||||
B["Let's Encrypt ACME"]
|
||||
C["certctl Server (control plane)"]
|
||||
D["certctl Agent (on NGINX server)"]
|
||||
E["NGINX Reverse Proxy"]
|
||||
|
||||
A -->|HTTP-01 validation<br/>port 80| B
|
||||
B -->|CSR submission| C
|
||||
C -->|API polling| D
|
||||
D -->|deploy cert+key| E
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
@@ -34,6 +38,13 @@ Your Domain (example.com)
|
||||
|
||||
If you don't have a real domain or can't open port 80, see [Customization Tips](#customization-tips) below.
|
||||
|
||||
## TLS Security
|
||||
|
||||
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
|
||||
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
|
||||
- Use `curl -k ...` for quick smoke tests (never in production)
|
||||
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone or copy this example
|
||||
@@ -120,7 +131,7 @@ docker compose logs -f certctl-server certctl-agent
|
||||
|
||||
### 5. Access the dashboard
|
||||
|
||||
Navigate to `http://localhost:8443` (or your `SERVER_PORT`)
|
||||
Navigate to `https://localhost:8443` (or your `SERVER_PORT`)
|
||||
|
||||
You should see:
|
||||
- An empty certificate inventory (no certs issued yet)
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
container_name: certctl-server-acme-nginx
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
+10
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
**What this does:** Issues wildcard certificates (e.g., `*.example.com`) from Let's Encrypt using DNS-01 challenge validation.
|
||||
|
||||
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
|
||||
|
||||
This example is ideal for:
|
||||
- Issuing wildcard certificates (`*.example.com`)
|
||||
- Services behind NAT, firewalls, or non-public networks
|
||||
@@ -9,6 +11,13 @@ This example is ideal for:
|
||||
- Internal PKI with public DNS names
|
||||
- Scenarios where you have programmatic access to your DNS provider's API
|
||||
|
||||
## TLS Security
|
||||
|
||||
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
|
||||
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
|
||||
- Use `curl -k ...` for quick smoke tests (never in production)
|
||||
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this example, you need:
|
||||
@@ -74,7 +83,7 @@ This starts:
|
||||
|
||||
### Step 5: Access the Dashboard
|
||||
|
||||
Open your browser to `http://localhost:8443`
|
||||
Open your browser to `https://localhost:8443`
|
||||
|
||||
### Step 6: Create a Wildcard Certificate
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
container_name: certctl-server-dns01
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
# Default is 30s; increase if your DNS propagates slowly
|
||||
# Set via CERTCTL_ACME_DNS_PROPAGATION_WAIT in code, or rely on default
|
||||
|
||||
# Optional: Let's Encrypt Renewal Information (RFC 9702) for CA-directed renewal timing
|
||||
# Optional: Let's Encrypt Renewal Information (RFC 9773) for CA-directed renewal timing
|
||||
# CERTCTL_ACME_ARI_ENABLED: "true"
|
||||
|
||||
# Local CA as fallback for internal services (optional)
|
||||
@@ -113,7 +113,7 @@ services:
|
||||
- certctl-network
|
||||
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
container_name: certctl-server-multi-issuer
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This example demonstrates certctl managing **both public and internal certificates from a single dashboard**. Public-facing services use Let's Encrypt (ACME), while internal services use a private Local CA — all visible and managed in one place.
|
||||
|
||||
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
|
||||
|
||||
## The Use Case
|
||||
|
||||
You have:
|
||||
@@ -13,27 +15,29 @@ With certctl, both issuer types are configured and available. You assign each ce
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ certctl Server (Control Plane) │
|
||||
│ - Let's Encrypt ACME issuer (HTTP-01 challenges) │
|
||||
│ - Local CA issuer (self-signed or sub-CA mode) │
|
||||
│ - PostgreSQL database (cert inventory, audit, jobs) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ API polling
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ certctl Agent │
|
||||
│ - Discovers existing certs in /etc/nginx/ssl and /etc/app/ssl │
|
||||
│ - Polls server for renewal/issuance/deployment jobs │
|
||||
│ - Generates keys locally (agent-side crypto) │
|
||||
│ - Deploys certs to NGINX and app service directories │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
NGINX (public TLS) App Services (internal TLS)
|
||||
(Let's Encrypt certs) (Local CA certs)
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Server ["certctl Server (Control Plane)"]
|
||||
A["Let's Encrypt ACME issuer<br/>(HTTP-01 challenges)"]
|
||||
B["Local CA issuer<br/>(self-signed or sub-CA mode)"]
|
||||
C["PostgreSQL database<br/>(cert inventory, audit, jobs)"]
|
||||
end
|
||||
|
||||
subgraph Agent ["certctl Agent"]
|
||||
D["Discovers existing certs<br/>(/etc/nginx/ssl, /etc/app/ssl)"]
|
||||
E["Polls server for<br/>renewal/issuance/deployment jobs"]
|
||||
F["Generates keys locally<br/>(agent-side crypto)"]
|
||||
G["Deploys certs to NGINX<br/>and app service directories"]
|
||||
end
|
||||
|
||||
subgraph Targets ["Target Services"]
|
||||
H["NGINX (public TLS)<br/>(Let's Encrypt certs)"]
|
||||
I["App Services (internal TLS)<br/>(Local CA certs)"]
|
||||
end
|
||||
|
||||
Server -->|API polling| Agent
|
||||
Agent -->|Deploy| H
|
||||
Agent -->|Deploy| I
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
@@ -43,6 +47,13 @@ With certctl, both issuer types are configured and available. You assign each ce
|
||||
- **Domain for ACME** (optional) — if using real Let's Encrypt, not needed for demo
|
||||
- **Internet connectivity** — to reach Let's Encrypt's API (demo can use staging directory)
|
||||
|
||||
## TLS Security
|
||||
|
||||
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
|
||||
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
|
||||
- Use `curl -k ...` for quick smoke tests (never in production)
|
||||
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone or navigate to this directory
|
||||
@@ -81,7 +92,7 @@ This spins up:
|
||||
|
||||
### 4. Access the dashboard
|
||||
|
||||
Open your browser to **http://localhost:8443** (or your configured SERVER_PORT)
|
||||
Open your browser to **https://localhost:8443** (or your configured SERVER_PORT)
|
||||
|
||||
You should see:
|
||||
- Empty cert inventory (fresh start)
|
||||
@@ -212,7 +223,7 @@ Each agent independently manages its local cert inventory and deployments. The s
|
||||
- For ACME, ensure ports 80/443 are open and your domain resolves
|
||||
|
||||
### Agent can't reach server
|
||||
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health`
|
||||
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/health`
|
||||
- Verify `CERTCTL_SERVER_URL` environment variable
|
||||
|
||||
### No issuers showing up
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
container_name: certctl-server-private-ca
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -77,7 +77,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
+30
-34
@@ -1,5 +1,7 @@
|
||||
# Private CA + Traefik Example
|
||||
|
||||
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
|
||||
|
||||
This example demonstrates certctl managing certificates for **internal services without public CA dependency**. Ideal for enterprise environments where:
|
||||
|
||||
- All services are internal (VPN, private networks)
|
||||
@@ -17,30 +19,24 @@ This example demonstrates certctl managing certificates for **internal services
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["certctl-server<br/>(control plane)<br/>(Local CA issuer)"]
|
||||
B["certctl-agent<br/>(certificate deployer)"]
|
||||
C["Traefik<br/>(watches cert directory)"]
|
||||
D["[Internal Services]"]
|
||||
|
||||
A -->|REST API<br/>job polling| B
|
||||
B -->|Write cert/key files| C
|
||||
C -->|TLS handshakes| D
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ certctl-server │ (Local CA issuer)
|
||||
│ (control │
|
||||
│ plane) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
│ REST API (job polling)
|
||||
│
|
||||
┌────────▼──────────┐
|
||||
│ certctl-agent │ (certificate deployer)
|
||||
└────────┬──────────┘
|
||||
│
|
||||
│ Write cert/key files
|
||||
│
|
||||
┌────────▼──────────────────────┐
|
||||
│ Traefik │
|
||||
│ (watches cert directory) │
|
||||
└────────────────────────────────┘
|
||||
│
|
||||
│ TLS handshakes
|
||||
│
|
||||
[Internal Services]
|
||||
```
|
||||
|
||||
## TLS Security
|
||||
|
||||
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
|
||||
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
|
||||
- Use `curl -k ...` for quick smoke tests (never in production)
|
||||
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
|
||||
|
||||
## Quick Start (Self-Signed CA)
|
||||
|
||||
@@ -71,7 +67,7 @@ EOF
|
||||
docker compose up -d
|
||||
|
||||
# 4. Access the dashboards
|
||||
# - certctl: http://localhost:8443 (API only, use the CLI or direct HTTP calls)
|
||||
# - certctl: https://localhost:8443 (API only, use the CLI or direct HTTP calls)
|
||||
# - Traefik dashboard: http://localhost:8080
|
||||
```
|
||||
|
||||
@@ -125,7 +121,7 @@ Once the stack is running:
|
||||
|
||||
```bash
|
||||
# 1. Create a certificate profile in certctl (defines allowed key types, TTL, etc.)
|
||||
curl -X POST http://localhost:8443/api/v1/profiles \
|
||||
curl -X POST https://localhost:8443/api/v1/profiles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "prof-internal",
|
||||
@@ -136,7 +132,7 @@ curl -X POST http://localhost:8443/api/v1/profiles \
|
||||
}'
|
||||
|
||||
# 2. Create a renewal policy (defines issuer, renewal thresholds, etc.)
|
||||
curl -X POST http://localhost:8443/api/v1/policies \
|
||||
curl -X POST https://localhost:8443/api/v1/policies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "pol-internal",
|
||||
@@ -148,7 +144,7 @@ curl -X POST http://localhost:8443/api/v1/policies \
|
||||
}'
|
||||
|
||||
# 3. Create a certificate (triggers issuance immediately)
|
||||
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
curl -X POST https://localhost:8443/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"common_name": "api.internal.local",
|
||||
@@ -157,7 +153,7 @@ curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
}'
|
||||
|
||||
# 4. Create a Traefik target (agent will deploy to this)
|
||||
curl -X POST http://localhost:8443/api/v1/targets \
|
||||
curl -X POST https://localhost:8443/api/v1/targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "target-traefik-01",
|
||||
@@ -169,7 +165,7 @@ curl -X POST http://localhost:8443/api/v1/targets \
|
||||
}'
|
||||
|
||||
# 5. Create a deployment job (agent picks this up and deploys)
|
||||
curl -X POST http://localhost:8443/api/v1/certificates/{cert-id}/deploy \
|
||||
curl -X POST https://localhost:8443/api/v1/certificates/{cert-id}/deploy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"target_ids": ["target-traefik-01"]
|
||||
@@ -222,16 +218,16 @@ The server provides a REST API on port 8443. Example queries:
|
||||
|
||||
```bash
|
||||
# List all certificates
|
||||
curl http://localhost:8443/api/v1/certificates
|
||||
curl https://localhost:8443/api/v1/certificates
|
||||
|
||||
# Check certificate status
|
||||
curl http://localhost:8443/api/v1/certificates/{cert-id}
|
||||
curl https://localhost:8443/api/v1/certificates/{cert-id}
|
||||
|
||||
# View audit trail
|
||||
curl http://localhost:8443/api/v1/audit
|
||||
curl https://localhost:8443/api/v1/audit
|
||||
|
||||
# Check renewal policy compliance
|
||||
curl http://localhost:8443/api/v1/policies/{policy-id}
|
||||
curl https://localhost:8443/api/v1/policies/{policy-id}
|
||||
```
|
||||
|
||||
### Traefik Dashboard
|
||||
@@ -303,7 +299,7 @@ Changes are picked up automatically (file watcher enabled).
|
||||
docker compose logs certctl-agent | grep heartbeat
|
||||
|
||||
# Check deployment job status
|
||||
curl http://localhost:8443/api/v1/jobs | jq '.[] | select(.type == "Deployment")'
|
||||
curl https://localhost:8443/api/v1/jobs | jq '.[] | select(.type == "Deployment")'
|
||||
|
||||
# Check Traefik is watching the directory
|
||||
docker compose exec traefik ls -la /etc/traefik/certs/
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
container_name: certctl-server-stepca-haproxy
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -119,7 +119,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This example demonstrates certctl managing certificates issued by **Smallstep step-ca** and deploying them to **HAProxy**.
|
||||
|
||||
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
|
||||
|
||||
## Scenario
|
||||
|
||||
You're a Smallstep user running step-ca as your internal PKI. You have HAProxy load balancers that need certificates. This setup:
|
||||
@@ -48,6 +50,13 @@ Monitor logs:
|
||||
docker compose logs -f certctl-server
|
||||
```
|
||||
|
||||
## TLS Security
|
||||
|
||||
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
|
||||
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
|
||||
- Use `curl -k ...` for quick smoke tests (never in production)
|
||||
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
|
||||
|
||||
Wait for all services to reach healthy state:
|
||||
|
||||
```bash
|
||||
@@ -69,7 +78,7 @@ certctl-haproxy-... healthy
|
||||
Open your browser to:
|
||||
|
||||
```
|
||||
http://localhost:8443
|
||||
https://localhost:8443
|
||||
```
|
||||
|
||||
You should see an empty dashboard. This is expected — no certificates issued yet.
|
||||
@@ -79,7 +88,7 @@ You should see an empty dashboard. This is expected — no certificates issued y
|
||||
This defines what certificates certctl can issue (key algorithm, max TTL, allowed names).
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/profiles \
|
||||
curl -X POST https://localhost:8443/api/v1/profiles \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "internal-web",
|
||||
@@ -94,7 +103,7 @@ curl -X POST http://localhost:8443/api/v1/profiles \
|
||||
This tells certctl where to deploy certificates on the HAProxy server.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/targets \
|
||||
curl -X POST https://localhost:8443/api/v1/targets \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "haproxy-01",
|
||||
@@ -115,7 +124,7 @@ Note: In the Docker Compose environment, reload command can be `kill -HUP $(pido
|
||||
This ties a certificate profile to a deployment target and sets renewal thresholds.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/renewal-policies \
|
||||
curl -X POST https://localhost:8443/api/v1/renewal-policies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "haproxy-internal-web",
|
||||
@@ -130,7 +139,7 @@ curl -X POST http://localhost:8443/api/v1/renewal-policies \
|
||||
Get the issuer ID:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8443/api/v1/issuers | jq '.'
|
||||
curl https://localhost:8443/api/v1/issuers | jq '.'
|
||||
```
|
||||
|
||||
You should see `iss-stepca` in the list.
|
||||
@@ -140,7 +149,7 @@ You should see `iss-stepca` in the list.
|
||||
Request a certificate via the API. The server will sign it via step-ca.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
curl -X POST https://localhost:8443/api/v1/certificates \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"common_name": "api.internal.example.com",
|
||||
@@ -155,7 +164,7 @@ curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
Get the certificate ID and trigger deployment:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/certificates/<cert_id>/deploy \
|
||||
curl -X POST https://localhost:8443/api/v1/certificates/<cert_id>/deploy \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"target_id": "<target_id_from_step_4>"
|
||||
@@ -171,7 +180,7 @@ The agent will:
|
||||
|
||||
### 8. Verify in Dashboard
|
||||
|
||||
Refresh http://localhost:8443 and you should see:
|
||||
Refresh https://localhost:8443 and you should see:
|
||||
- 1 certificate (status: Active, expiry in 90 days)
|
||||
- 1 deployment job (status: Completed)
|
||||
- 1 agent (heartbeat: recent)
|
||||
@@ -315,7 +324,7 @@ Common issues:
|
||||
Verify network:
|
||||
|
||||
```bash
|
||||
docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health
|
||||
docker compose exec certctl-agent curl http://certctl-server:8443/health
|
||||
```
|
||||
|
||||
### HAProxy config validation fails
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/shankar0123/certctl
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -9,12 +9,21 @@ require (
|
||||
github.com/testcontainers/testcontainers-go v0.35.0
|
||||
)
|
||||
|
||||
require golang.org/x/crypto v0.31.0
|
||||
require (
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||
github.com/pkg/sftp v1.13.10
|
||||
golang.org/x/crypto v0.41.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
github.com/containerd/containerd v1.7.18 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
@@ -29,12 +38,23 @@ require (
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.5.0 // indirect
|
||||
@@ -51,7 +71,8 @@ require (
|
||||
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
@@ -60,8 +81,9 @@ require (
|
||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,8 +4,16 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns=
|
||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE=
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b/go.mod h1:Ram6ngyPDmP+0t6+4T2rymv0w0BS9N8Ch5vvUJccw5o=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
|
||||
@@ -39,6 +47,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
@@ -52,12 +62,35 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -68,6 +101,10 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 h1:2ZKn+w/BJeL43sCxI2jhPLRv73oVVOjEKZjKkflyqxg=
|
||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc=
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 h1:AKIJL2PfBX2uie0Mn5pxtG1+zut3hAVMZbRfoXecFzI=
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321/go.mod h1:JajVhkiG2bYSNYYPYuWG7WZHr42CTjMTcCjfInRNCqc=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
@@ -88,6 +125,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
@@ -111,14 +150,18 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
||||
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
|
||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
|
||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde/go.mod h1:MvrEmduDUz4ST5pGZ7CABCnOU5f3ZiOAZzT6b1A6nX8=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -127,6 +170,7 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
@@ -148,45 +192,65 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -205,6 +269,7 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+180
-23
@@ -60,8 +60,29 @@ OPTIONS:
|
||||
-h, --help Show this help message
|
||||
--server-url URL Set CERTCTL_SERVER_URL (skips interactive prompt)
|
||||
--api-key KEY Set CERTCTL_API_KEY (skips interactive prompt)
|
||||
--agent-id ID Set CERTCTL_AGENT_ID (defaults to hostname)
|
||||
--no-start Install but don't start the service
|
||||
|
||||
EXAMPLES:
|
||||
# Interactive install (download first):
|
||||
curl -sSLO https://raw.githubusercontent.com/${GITHUB_REPO}/master/install-agent.sh
|
||||
chmod +x install-agent.sh
|
||||
sudo ./install-agent.sh
|
||||
|
||||
# Non-interactive install (pipe via curl):
|
||||
curl -sSL https://raw.githubusercontent.com/${GITHUB_REPO}/master/install-agent.sh \\
|
||||
| sudo bash -s -- \\
|
||||
--server-url https://certctl.example.com \\
|
||||
--api-key YOUR_API_KEY
|
||||
|
||||
CONTROL-PLANE TLS TRUST:
|
||||
The certctl server is HTTPS-only as of v2.2. This installer does NOT copy a CA
|
||||
bundle — the generated agent.env leaves TLS trust to the system root store by
|
||||
default. If the server uses a private/enterprise or self-signed CA, set
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH in the generated agent.env to point at the CA
|
||||
bundle, or (dev only) CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true. See the
|
||||
commented block in the generated agent.env for the full menu.
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -74,19 +95,47 @@ parse_args() {
|
||||
exit 0
|
||||
;;
|
||||
--server-url)
|
||||
SERVER_URL="$2"
|
||||
SERVER_URL="${2:-}"
|
||||
if [[ -z "$SERVER_URL" ]]; then
|
||||
echo -e "${RED}Error: --server-url requires a value${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--server-url=*)
|
||||
SERVER_URL="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--api-key)
|
||||
API_KEY="$2"
|
||||
API_KEY="${2:-}"
|
||||
if [[ -z "$API_KEY" ]]; then
|
||||
echo -e "${RED}Error: --api-key requires a value${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--api-key=*)
|
||||
API_KEY="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--agent-id)
|
||||
AGENT_ID="${2:-}"
|
||||
if [[ -z "$AGENT_ID" ]]; then
|
||||
echo -e "${RED}Error: --agent-id requires a value${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--agent-id=*)
|
||||
AGENT_ID="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--no-start)
|
||||
NO_START=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Error: Unknown option: $1${NC}"
|
||||
echo -e "${RED}Error: Unknown option: $1${NC}" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
@@ -94,6 +143,56 @@ parse_args() {
|
||||
done
|
||||
}
|
||||
|
||||
# Ensure stdin is interactive before prompting. When the script is piped via
|
||||
# curl|bash, stdin is the pipe from curl, so `read` hits EOF immediately and
|
||||
# set -e aborts the script silently. Reopen stdin from the controlling terminal
|
||||
# (/dev/tty) if available; otherwise print a helpful error pointing at the
|
||||
# flag-based non-interactive install.
|
||||
ensure_interactive_input() {
|
||||
# If all required config is already provided via flags, no prompting needed.
|
||||
if [[ -n "${SERVER_URL:-}" && -n "${API_KEY:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Already interactive — nothing to do.
|
||||
if [[ -t 0 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Piped stdin — try to reopen from the controlling terminal. Actually
|
||||
# attempt to open /dev/tty inside a subshell: the device node may exist
|
||||
# even when the process has no controlling terminal (ENXIO on open), so
|
||||
# `[[ -r /dev/tty ]]` is not reliable.
|
||||
if ( exec </dev/tty ) 2>/dev/null; then
|
||||
exec </dev/tty
|
||||
return
|
||||
fi
|
||||
|
||||
# No terminal available — emit clear guidance and exit.
|
||||
# Use printf '%b' so the ANSI color escapes in $RED/$NC are interpreted
|
||||
# rather than rendered as literal backslash sequences (a heredoc would
|
||||
# keep them as raw text).
|
||||
{
|
||||
printf '%b\n' "${RED}Error: No interactive terminal available.${NC}"
|
||||
printf '\n'
|
||||
printf 'The installer was piped through curl and no controlling terminal (/dev/tty)\n'
|
||||
printf 'is available for prompts. Pass the required values as flags instead:\n'
|
||||
printf '\n'
|
||||
printf ' curl -sSL https://raw.githubusercontent.com/%s/master/install-agent.sh \\\n' "$GITHUB_REPO"
|
||||
printf ' | sudo bash -s -- \\\n'
|
||||
printf ' --server-url https://certctl.example.com \\\n'
|
||||
printf ' --api-key YOUR_API_KEY\n'
|
||||
printf '\n'
|
||||
printf 'Or download the script first and run it directly:\n'
|
||||
printf '\n'
|
||||
printf ' curl -sSLO https://raw.githubusercontent.com/%s/master/install-agent.sh\n' "$GITHUB_REPO"
|
||||
printf ' chmod +x install-agent.sh\n'
|
||||
printf ' sudo ./install-agent.sh\n'
|
||||
printf '\n'
|
||||
} >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if running as root/sudo on Linux
|
||||
check_privileges() {
|
||||
if [[ "$OS_TYPE" == "linux" && "$EUID" -ne 0 ]]; then
|
||||
@@ -103,23 +202,33 @@ check_privileges() {
|
||||
}
|
||||
|
||||
# Download agent binary from GitHub Releases
|
||||
# IMPORTANT: main() captures this function's stdout via `binary_path=$(download_binary)`,
|
||||
# so every status/error message MUST go to stderr (>&2). Only the final
|
||||
# `echo "$temp_file"` is allowed on stdout — that's the return value.
|
||||
#
|
||||
# We deliberately do NOT register an EXIT trap to clean up $temp_file: because
|
||||
# of the command substitution, this function runs in a subshell, and any EXIT
|
||||
# trap set here fires when the subshell exits — which is *before* install_binary
|
||||
# gets a chance to cp the file. Cleanup on success is install_binary's job
|
||||
# (after the cp), and cleanup on curl failure is handled inline below.
|
||||
download_binary() {
|
||||
local binary_name="certctl-agent-${OS_TYPE}-${ARCH_TYPE}"
|
||||
local download_url="${RELEASE_URL}/${binary_name}"
|
||||
|
||||
echo -e "${YELLOW}Downloading certctl agent (${OS_TYPE}-${ARCH_TYPE})...${NC}"
|
||||
echo -e "${YELLOW}Downloading certctl agent (${OS_TYPE}-${ARCH_TYPE})...${NC}" >&2
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
echo -e "${RED}Error: curl is required but not installed${NC}"
|
||||
echo -e "${RED}Error: curl is required but not installed${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local temp_file=$(mktemp)
|
||||
trap "rm -f $temp_file" EXIT
|
||||
local temp_file
|
||||
temp_file=$(mktemp)
|
||||
|
||||
if ! curl -sSL -f "$download_url" -o "$temp_file"; then
|
||||
echo -e "${RED}Error: Failed to download binary from $download_url${NC}"
|
||||
echo "Make sure the latest release exists on GitHub with the binary asset for ${OS_TYPE}-${ARCH_TYPE}."
|
||||
if ! curl -sSL -f "$download_url" -o "$temp_file" >&2; then
|
||||
rm -f "$temp_file"
|
||||
echo -e "${RED}Error: Failed to download binary from $download_url${NC}" >&2
|
||||
echo "Make sure the latest release exists on GitHub with the binary asset for ${OS_TYPE}-${ARCH_TYPE}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -146,35 +255,52 @@ install_binary() {
|
||||
|
||||
chmod +x "$INSTALL_DIR/$SERVICE_NAME"
|
||||
echo -e "${GREEN}Binary installed: $INSTALL_DIR/$SERVICE_NAME${NC}"
|
||||
|
||||
# Clean up the temp file created by download_binary. We can't use an EXIT
|
||||
# trap inside download_binary because it runs in a subshell (command
|
||||
# substitution), so the trap would fire before we got here. Doing it
|
||||
# explicitly after the successful cp is the simplest correct pattern.
|
||||
rm -f "$binary_path"
|
||||
}
|
||||
|
||||
# Prompt for configuration (unless --server-url and --api-key provided)
|
||||
# Prompt for configuration. Any value supplied via flag is honored as-is
|
||||
# and we only prompt for the missing pieces. `read || true` prevents set -e
|
||||
# from aborting the script on EOF — instead the empty check below fires the
|
||||
# proper "required" error message.
|
||||
prompt_for_config() {
|
||||
if [[ -z "${SERVER_URL:-}" ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Enter certctl server URL (e.g., https://certctl.example.com):${NC}"
|
||||
read -r SERVER_URL
|
||||
if [[ -z "$SERVER_URL" ]]; then
|
||||
echo -e "${RED}Error: Server URL is required${NC}"
|
||||
read -r SERVER_URL || true
|
||||
if [[ -z "${SERVER_URL:-}" ]]; then
|
||||
echo -e "${RED}Error: Server URL is required${NC}" >&2
|
||||
echo "Hint: pass --server-url <URL> to run non-interactively." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${API_KEY:-}" ]]; then
|
||||
echo -e "${YELLOW}Enter certctl API key:${NC}"
|
||||
read -sr API_KEY
|
||||
read -rs API_KEY || true
|
||||
echo ""
|
||||
if [[ -z "$API_KEY" ]]; then
|
||||
echo -e "${RED}Error: API key is required${NC}"
|
||||
if [[ -z "${API_KEY:-}" ]]; then
|
||||
echo -e "${RED}Error: API key is required${NC}" >&2
|
||||
echo "Hint: pass --api-key <KEY> to run non-interactively." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${AGENT_ID:-}" ]]; then
|
||||
local default_agent_id="$(hostname)"
|
||||
echo -e "${YELLOW}Enter agent ID (default: $default_agent_id):${NC}"
|
||||
read -r AGENT_ID
|
||||
if [[ -z "$AGENT_ID" ]]; then
|
||||
local default_agent_id
|
||||
default_agent_id="$(hostname)"
|
||||
# If stdin is still piped (no /dev/tty was available but SERVER_URL +
|
||||
# API_KEY arrived via flags), skip the prompt entirely and use the
|
||||
# default — no need to block on an optional value.
|
||||
if [[ -t 0 ]]; then
|
||||
echo -e "${YELLOW}Enter agent ID (default: $default_agent_id):${NC}"
|
||||
read -r AGENT_ID || true
|
||||
fi
|
||||
if [[ -z "${AGENT_ID:-}" ]]; then
|
||||
AGENT_ID="$default_agent_id"
|
||||
fi
|
||||
fi
|
||||
@@ -204,7 +330,7 @@ setup_linux_config() {
|
||||
# Agent ID (unique identifier in the fleet)
|
||||
CERTCTL_AGENT_ID=$AGENT_ID
|
||||
|
||||
# Control plane server URL
|
||||
# Control plane server URL (HTTPS-only as of v2.2)
|
||||
CERTCTL_SERVER_URL=$SERVER_URL
|
||||
|
||||
# API authentication key
|
||||
@@ -216,6 +342,21 @@ CERTCTL_KEYGEN_MODE=agent
|
||||
# Key storage directory (agent-side keygen)
|
||||
CERTCTL_KEY_DIR=$key_dir
|
||||
|
||||
# ---- Control-plane TLS trust ----
|
||||
# The certctl server is HTTPS-only (v2.2+). The agent's HTTP client MUST trust the
|
||||
# server's certificate chain. Pick ONE of the approaches below:
|
||||
#
|
||||
# 1) Public CA (Let's Encrypt, DigiCert, etc.) — no config needed; system trust store works.
|
||||
# 2) Private / enterprise CA — point the agent at the CA bundle that signed the server cert:
|
||||
# CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/server-ca.crt
|
||||
#
|
||||
# 3) Self-signed server cert (Helm/compose bootstrap) — same env var, just point at the
|
||||
# extracted self-signed CA bundle (e.g. from the certctl-server-tls Kubernetes secret
|
||||
# via: kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d).
|
||||
#
|
||||
# 4) Dev/eval only — disable verification entirely (NEVER do this in production):
|
||||
# CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true
|
||||
|
||||
# Logging level (debug, info, warn, error)
|
||||
# CERTCTL_LOG_LEVEL=info
|
||||
|
||||
@@ -255,7 +396,7 @@ setup_macos_config() {
|
||||
# Agent ID (unique identifier in the fleet)
|
||||
CERTCTL_AGENT_ID=$AGENT_ID
|
||||
|
||||
# Control plane server URL
|
||||
# Control plane server URL (HTTPS-only as of v2.2)
|
||||
CERTCTL_SERVER_URL=$SERVER_URL
|
||||
|
||||
# API authentication key
|
||||
@@ -267,6 +408,21 @@ CERTCTL_KEYGEN_MODE=agent
|
||||
# Key storage directory (agent-side keygen)
|
||||
CERTCTL_KEY_DIR=$key_dir
|
||||
|
||||
# ---- Control-plane TLS trust ----
|
||||
# The certctl server is HTTPS-only (v2.2+). The agent's HTTP client MUST trust the
|
||||
# server's certificate chain. Pick ONE of the approaches below:
|
||||
#
|
||||
# 1) Public CA (Let's Encrypt, DigiCert, etc.) — no config needed; system trust store works.
|
||||
# 2) Private / enterprise CA — point the agent at the CA bundle that signed the server cert:
|
||||
# CERTCTL_SERVER_CA_BUNDLE_PATH=$HOME/.certctl/server-ca.crt
|
||||
#
|
||||
# 3) Self-signed server cert (Helm/compose bootstrap) — same env var, just point at the
|
||||
# extracted self-signed CA bundle (e.g. from the certctl-server-tls Kubernetes secret
|
||||
# via: kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d).
|
||||
#
|
||||
# 4) Dev/eval only — disable verification entirely (NEVER do this in production):
|
||||
# CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true
|
||||
|
||||
# Logging level (debug, info, warn, error)
|
||||
# CERTCTL_LOG_LEVEL=info
|
||||
|
||||
@@ -447,6 +603,7 @@ main() {
|
||||
echo "Detected platform: ${OS_TYPE}-${ARCH_TYPE}"
|
||||
echo ""
|
||||
|
||||
ensure_interactive_input
|
||||
prompt_for_config
|
||||
|
||||
# Download and install binary
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
-- =============================================================================
|
||||
-- Comprehensive Referential Integrity Check for seed_demo.sql
|
||||
-- Run AFTER migrations and seed data are loaded
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. Verify certificate_versions.certificate_id references valid managed_certificates.id
|
||||
SELECT 'FK VIOLATION: certificate_versions.certificate_id' AS issue, cv.id, cv.certificate_id
|
||||
FROM certificate_versions cv
|
||||
WHERE cv.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY cv.id;
|
||||
|
||||
-- 2. Verify certificate_target_mappings references valid IDs
|
||||
SELECT 'FK VIOLATION: certificate_target_mappings.certificate_id' AS issue, ctm.certificate_id
|
||||
FROM certificate_target_mappings ctm
|
||||
WHERE ctm.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY ctm.certificate_id;
|
||||
|
||||
SELECT 'FK VIOLATION: certificate_target_mappings.target_id' AS issue, ctm.target_id
|
||||
FROM certificate_target_mappings ctm
|
||||
WHERE ctm.target_id NOT IN (SELECT id FROM deployment_targets)
|
||||
ORDER BY ctm.target_id;
|
||||
|
||||
-- 3. Verify jobs references valid IDs
|
||||
SELECT 'FK VIOLATION: jobs.certificate_id' AS issue, j.id, j.certificate_id
|
||||
FROM jobs j
|
||||
WHERE j.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY j.id;
|
||||
|
||||
SELECT 'FK VIOLATION: jobs.target_id' AS issue, j.id, j.target_id
|
||||
FROM jobs j
|
||||
WHERE j.target_id IS NOT NULL AND j.target_id NOT IN (SELECT id FROM deployment_targets)
|
||||
ORDER BY j.id;
|
||||
|
||||
SELECT 'FK VIOLATION: jobs.agent_id' AS issue, j.id, j.agent_id
|
||||
FROM jobs j
|
||||
WHERE j.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY j.id;
|
||||
|
||||
-- 4. Verify discovered_certificates references valid IDs
|
||||
SELECT 'FK VIOLATION: discovered_certificates.agent_id' AS issue, dc.id, dc.agent_id
|
||||
FROM discovered_certificates dc
|
||||
WHERE dc.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY dc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id' AS issue, dc.id, dc.discovery_scan_id
|
||||
FROM discovered_certificates dc
|
||||
WHERE dc.discovery_scan_id IS NOT NULL AND dc.discovery_scan_id NOT IN (SELECT id FROM discovery_scans)
|
||||
ORDER BY dc.id;
|
||||
|
||||
-- 5. Verify notification_events references valid certificate_id
|
||||
SELECT 'FK VIOLATION: notification_events.certificate_id' AS issue, ne.id, ne.certificate_id
|
||||
FROM notification_events ne
|
||||
WHERE ne.certificate_id IS NOT NULL AND ne.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY ne.id;
|
||||
|
||||
-- 6. Verify policy_violations references valid certificate_id
|
||||
SELECT 'FK VIOLATION: policy_violations.certificate_id' AS issue, pv.id, pv.certificate_id
|
||||
FROM policy_violations pv
|
||||
WHERE pv.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY pv.id;
|
||||
|
||||
-- 7. Verify certificate_revocations references valid IDs
|
||||
SELECT 'FK VIOLATION: certificate_revocations.certificate_id' AS issue, cr.id, cr.certificate_id
|
||||
FROM certificate_revocations cr
|
||||
WHERE cr.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY cr.id;
|
||||
|
||||
SELECT 'FK VIOLATION: certificate_revocations.issuer_id' AS issue, cr.id, cr.issuer_id
|
||||
FROM certificate_revocations cr
|
||||
WHERE cr.issuer_id NOT IN (SELECT id FROM issuers)
|
||||
ORDER BY cr.id;
|
||||
|
||||
-- 8. Verify agent_group_members references valid IDs
|
||||
SELECT 'FK VIOLATION: agent_group_members.agent_group_id' AS issue, agm.agent_group_id
|
||||
FROM agent_group_members agm
|
||||
WHERE agm.agent_group_id NOT IN (SELECT id FROM agent_groups)
|
||||
ORDER BY agm.agent_group_id;
|
||||
|
||||
SELECT 'FK VIOLATION: agent_group_members.agent_id' AS issue, agm.agent_id
|
||||
FROM agent_group_members agm
|
||||
WHERE agm.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY agm.agent_id;
|
||||
|
||||
-- 9. Verify owners.team_id references valid teams.id
|
||||
SELECT 'FK VIOLATION: owners.team_id' AS issue, o.id, o.team_id
|
||||
FROM owners o
|
||||
WHERE o.team_id IS NOT NULL AND o.team_id NOT IN (SELECT id FROM teams)
|
||||
ORDER BY o.id;
|
||||
|
||||
-- 10. Verify deployment_targets.agent_id references valid agents.id
|
||||
SELECT 'FK VIOLATION: deployment_targets.agent_id' AS issue, dt.id, dt.agent_id
|
||||
FROM deployment_targets dt
|
||||
WHERE dt.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY dt.id;
|
||||
|
||||
-- 11. Verify managed_certificates FK columns
|
||||
SELECT 'FK VIOLATION: managed_certificates.owner_id' AS issue, mc.id, mc.owner_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.owner_id IS NOT NULL AND mc.owner_id NOT IN (SELECT id FROM owners)
|
||||
ORDER BY mc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: managed_certificates.team_id' AS issue, mc.id, mc.team_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.team_id IS NOT NULL AND mc.team_id NOT IN (SELECT id FROM teams)
|
||||
ORDER BY mc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: managed_certificates.issuer_id' AS issue, mc.id, mc.issuer_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.issuer_id NOT IN (SELECT id FROM issuers)
|
||||
ORDER BY mc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: managed_certificates.renewal_policy_id' AS issue, mc.id, mc.renewal_policy_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.renewal_policy_id IS NOT NULL AND mc.renewal_policy_id NOT IN (SELECT id FROM renewal_policies)
|
||||
ORDER BY mc.id;
|
||||
|
||||
-- 12. Check for duplicate primary keys
|
||||
SELECT 'DUPLICATE PK: teams' AS issue, id, COUNT(*) as count
|
||||
FROM teams GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: owners' AS issue, id, COUNT(*) as count
|
||||
FROM owners GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: agents' AS issue, id, COUNT(*) as count
|
||||
FROM agents GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: deployment_targets' AS issue, id, COUNT(*) as count
|
||||
FROM deployment_targets GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: managed_certificates' AS issue, id, COUNT(*) as count
|
||||
FROM managed_certificates GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: certificate_versions' AS issue, id, COUNT(*) as count
|
||||
FROM certificate_versions GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: issuers' AS issue, id, COUNT(*) as count
|
||||
FROM issuers GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: renewal_policies' AS issue, id, COUNT(*) as count
|
||||
FROM renewal_policies GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: jobs' AS issue, id, COUNT(*) as count
|
||||
FROM jobs GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: certificate_profiles' AS issue, id, COUNT(*) as count
|
||||
FROM certificate_profiles GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: certificate_revocations' AS issue, id, COUNT(*) as count
|
||||
FROM certificate_revocations GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
-- 13. Check fingerprint_sha256 uniqueness in certificate_versions
|
||||
SELECT 'DUPLICATE FINGERPRINT: certificate_versions' AS issue, fingerprint_sha256, COUNT(*) as count
|
||||
FROM certificate_versions
|
||||
WHERE fingerprint_sha256 IS NOT NULL
|
||||
GROUP BY fingerprint_sha256
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 14. Check serial number uniqueness in certificate_versions
|
||||
SELECT 'DUPLICATE SERIAL: certificate_versions' AS issue, serial_number, COUNT(*) as count
|
||||
FROM certificate_versions
|
||||
WHERE serial_number IS NOT NULL
|
||||
GROUP BY serial_number
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 15. Verify discovery_scan_id references are valid
|
||||
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id references' AS issue,
|
||||
dc.id, dc.discovery_scan_id, ds.id
|
||||
FROM discovered_certificates dc
|
||||
LEFT JOIN discovery_scans ds ON dc.discovery_scan_id = ds.id
|
||||
WHERE dc.discovery_scan_id IS NOT NULL AND ds.id IS NULL;
|
||||
|
||||
-- Summary: Count total records
|
||||
SELECT 'SUMMARY: teams' AS table_name, COUNT(*) as count FROM teams UNION ALL
|
||||
SELECT 'SUMMARY: owners', COUNT(*) FROM owners UNION ALL
|
||||
SELECT 'SUMMARY: agents', COUNT(*) FROM agents UNION ALL
|
||||
SELECT 'SUMMARY: deployment_targets', COUNT(*) FROM deployment_targets UNION ALL
|
||||
SELECT 'SUMMARY: managed_certificates', COUNT(*) FROM managed_certificates UNION ALL
|
||||
SELECT 'SUMMARY: certificate_versions', COUNT(*) FROM certificate_versions UNION ALL
|
||||
SELECT 'SUMMARY: certificate_target_mappings', COUNT(*) FROM certificate_target_mappings UNION ALL
|
||||
SELECT 'SUMMARY: issuers', COUNT(*) FROM issuers UNION ALL
|
||||
SELECT 'SUMMARY: renewal_policies', COUNT(*) FROM renewal_policies UNION ALL
|
||||
SELECT 'SUMMARY: jobs', COUNT(*) FROM jobs UNION ALL
|
||||
SELECT 'SUMMARY: certificate_profiles', COUNT(*) FROM certificate_profiles UNION ALL
|
||||
SELECT 'SUMMARY: certificate_revocations', COUNT(*) FROM certificate_revocations UNION ALL
|
||||
SELECT 'SUMMARY: audit_events', COUNT(*) FROM audit_events UNION ALL
|
||||
SELECT 'SUMMARY: discovery_scans', COUNT(*) FROM discovery_scans UNION ALL
|
||||
SELECT 'SUMMARY: discovered_certificates', COUNT(*) FROM discovered_certificates;
|
||||
@@ -0,0 +1,339 @@
|
||||
package handler
|
||||
|
||||
// Adversarial EST (RFC 7030) enrollment tests — Tier 1F.
|
||||
//
|
||||
// EST is the RFC 7030 protocol for certificate enrollment over HTTPS. The
|
||||
// control-plane parser accepts PKCS#10 CSRs either as PEM or as base64-encoded
|
||||
// DER, and it's a prime target for:
|
||||
//
|
||||
// * Malformed base64 / non-DER payloads
|
||||
// * Valid base64 that doesn't decode to a valid CSR
|
||||
// * PEM header spoofing (wrong block type)
|
||||
// * Null bytes and control characters embedded in PEM or base64
|
||||
// * Huge CSR bodies (we expect the handler's 1 MiB LimitReader to clamp them)
|
||||
// * Truncated or partially-written PEM blocks
|
||||
// * Unicode homoglyphs in PEM delimiters
|
||||
// * Content-Type mismatch (handler ignores Content-Type, but attackers might
|
||||
// still try header spoofing)
|
||||
//
|
||||
// The contract is the same as other adversarial tiers: the handler must never
|
||||
// panic and must never return 500 for a malformed CSR (500 is reserved for
|
||||
// issuer/service failures). For adversarial CSRs, the correct status is 400.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// adversarialCSRInputs exercises the EST CSR parsing surface. None of these
|
||||
// should reach the underlying ESTService — they must be rejected by
|
||||
// readCSRFromRequest with a 400 before any service call is made.
|
||||
func adversarialCSRInputs() []struct {
|
||||
name string
|
||||
body string
|
||||
} {
|
||||
// A garbage base64 string that decodes cleanly but isn't a PKCS#10 CSR.
|
||||
// base64 of "this is definitely not a CSR" = dGhpcyBpcyBkZWZpbml0ZWx5IG5vdCBhIENTUg==
|
||||
nonCSRBase64 := base64.StdEncoding.EncodeToString([]byte("this is definitely not a CSR"))
|
||||
|
||||
return []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"garbage_string", "not-a-csr-at-all"},
|
||||
{"base64_garbage", "!!!@@@###$$$%%%"},
|
||||
{"base64_valid_non_csr", nonCSRBase64},
|
||||
{"base64_very_short", "AA=="},
|
||||
{"null_byte_only", "\x00"},
|
||||
{"null_bytes_padding", "\x00\x00\x00\x00\x00\x00\x00\x00"},
|
||||
{"control_chars", "\x01\x02\x03\x04\x05\x06\x07\x08"},
|
||||
{"pem_wrong_block_type", "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"},
|
||||
{"pem_wrong_header_close", "-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END PRIVATE KEY-----\n"},
|
||||
{"pem_empty_block", "-----BEGIN CERTIFICATE REQUEST-----\n-----END CERTIFICATE REQUEST-----\n"},
|
||||
{"pem_garbage_body", "-----BEGIN CERTIFICATE REQUEST-----\n!!!not base64!!!\n-----END CERTIFICATE REQUEST-----\n"},
|
||||
{"pem_truncated", "-----BEGIN CERTIFICATE REQUEST-----\nMIIBijCCAT"},
|
||||
{"pem_no_end_marker", "-----BEGIN CERTIFICATE REQUEST-----\nMIIBijCCATICAQAwFjEUMBIGA1UE\n"},
|
||||
{"pem_header_injection", "-----BEGIN CERTIFICATE REQUEST-----\r\nHost: evil.com\r\n\r\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
||||
{"pem_embedded_null", "-----BEGIN CERTIFICATE\x00REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
||||
{"unicode_homoglyph_pem", "-----BEGIN CERTIFICATE REQUEST─────\nMIIB\n─────END CERTIFICATE REQUEST-----\n"},
|
||||
{"double_pem_block", "-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
||||
{"json_body", `{"csr":"MIIB","common_name":"attacker.com"}`},
|
||||
{"xml_body", `<?xml version="1.0"?><csr>MIIB</csr>`},
|
||||
{"shell_metacharacters", "$(whoami); rm -rf / #"},
|
||||
{"sql_injection", "' OR 1=1; DROP TABLE certificates;--"},
|
||||
{"long_garbage_10k", strings.Repeat("A", 10000)},
|
||||
{"long_base64_not_csr", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0xFF}, 5000))},
|
||||
{"base64_with_newlines_garbage", "AAAAAAAAAAAAAAAA\nBBBBBBBBBBBBBBBB\nCCCCCCCCCCCCCCCC"},
|
||||
{"percent_encoded_pem", "%2D%2D%2D%2D%2DBEGIN+CERTIFICATE+REQUEST%2D%2D%2D%2D%2D"},
|
||||
}
|
||||
}
|
||||
|
||||
// assertESTErrorResponse enforces the EST handler contract for adversarial CSRs:
|
||||
// no panic, no 500, body is valid JSON (since Error helper emits JSON errors).
|
||||
func assertESTErrorResponse(t *testing.T, w *httptest.ResponseRecorder, label string) {
|
||||
t.Helper()
|
||||
|
||||
// The handler must never reach a 500 for parser-rejected CSRs — that would
|
||||
// indicate a service call slipped through.
|
||||
if w.Code == http.StatusInternalServerError {
|
||||
t.Errorf("%s: handler returned 500 body=%q — adversarial CSR should not reach the service layer",
|
||||
label, w.Body.String())
|
||||
}
|
||||
|
||||
// The handler should return 400 Bad Request for adversarial CSR inputs.
|
||||
// A 405 (method not allowed) is impossible here because we always POST.
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: expected 400, got %d (body=%q)", label, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// newESTHandlerWithTrap returns an ESTHandler whose service panics if reached.
|
||||
// This is the core invariant for Tier 1F: adversarial CSRs must be rejected at
|
||||
// the parser, never reaching SimpleEnroll/SimpleReEnroll on the service.
|
||||
func newESTHandlerWithTrap() (ESTHandler, *trappedESTService) {
|
||||
svc := &trappedESTService{}
|
||||
return NewESTHandler(svc), svc
|
||||
}
|
||||
|
||||
// trappedESTService is a mock that fails the test if any service method is
|
||||
// called with an adversarial CSR. The parser should reject these before they
|
||||
// get here.
|
||||
type trappedESTService struct {
|
||||
serviceCalled bool
|
||||
}
|
||||
|
||||
func (t *trappedESTService) GetCACerts(ctx context.Context) (string, error) {
|
||||
t.serviceCalled = true
|
||||
return "", errors.New("trap: GetCACerts should not be called from adversarial CSR tests")
|
||||
}
|
||||
|
||||
func (t *trappedESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||
t.serviceCalled = true
|
||||
return nil, errors.New("trap: SimpleEnroll should not be called from adversarial CSR tests")
|
||||
}
|
||||
|
||||
func (t *trappedESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||
t.serviceCalled = true
|
||||
return nil, errors.New("trap: SimpleReEnroll should not be called from adversarial CSR tests")
|
||||
}
|
||||
|
||||
func (t *trappedESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
||||
t.serviceCalled = true
|
||||
return nil, errors.New("trap: GetCSRAttrs should not be called from adversarial CSR tests")
|
||||
}
|
||||
|
||||
// TestESTSimpleEnroll_AdversarialCSRs runs each adversarial CSR through the
|
||||
// enrollment endpoint.
|
||||
func TestESTSimpleEnroll_AdversarialCSRs(t *testing.T) {
|
||||
for _, tc := range adversarialCSRInputs() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on body %q: %v", tc.body, r)
|
||||
}
|
||||
}()
|
||||
|
||||
h, svc := newESTHandlerWithTrap()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/pkcs10")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
assertESTErrorResponse(t, w, "SimpleEnroll/"+tc.name)
|
||||
|
||||
if svc.serviceCalled {
|
||||
t.Errorf("SimpleEnroll/%s: service was reached with adversarial CSR (body=%q)",
|
||||
tc.name, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestESTSimpleReEnroll_AdversarialCSRs runs each adversarial CSR through the
|
||||
// re-enrollment endpoint. Same contract as simpleenroll.
|
||||
func TestESTSimpleReEnroll_AdversarialCSRs(t *testing.T) {
|
||||
for _, tc := range adversarialCSRInputs() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on body %q: %v", tc.body, r)
|
||||
}
|
||||
}()
|
||||
|
||||
h, svc := newESTHandlerWithTrap()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/pkcs10")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleReEnroll(w, req)
|
||||
|
||||
assertESTErrorResponse(t, w, "SimpleReEnroll/"+tc.name)
|
||||
|
||||
if svc.serviceCalled {
|
||||
t.Errorf("SimpleReEnroll/%s: service was reached with adversarial CSR (body=%q)",
|
||||
tc.name, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestESTSimpleEnroll_HugeBody verifies the handler's 1 MiB limit truncates
|
||||
// oversized requests at the LimitReader boundary. We send a 2 MiB body of
|
||||
// base64 garbage and confirm the handler rejects it cleanly (400, no panic,
|
||||
// no 500) and the service is never reached.
|
||||
func TestESTSimpleEnroll_HugeBody(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on 2 MiB body: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2 MiB of base64-valid garbage: the LimitReader will truncate to 1 MiB, and
|
||||
// the truncated base64 chunk won't parse as a valid PKCS#10 CSR.
|
||||
huge := strings.Repeat("A", 2<<20)
|
||||
|
||||
h, svc := newESTHandlerWithTrap()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(huge))
|
||||
req.Header.Set("Content-Type", "application/pkcs10")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
// Contract: 400 Bad Request (parser fail), no panic, no 500.
|
||||
if w.Code == http.StatusInternalServerError {
|
||||
t.Errorf("HugeBody: handler returned 500 for 2 MiB body (body=%q)", w.Body.String())
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("HugeBody: expected 400, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
if svc.serviceCalled {
|
||||
t.Error("HugeBody: service was reached with 2 MiB adversarial body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestESTSimpleEnroll_ExactlyAtLimit sends a body exactly at the 1 MiB
|
||||
// LimitReader boundary. The body is still garbage (won't parse as CSR), but we
|
||||
// verify the handler doesn't panic or hang on the boundary case.
|
||||
func TestESTSimpleEnroll_ExactlyAtLimit(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on exact-limit body: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
atLimit := strings.Repeat("A", 1<<20) // exactly 1 MiB
|
||||
|
||||
h, _ := newESTHandlerWithTrap()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(atLimit))
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code == http.StatusInternalServerError {
|
||||
t.Errorf("ExactlyAtLimit: handler returned 500 (body=%q)", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestESTSimpleEnroll_MultipartBody sends a multipart/form-data body that a
|
||||
// naive parser might try to unwrap. The handler should treat the raw bytes as
|
||||
// a CSR payload and reject them.
|
||||
func TestESTSimpleEnroll_MultipartBody(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on multipart body: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
multipart := "--boundary\r\nContent-Disposition: form-data; name=\"csr\"\r\n\r\nMIIB\r\n--boundary--\r\n"
|
||||
|
||||
h, svc := newESTHandlerWithTrap()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(multipart))
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("MultipartBody: expected 400, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
if svc.serviceCalled {
|
||||
t.Error("MultipartBody: service was reached with multipart wrapper")
|
||||
}
|
||||
}
|
||||
|
||||
// TestESTCACerts_MethodAbuse verifies the /cacerts endpoint only accepts GET
|
||||
// and rejects every other method cleanly. This is a small safety check for
|
||||
// the spec invariant.
|
||||
func TestESTCACerts_MethodAbuse(t *testing.T) {
|
||||
methods := []string{
|
||||
http.MethodPost, http.MethodPut, http.MethodDelete,
|
||||
http.MethodPatch, http.MethodHead, http.MethodOptions,
|
||||
"TRACE", "CONNECT", "PROPFIND", "BOGUS",
|
||||
}
|
||||
|
||||
for _, method := range methods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on method %s: %v", method, r)
|
||||
}
|
||||
}()
|
||||
|
||||
h, _ := newESTHandlerWithTrap()
|
||||
|
||||
req := httptest.NewRequest(method, "/.well-known/est/cacerts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.CACerts(w, req)
|
||||
|
||||
// HEAD on a GET handler in Go's stdlib is normally accepted, but
|
||||
// this handler enforces strict GET-only — so HEAD should also get 405.
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("method %s: expected 405, got %d", method, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestESTSimpleEnroll_MethodAbuse verifies strict POST-only enforcement.
|
||||
func TestESTSimpleEnroll_MethodAbuse(t *testing.T) {
|
||||
methods := []string{
|
||||
http.MethodGet, http.MethodPut, http.MethodDelete,
|
||||
http.MethodPatch, http.MethodHead, http.MethodOptions,
|
||||
"TRACE", "CONNECT",
|
||||
}
|
||||
|
||||
for _, method := range methods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on method %s: %v", method, r)
|
||||
}
|
||||
}()
|
||||
|
||||
h, svc := newESTHandlerWithTrap()
|
||||
|
||||
req := httptest.NewRequest(method, "/.well-known/est/simpleenroll", strings.NewReader("body"))
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("method %s: expected 405, got %d", method, w.Code)
|
||||
}
|
||||
if svc.serviceCalled {
|
||||
t.Errorf("method %s: service was called for non-POST", method)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package handler
|
||||
|
||||
// Adversarial path-parameter and multi-segment path tests.
|
||||
//
|
||||
// These tests exercise the input parsing boundary of the certificate handler
|
||||
// against the attack categories listed in certctl-adversarial-testing-prompt.md
|
||||
// Tier 1A / 1B:
|
||||
//
|
||||
// * Empty and whitespace-only path IDs
|
||||
// * SQL-injection sentinels embedded in the path
|
||||
// * Directory traversal (`../../etc/passwd`)
|
||||
// * Null bytes and control characters
|
||||
// * Extremely long IDs (10 KiB)
|
||||
// * Unicode homoglyphs (visually identical substitutes)
|
||||
// * Multi-segment paths (OCSP, DER CRL, versions, renew, deploy, revoke)
|
||||
//
|
||||
// The contract we verify is defensive, not behavioural:
|
||||
//
|
||||
// 1. The handler never panics.
|
||||
// 2. The HTTP status is one of {200, 400, 404, 405} — never 500.
|
||||
// 3. The response body is either empty or valid JSON.
|
||||
// 4. No attacker-controlled input is echoed verbatim in a 500 body.
|
||||
//
|
||||
// We do not assert the exact status code for every adversarial input because
|
||||
// the current handler intentionally delegates identifier validation to the
|
||||
// repository layer; its only job here is to stay up and well-formed.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// adversarialPathInputs is the attack catalog shared by Tier 1A cases. Each
|
||||
// entry targets a different parsing surface; adding a new category here makes
|
||||
// every Tier 1A test below exercise it automatically.
|
||||
func adversarialPathInputs() []struct {
|
||||
name string
|
||||
input string
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"sql_injection_drop_table", "'; DROP TABLE managed_certificates;--"},
|
||||
{"sql_injection_or_true", "' OR 1=1--"},
|
||||
{"sql_injection_union", "mc-001' UNION SELECT * FROM agents--"},
|
||||
{"path_traversal_dot_dot", "../../etc/passwd"},
|
||||
{"path_traversal_encoded", "..%2F..%2Fetc%2Fpasswd"},
|
||||
{"null_byte_trailing", "mc-001\x00"},
|
||||
{"null_byte_embedded", "mc-\x00-001"},
|
||||
{"long_id_10k", strings.Repeat("A", 10000)},
|
||||
{"unicode_homoglyph_hyphen", "mc\u2010001"}, // U+2010 HYPHEN
|
||||
{"unicode_homoglyph_fullwidth", "mc\uFF0D001"}, // U+FF0D FULLWIDTH HYPHEN-MINUS
|
||||
{"control_char_newline", "mc-001\n"},
|
||||
{"control_char_tab", "mc\t001"},
|
||||
{"control_char_bell", "mc\x07001"},
|
||||
{"percent_encoded_null", "mc-001%00"},
|
||||
{"whitespace_only", " "},
|
||||
{"shell_metacharacters", "mc-001;`rm -rf /`"},
|
||||
{"leading_slash", "/mc-001"},
|
||||
{"trailing_slash", "mc-001/"},
|
||||
{"double_slash", "mc//001"},
|
||||
}
|
||||
}
|
||||
|
||||
// assertSafeResponse is the core defensive check. Any adversarial input is
|
||||
// allowed to produce a 4xx, but must not panic or leak through as a 500.
|
||||
func assertSafeResponse(t *testing.T, w *httptest.ResponseRecorder, label string) {
|
||||
t.Helper()
|
||||
|
||||
// 1. No 500 (500 implies the handler reached an unexpected internal state).
|
||||
if w.Code == http.StatusInternalServerError {
|
||||
t.Errorf("%s: handler returned 500, body=%q — adversarial input should not reach an internal error path",
|
||||
label, w.Body.String())
|
||||
}
|
||||
|
||||
// 2. Status must be in the expected safe set.
|
||||
switch w.Code {
|
||||
case http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent,
|
||||
http.StatusBadRequest, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
|
||||
// ok
|
||||
default:
|
||||
t.Errorf("%s: unexpected status %d (body=%q)", label, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 3. Non-empty bodies must be valid JSON (no template leakage, no raw panics).
|
||||
if body := bytes.TrimSpace(w.Body.Bytes()); len(body) > 0 {
|
||||
var discard interface{}
|
||||
if err := json.Unmarshal(body, &discard); err != nil {
|
||||
t.Errorf("%s: response body is not valid JSON: %v (body=%q)", label, err, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newCertHandlerWithMock builds a handler whose mock service returns nothing.
|
||||
// This keeps every adversarial test focused on the handler's parsing layer
|
||||
// rather than service behaviour.
|
||||
func newCertHandlerWithMock() (CertificateHandler, *MockCertificateService) {
|
||||
mock := &MockCertificateService{}
|
||||
return NewCertificateHandler(mock), mock
|
||||
}
|
||||
|
||||
// TestGetCertificate_PathInjection runs each adversarial path through the
|
||||
// certificate GET handler.
|
||||
func TestGetCertificate_PathInjection(t *testing.T) {
|
||||
for _, tc := range adversarialPathInputs() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
// Force a 404 so we can distinguish "service was called" from
|
||||
// "parser accepted the ID"; a 200 with null body is also fine.
|
||||
mock.GetCertificateFn = func(_ context.Context, id string) (*domain.ManagedCertificate, error) {
|
||||
return nil, ErrMockNotFound
|
||||
}
|
||||
|
||||
// Build the URL by string concatenation to keep attacker-controlled
|
||||
// bytes intact (httptest.NewRequest uses url.Parse under the hood,
|
||||
// which normalises some characters — we want the raw path on the
|
||||
// request object).
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/x", nil)
|
||||
req.URL.Path = "/api/v1/certificates/" + tc.input
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetCertificate(w, req)
|
||||
|
||||
assertSafeResponse(t, w, "GetCertificate/"+tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCertificate_PathInjection exercises the PUT handler's path parser.
|
||||
// UpdateCertificate splits the path on "/" and takes parts[0]; traversal and
|
||||
// double-slash inputs must still short-circuit at the parser rather than
|
||||
// reaching the service.
|
||||
func TestUpdateCertificate_PathInjection(t *testing.T) {
|
||||
body := `{"common_name":"example.com","owner_id":"o-alice","team_id":"t-a","issuer_id":"iss-local","name":"n","renewal_policy_id":"rp-1"}`
|
||||
|
||||
for _, tc := range adversarialPathInputs() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.UpdateCertificateFn = func(_ context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
return nil, ErrMockNotFound
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/certificates/x", bytes.NewBufferString(body))
|
||||
req.URL.Path = "/api/v1/certificates/" + tc.input
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.UpdateCertificate(w, req)
|
||||
|
||||
assertSafeResponse(t, w, "UpdateCertificate/"+tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArchiveCertificate_PathInjection exercises DELETE.
|
||||
func TestArchiveCertificate_PathInjection(t *testing.T) {
|
||||
for _, tc := range adversarialPathInputs() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.ArchiveCertificateFn = func(_ context.Context, id string) error { return ErrMockNotFound }
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/x", nil)
|
||||
req.URL.Path = "/api/v1/certificates/" + tc.input
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ArchiveCertificate(w, req)
|
||||
|
||||
assertSafeResponse(t, w, "ArchiveCertificate/"+tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCertificateVersions_MultiSegment is a Tier 1B test: the versions
|
||||
// handler requires a 2-segment path (certID/versions). The parser uses
|
||||
// strings.Split(path, "/") and checks len(parts) < 2 — but an adversarial
|
||||
// caller can inject extra slashes to either produce an empty parts[0] or a
|
||||
// very long parts slice. Either way we must not panic.
|
||||
func TestGetCertificateVersions_MultiSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"missing_segment", "/api/v1/certificates/versions"},
|
||||
{"empty_cert_id", "/api/v1/certificates//versions"},
|
||||
{"traversal_cert_id", "/api/v1/certificates/..%2F..%2Fversions/versions"},
|
||||
{"sql_injection_cert_id", "/api/v1/certificates/'%20OR%201=1--/versions"},
|
||||
{"null_byte_cert_id", "/api/v1/certificates/mc\x00001/versions"},
|
||||
{"very_long_cert_id", "/api/v1/certificates/" + strings.Repeat("A", 5000) + "/versions"},
|
||||
{"trailing_segments", "/api/v1/certificates/mc-001/versions/extra/trailing"},
|
||||
{"deep_nesting", "/api/v1/certificates/" + strings.Repeat("a/", 50) + "versions"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on path %q: %v", tc.path, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.GetCertificateVersionsFn = func(_ context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
||||
return []domain.CertificateVersion{}, 0, nil
|
||||
}
|
||||
|
||||
// Use a dummy safe URL in NewRequest to avoid url.Parse panics
|
||||
// on control chars, then overwrite with the raw attacker path.
|
||||
req := httptest.NewRequest(http.MethodGet, "/safe", nil)
|
||||
req.URL.Path = tc.path
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetCertificateVersions(w, req)
|
||||
|
||||
assertSafeResponse(t, w, "GetCertificateVersions/"+tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleOCSP_MultiSegment exercises the OCSP responder's 2-segment path
|
||||
// parser (/.well-known/pki/ocsp/{issuer_id}/{serial_hex}). Each leg is
|
||||
// attacker-controlled and the serial can be arbitrary length. This is a key
|
||||
// adversarial surface because the serial is passed directly to the
|
||||
// CA-operations service, which is expected to treat it as an opaque
|
||||
// identifier.
|
||||
//
|
||||
// M-006 relocation: these paths were previously served at /api/v1/ocsp/*;
|
||||
// under RFC 8615 and RFC 6960 they now live under /.well-known/pki/ocsp/*.
|
||||
func TestHandleOCSP_MultiSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"missing_serial", "/.well-known/pki/ocsp/iss-local"},
|
||||
{"missing_both", "/.well-known/pki/ocsp/"},
|
||||
{"empty_issuer", "/.well-known/pki/ocsp//01ABCDEF"},
|
||||
{"empty_serial", "/.well-known/pki/ocsp/iss-local/"},
|
||||
{"traversal_issuer", "/.well-known/pki/ocsp/..%2F..%2Fetc/passwd/01"},
|
||||
{"null_byte_serial", "/.well-known/pki/ocsp/iss-local/01\x00FF"},
|
||||
{"sql_injection_serial", "/.well-known/pki/ocsp/iss-local/01'; DROP TABLE--"},
|
||||
{"negative_hex_serial", "/.well-known/pki/ocsp/iss-local/-1"},
|
||||
{"unicode_serial", "/.well-known/pki/ocsp/iss-local/01\u2010FF"},
|
||||
{"extremely_long_serial", "/.well-known/pki/ocsp/iss-local/" + strings.Repeat("F", 10000)},
|
||||
{"extra_segments", "/.well-known/pki/ocsp/iss-local/01FF/extra/segments"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on path %q: %v", tc.path, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.GetOCSPResponseFn = func(_ context.Context, issuerID, serialHex string) ([]byte, error) {
|
||||
return nil, ErrMockNotFound
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/safe", nil)
|
||||
req.URL.Path = tc.path
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleOCSP(w, req)
|
||||
|
||||
// OCSP does NOT guarantee JSON responses (pkix-crl uses binary),
|
||||
// so we only check status safety, not body structure.
|
||||
if w.Code == http.StatusInternalServerError {
|
||||
t.Errorf("HandleOCSP/%s: returned 500 body=%q", tc.name, w.Body.String())
|
||||
}
|
||||
if w.Code >= 500 {
|
||||
t.Errorf("HandleOCSP/%s: unexpected 5xx %d", tc.name, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDERCRL_IssuerPathInjection exercises
|
||||
// /.well-known/pki/crl/{issuer_id} (RFC 5280 CRL; M-006 relocation from
|
||||
// /api/v1/crl/{issuer_id}).
|
||||
func TestGetDERCRL_IssuerPathInjection(t *testing.T) {
|
||||
for _, tc := range adversarialPathInputs() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.GenerateDERCRLFn = func(_ context.Context, issuerID string) ([]byte, error) {
|
||||
return nil, ErrMockNotFound
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/x", nil)
|
||||
req.URL.Path = "/.well-known/pki/crl/" + tc.input
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetDERCRL(w, req)
|
||||
|
||||
if w.Code >= 500 {
|
||||
t.Errorf("GetDERCRL/%s: unexpected 5xx %d (body=%q)", tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package handler
|
||||
|
||||
// Adversarial query-parameter, request-body, and revocation-reason tests.
|
||||
//
|
||||
// These tests exercise the second boundary of the certificate handler:
|
||||
//
|
||||
// * Numeric pagination parsing (page, per_page, page_size)
|
||||
// * Sort direction and field whitelist
|
||||
// * Time-range filters (expires_before, expires_after, created_after, updated_after)
|
||||
// * Cursor pagination
|
||||
// * Sparse-field projection (?fields=...)
|
||||
// * Request-body JSON parsing (create/update) — null, malformed, deep nesting,
|
||||
// unicode, oversized
|
||||
// * Revocation reason abuse
|
||||
//
|
||||
// The handler silently ignores malformed pagination values (it falls back to
|
||||
// defaults) and ignores invalid RFC3339 time values. These tests lock in that
|
||||
// behaviour so a future "fail-closed" change has to be deliberate.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// buildListRequest constructs a GET /api/v1/certificates request with the
|
||||
// given raw query string. We use raw query strings (not url.Values.Encode)
|
||||
// so adversarial inputs like "page=abc&page=-1" or "%00" pass through
|
||||
// unchanged.
|
||||
func buildListRequest(rawQuery string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.URL.RawQuery = rawQuery
|
||||
return req.WithContext(contextWithRequestID())
|
||||
}
|
||||
|
||||
// TestListCertificates_PaginationAbuse verifies adversarial pagination values
|
||||
// never produce a 500 and the handler always falls back to sane defaults.
|
||||
func TestListCertificates_PaginationAbuse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
}{
|
||||
{"negative_page", "page=-1"},
|
||||
{"zero_page", "page=0"},
|
||||
{"non_numeric_page", "page=abc"},
|
||||
{"huge_page", "page=99999999999"},
|
||||
{"int_overflow_page", "page=9223372036854775808"}, // int64 max + 1
|
||||
{"negative_per_page", "per_page=-1"},
|
||||
{"zero_per_page", "per_page=0"},
|
||||
{"per_page_cap_at_500", "per_page=500"},
|
||||
{"per_page_above_cap", "per_page=501"},
|
||||
{"per_page_absurd", "per_page=1000000"},
|
||||
{"non_numeric_per_page", "per_page=xyz"},
|
||||
{"mixed_numeric_per_page", "per_page=10abc"},
|
||||
{"negative_page_size", "page_size=-1"},
|
||||
{"page_size_above_cap", "page_size=501"},
|
||||
{"float_page", "page=1.5"},
|
||||
{"exponent_page", "page=1e10"},
|
||||
{"hex_page", "page=0xff"},
|
||||
{"unicode_digits_page", "page=\u0661\u0662\u0663"}, // Arabic-Indic digits
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||
// Sanity: page/perPage on the filter must never be negative
|
||||
// and perPage must never exceed 500 after parsing.
|
||||
if filter.Page < 1 {
|
||||
t.Errorf("filter.Page=%d (must be >=1)", filter.Page)
|
||||
}
|
||||
if filter.PerPage < 1 || filter.PerPage > 500 {
|
||||
t.Errorf("filter.PerPage=%d (must be in [1,500])", filter.PerPage)
|
||||
}
|
||||
return []domain.ManagedCertificate{}, 0, nil
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||||
|
||||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("%s: expected 200, got %d (body=%q)", tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCertificates_SortAbuse verifies the sort field (which feeds into a
|
||||
// whitelist in the repository layer) handles adversarial input safely at the
|
||||
// handler boundary. The handler accepts the raw value and forwards it; the
|
||||
// repository is expected to whitelist it, but at THIS layer we just verify
|
||||
// we don't crash or leak.
|
||||
func TestListCertificates_SortAbuse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
}{
|
||||
{"sql_injection_sort", "sort=notAfter;DROP TABLE managed_certificates--"},
|
||||
{"sql_injection_or", "sort=notAfter' OR '1'='1"},
|
||||
{"path_traversal_sort", "sort=../../etc/passwd"},
|
||||
{"null_byte_sort", "sort=notAfter%00"},
|
||||
{"unicode_sort", "sort=notAfter\u2010desc"},
|
||||
{"leading_dash_only", "sort=-"},
|
||||
{"leading_dashes", "sort=---notAfter"},
|
||||
{"empty_sort", "sort="},
|
||||
{"very_long_sort", "sort=" + strings.Repeat("a", 5000)},
|
||||
{"sort_desc_flag", "sort=notAfter&sort_desc=true"},
|
||||
{"conflicting_sort_desc", "sort=-notAfter&sort_desc=false"},
|
||||
{"unknown_field", "sort=gibberish"},
|
||||
{"shell_metacharacters_sort", "sort=notAfter;rm -rf /"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||
return []domain.ManagedCertificate{}, 0, nil
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||||
|
||||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCertificates_FieldsAbuse verifies sparse field projection handles
|
||||
// adversarial field lists safely.
|
||||
func TestListCertificates_FieldsAbuse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
}{
|
||||
{"sql_injection_fields", "fields=id,name' OR 1=1--"},
|
||||
{"path_traversal_fields", "fields=../../etc/passwd"},
|
||||
{"empty_fields", "fields="},
|
||||
{"single_comma", "fields=,"},
|
||||
{"trailing_comma", "fields=id,name,"},
|
||||
{"leading_comma", "fields=,id,name"},
|
||||
{"whitespace_fields", "fields= id , name "},
|
||||
{"duplicate_fields", "fields=id,id,id,id,id"},
|
||||
{"unknown_fields", "fields=totally_not_a_field"},
|
||||
{"many_fields", "fields=" + strings.Repeat("x,", 200) + "id"},
|
||||
{"unicode_fields", "fields=id,n\u00e4me"},
|
||||
{"null_byte_fields", "fields=id%00name"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||
return []domain.ManagedCertificate{}, 0, nil
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||||
|
||||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCertificates_TimeRangeAbuse verifies RFC3339 time-range filters
|
||||
// handle malformed input by silently falling back to no filter (current
|
||||
// behaviour).
|
||||
func TestListCertificates_TimeRangeAbuse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
}{
|
||||
{"invalid_expires_before", "expires_before=not-a-date"},
|
||||
{"empty_expires_before", "expires_before="},
|
||||
{"garbage_expires_before", "expires_before=%00%00"},
|
||||
{"sql_injection_time", "expires_before=2026-01-01T00:00:00Z';DROP TABLE managed_certificates--"},
|
||||
{"year_zero", "expires_before=0000-01-01T00:00:00Z"},
|
||||
{"year_negative", "expires_before=-0001-01-01T00:00:00Z"},
|
||||
{"year_huge", "expires_before=99999-12-31T23:59:59Z"},
|
||||
{"invalid_month", "expires_before=2026-13-01T00:00:00Z"},
|
||||
{"invalid_day", "expires_before=2026-02-30T00:00:00Z"},
|
||||
{"valid_utc", "expires_before=2026-06-15T12:00:00Z"},
|
||||
{"valid_with_offset", "expires_before=2026-06-15T12:00:00-07:00"},
|
||||
{"unix_seconds_not_rfc3339", "expires_before=1767225600"},
|
||||
{"all_four_filters", "expires_before=garbage&expires_after=garbage&created_after=garbage&updated_after=garbage"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||
return []domain.ManagedCertificate{}, 0, nil
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||||
|
||||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("%s: expected 200, got %d", tc.name, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCertificates_CursorAbuse exercises cursor-based pagination with
|
||||
// adversarial cursor tokens. The handler forwards the cursor to the
|
||||
// repository; we verify no 500 at the boundary and that the response type
|
||||
// switches correctly.
|
||||
func TestListCertificates_CursorAbuse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cursor string
|
||||
}{
|
||||
{"empty_not_set", ""}, // special-cased: should return PagedResponse
|
||||
{"garbage_cursor", "not-a-valid-cursor"},
|
||||
{"base64_garbage", "dGhpcyBpcyBub3QgYSB2YWxpZCBjdXJzb3I="},
|
||||
{"sql_injection_cursor", "2026-01-01T00:00:00Z:mc-001';DROP TABLE--"},
|
||||
{"path_traversal_cursor", "../../etc/passwd"},
|
||||
{"null_byte_cursor", "valid%00cursor"},
|
||||
{"very_long_cursor", strings.Repeat("A", 8192)},
|
||||
{"unicode_cursor", "2026-01-01T00:00:00Z:mc\u20100001"},
|
||||
{"valid_looking_cursor", "2026-01-01T00:00:00.000000000Z:mc-001"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on %q: %v", tc.cursor, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||
return []domain.ManagedCertificate{}, 0, nil
|
||||
}
|
||||
|
||||
rawQuery := "cursor=" + url.QueryEscape(tc.cursor) + "&page_size=50"
|
||||
if tc.cursor == "" {
|
||||
rawQuery = "page=1&per_page=50"
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListCertificates(w, buildListRequest(rawQuery))
|
||||
|
||||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("%s: expected 200, got %d", tc.name, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCertificates_FilterInjection verifies the basic string filters
|
||||
// (status, environment, owner_id, team_id, issuer_id, agent_id, profile_id)
|
||||
// are forwarded as-is without causing any handler-layer failures. These go
|
||||
// into parameterized SQL at the repo layer.
|
||||
func TestListCertificates_FilterInjection(t *testing.T) {
|
||||
filters := []string{
|
||||
"status", "environment", "owner_id", "team_id",
|
||||
"issuer_id", "agent_id", "profile_id",
|
||||
}
|
||||
payloads := []string{
|
||||
"' OR 1=1--",
|
||||
"'; DROP TABLE managed_certificates;--",
|
||||
"../../etc/passwd",
|
||||
strings.Repeat("A", 5000),
|
||||
"\u2010hyphen",
|
||||
"%00null",
|
||||
}
|
||||
|
||||
for _, f := range filters {
|
||||
for _, p := range payloads {
|
||||
name := f + "__" + p
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||
return []domain.ManagedCertificate{}, 0, nil
|
||||
}
|
||||
|
||||
rawQuery := f + "=" + url.QueryEscape(p)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListCertificates(w, buildListRequest(rawQuery))
|
||||
|
||||
assertSafeResponse(t, w, "ListCertificates/"+f)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Request body abuse (Tier 1D) ----------
|
||||
|
||||
// TestCreateCertificate_BodyAbuse sends adversarial JSON bodies to
|
||||
// POST /api/v1/certificates. Every case must respond with 400 (not 500,
|
||||
// not 200). This proves we reject malformed input before reaching the
|
||||
// service layer.
|
||||
func TestCreateCertificate_BodyAbuse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"null_body", "null"},
|
||||
{"empty_body", ""},
|
||||
{"not_json", "not json at all"},
|
||||
{"truncated_json", `{"common_name":"exa`},
|
||||
{"unclosed_object", `{"common_name":"example.com"`},
|
||||
{"array_not_object", `["example.com"]`},
|
||||
{"number_not_object", `42`},
|
||||
{"string_not_object", `"hello"`},
|
||||
{"boolean_not_object", `true`},
|
||||
{"duplicate_keys", `{"common_name":"evil.com","common_name":"example.com"}`},
|
||||
{"unicode_bom", "\ufeff{\"common_name\":\"example.com\"}"},
|
||||
{"deep_nesting", strings.Repeat("{\"x\":", 100) + "null" + strings.Repeat("}", 100)},
|
||||
{"nested_array_bomb", `{"common_name":"x","sans":[[[[[[[[[[]]]]]]]]]]}`},
|
||||
{"sql_injection_cn", `{"common_name":"'; DROP TABLE managed_certificates;--"}`},
|
||||
{"empty_cn", `{"common_name":""}`},
|
||||
{"null_cn", `{"common_name":null}`},
|
||||
{"whitespace_cn", `{"common_name":" "}`},
|
||||
{"cn_too_long", fmt.Sprintf(`{"common_name":%q}`, strings.Repeat("a", 500))},
|
||||
{"cn_path_traversal", `{"common_name":"../../etc/passwd"}`},
|
||||
{"cn_null_byte", "{\"common_name\":\"example\\u0000.com\"}"},
|
||||
{"cn_newline", "{\"common_name\":\"example\\n.com\"}"},
|
||||
{"cn_only_missing_others", `{"common_name":"example.com"}`},
|
||||
{"extra_unknown_fields", `{"common_name":"example.com","__proto__":{"polluted":true},"eval":"alert(1)"}`},
|
||||
{"unicode_homoglyph_cn", "{\"common_name\":\"ex\u0430mple.com\"}"}, // Cyrillic а
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on %q: %v", tc.name, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.CreateCertificateFn = func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
// If we ever reach this, the handler accepted a malformed
|
||||
// body. Return a sentinel that passes but flag it.
|
||||
c := cert
|
||||
c.ID = "mc-accepted"
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
handler.CreateCertificate(w, req)
|
||||
|
||||
assertSafeResponse(t, w, "CreateCertificate/"+tc.name)
|
||||
// Must NOT be 201 — all these bodies should be rejected.
|
||||
if w.Code == http.StatusCreated {
|
||||
t.Errorf("%s: handler accepted malformed body (201) body=%q", tc.name, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateCertificate_HugeBody sends a 2 MiB JSON body. The body-limit
|
||||
// middleware is not in this handler-unit test, so we just verify the handler
|
||||
// doesn't OOM/panic on a large but well-formed body.
|
||||
func TestCreateCertificate_HugeBody(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on huge body: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2 MiB of SANs — well-formed JSON, technically valid, just huge.
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"common_name":"example.com","owner_id":"o","team_id":"t","issuer_id":"iss","name":"n","renewal_policy_id":"rp","sans":[`)
|
||||
for i := 0; i < 20000; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&sb, `"host%d.example.com"`, i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.CreateCertificateFn = func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
c := cert
|
||||
c.ID = "mc-huge"
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", strings.NewReader(sb.String()))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
handler.CreateCertificate(w, req)
|
||||
|
||||
assertSafeResponse(t, w, "CreateCertificate/huge_body")
|
||||
}
|
||||
|
||||
// ---------- Revocation reason abuse (Tier 1E) ----------
|
||||
|
||||
// TestRevokeCertificate_ReasonAbuse sends adversarial revocation reasons to
|
||||
// POST /api/v1/certificates/{id}/revoke. The handler forwards the reason
|
||||
// string to the service layer, which validates against RFC 5280. Errors
|
||||
// from the service containing "invalid revocation reason" must map to 400,
|
||||
// never 500.
|
||||
func TestRevokeCertificate_ReasonAbuse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"empty_reason", `{"reason":""}`},
|
||||
{"null_reason", `{"reason":null}`},
|
||||
{"nonexistent_reason", `{"reason":"totally made up"}`},
|
||||
{"case_variant", `{"reason":"KEYCOMPROMISE"}`},
|
||||
{"with_spaces", `{"reason":"key compromise"}`},
|
||||
{"with_dashes", `{"reason":"key-compromise"}`},
|
||||
{"mixed_case", `{"reason":"KeyCompromise"}`},
|
||||
{"lowercase_valid", `{"reason":"keycompromise"}`},
|
||||
{"unicode_homoglyph", "{\"reason\":\"keyCompr\u043emise\"}"},
|
||||
{"sql_injection", `{"reason":"keyCompromise';DROP TABLE revocations--"}`},
|
||||
{"very_long", fmt.Sprintf(`{"reason":%q}`, strings.Repeat("a", 10000))},
|
||||
{"integer_reason", `{"reason":1}`},
|
||||
{"array_reason", `{"reason":["keyCompromise"]}`},
|
||||
{"object_reason", `{"reason":{"code":1}}`},
|
||||
{"extra_fields", `{"reason":"keyCompromise","admin":true,"bypass":true}`},
|
||||
{"no_body", ``},
|
||||
{"malformed_json", `{"reason":`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panicked on %q: %v", tc.name, r)
|
||||
}
|
||||
}()
|
||||
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
// The mock always returns "invalid revocation reason" so we
|
||||
// verify the handler's errMsg→status mapping turns it into a 400.
|
||||
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||||
// The service uses domain.IsValidRevocationReason. If we got
|
||||
// through to here with something bogus, simulate a real
|
||||
// service error.
|
||||
return fmt.Errorf("invalid revocation reason: %q", reason)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-001/revoke", bytes.NewBufferString(tc.body))
|
||||
req.URL.Path = "/api/v1/certificates/mc-001/revoke"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
handler.RevokeCertificate(w, req)
|
||||
|
||||
assertSafeResponse(t, w, "RevokeCertificate/"+tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeCertificate_AlreadyRevoked locks in the specific error->status
|
||||
// mapping for "already revoked". The handler uses substring matching on the
|
||||
// service error message, which is fragile — this test catches regressions.
|
||||
func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||||
return fmt.Errorf("cannot revoke: certificate is already revoked")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-001/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
||||
req.URL.Path = "/api/v1/certificates/mc-001/revoke"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
handler.RevokeCertificate(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for already-revoked, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
assertSafeResponse(t, w, "RevokeCertificate/already_revoked")
|
||||
}
|
||||
|
||||
// TestRevokeCertificate_NotFound verifies 404 mapping.
|
||||
func TestRevokeCertificate_NotFound(t *testing.T) {
|
||||
handler, mock := newCertHandlerWithMock()
|
||||
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||||
return fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
||||
req.URL.Path = "/api/v1/certificates/mc-missing/revoke"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
handler.RevokeCertificate(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for not-found, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
assertSafeResponse(t, w, "RevokeCertificate/not_found")
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (m *MockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*do
|
||||
if m.GetAgentGroupFn != nil {
|
||||
return m.GetAgentGroupFn(id)
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||
}
|
||||
|
||||
func (m *MockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"errors"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
@@ -160,7 +162,7 @@ func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
return
|
||||
}
|
||||
@@ -188,7 +190,7 @@ func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// MockAgentService is a mock implementation of AgentService interface.
|
||||
@@ -24,6 +25,11 @@ type MockAgentService struct {
|
||||
GetWorkFn func(agentID string) ([]domain.Job, error)
|
||||
GetWorkWithTargetsFn func(agentID string) ([]domain.WorkItem, error)
|
||||
UpdateJobStatusFn func(agentID string, jobID string, status string, errMsg string) error
|
||||
// I-004: soft-retirement hooks. Tests that don't set these receive nil
|
||||
// results and nil errors, which mirrors the safest default (no-op) for
|
||||
// unrelated suites that mock only the legacy surface.
|
||||
RetireAgentFn func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error)
|
||||
ListRetiredAgentsFn func(page, perPage int) ([]domain.Agent, int64, error)
|
||||
}
|
||||
|
||||
func (m *MockAgentService) ListAgents(_ context.Context, page, perPage int) ([]domain.Agent, int64, error) {
|
||||
@@ -96,6 +102,25 @@ func (m *MockAgentService) UpdateJobStatus(_ context.Context, agentID string, jo
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetireAgent is the I-004 soft-retirement entrypoint. Tests that don't set
|
||||
// RetireAgentFn get a nil result + nil error, which is a no-op response that
|
||||
// lets unrelated suites compile without caring about the retirement surface.
|
||||
func (m *MockAgentService) RetireAgent(_ context.Context, agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
if m.RetireAgentFn != nil {
|
||||
return m.RetireAgentFn(agentID, actor, force, reason)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ListRetiredAgents returns retired rows for the retired-agents tab / audit
|
||||
// views. Same zero-value default as RetireAgent for unrelated tests.
|
||||
func (m *MockAgentService) ListRetiredAgents(_ context.Context, page, perPage int) ([]domain.Agent, int64, error) {
|
||||
if m.ListRetiredAgentsFn != nil {
|
||||
return m.ListRetiredAgentsFn(page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// Test ListAgents - success case
|
||||
func TestListAgents_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
@@ -868,3 +893,161 @@ func TestAgentReportJobStatus_ServiceError(t *testing.T) {
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// G-2 (P1): cat-s5-apikey_leak audit closure tests. Pre-G-2,
|
||||
// Agent.APIKeyHash was tagged `json:"api_key_hash"` and shipped on
|
||||
// every wire surface that returned domain.Agent. Post-G-2 the tag is
|
||||
// "-" and Agent.MarshalJSON enforces redaction via a marshal-time copy
|
||||
// (see internal/domain/connector_test.go for the type-level pin). These
|
||||
// four tests are the wire-shape contract — they capture the actual HTTP
|
||||
// response body via httptest and assert the credential-derivative hash
|
||||
// is absent.
|
||||
//
|
||||
// One sentinel value (g2HandlerLeakSentinel) flows through every fixture
|
||||
// so a single grep over a failing test's output identifies the leak
|
||||
// surface immediately.
|
||||
const g2HandlerLeakSentinel = "sha256:LEAKED-CREDENTIAL-DERIVATIVE-HANDLER-SENTINEL"
|
||||
|
||||
func TestListAgents_DoesNotLeakAPIKeyHash(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockAgentService{
|
||||
ListAgentsFn: func(page, perPage int) ([]domain.Agent, int64, error) {
|
||||
return []domain.Agent{
|
||||
{ID: "a-1", Name: "agent-one", Hostname: "host-1",
|
||||
Status: domain.AgentStatusOnline, RegisteredAt: now,
|
||||
APIKeyHash: g2HandlerLeakSentinel + "-1"},
|
||||
{ID: "a-2", Name: "agent-two", Hostname: "host-2",
|
||||
Status: domain.AgentStatusOnline, RegisteredAt: now,
|
||||
APIKeyHash: g2HandlerLeakSentinel + "-2"},
|
||||
}, 2, nil
|
||||
},
|
||||
}
|
||||
h := NewAgentHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?page=1&per_page=50", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.ListAgents(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListAgents status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if bytes.Contains([]byte(body), []byte("api_key_hash")) {
|
||||
t.Errorf("ListAgents response leaked \"api_key_hash\" key (G-2 regressed):\n%s", body)
|
||||
}
|
||||
if bytes.Contains([]byte(body), []byte(g2HandlerLeakSentinel)) {
|
||||
t.Errorf("ListAgents response leaked sentinel %q:\n%s", g2HandlerLeakSentinel, body)
|
||||
}
|
||||
// Sanity: the non-leaked fields ARE present (handler did serve real data).
|
||||
for _, want := range []string{"a-1", "a-2", "agent-one", "agent-two"} {
|
||||
if !bytes.Contains([]byte(body), []byte(want)) {
|
||||
t.Errorf("ListAgents response missing expected field %q (handler may not be serving data):\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgent_DoesNotLeakAPIKeyHash(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockAgentService{
|
||||
GetAgentFn: func(id string) (*domain.Agent, error) {
|
||||
return &domain.Agent{
|
||||
ID: id, Name: "single-agent", Hostname: "single.host",
|
||||
Status: domain.AgentStatusOnline, RegisteredAt: now,
|
||||
APIKeyHash: g2HandlerLeakSentinel,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewAgentHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.GetAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetAgent status = %d, want 200, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if bytes.Contains([]byte(body), []byte("api_key_hash")) {
|
||||
t.Errorf("GetAgent response leaked \"api_key_hash\" key:\n%s", body)
|
||||
}
|
||||
if bytes.Contains([]byte(body), []byte(g2HandlerLeakSentinel)) {
|
||||
t.Errorf("GetAgent response leaked sentinel:\n%s", body)
|
||||
}
|
||||
if !bytes.Contains([]byte(body), []byte("single-agent")) {
|
||||
t.Errorf("GetAgent response missing the agent name (handler may not be serving data):\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAgent_DoesNotLeakAPIKeyHash(t *testing.T) {
|
||||
// Registration is the most likely path for a freshly-hashed key to
|
||||
// leak: the service mints a new APIKeyHash inside RegisterAgent
|
||||
// (service/agent.go:405) and the handler returns the agent struct
|
||||
// verbatim. Pin that the redaction holds even on a "freshly created"
|
||||
// agent payload.
|
||||
now := time.Now()
|
||||
mock := &MockAgentService{
|
||||
RegisterAgentFn: func(in domain.Agent) (*domain.Agent, error) {
|
||||
return &domain.Agent{
|
||||
ID: "agent-new", Name: in.Name, Hostname: in.Hostname,
|
||||
Status: domain.AgentStatusOnline, RegisteredAt: now,
|
||||
APIKeyHash: g2HandlerLeakSentinel,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewAgentHandler(mock)
|
||||
body := bytes.NewBufferString(`{"name":"freshly-registered","hostname":"new.host"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", body)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.RegisterAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("RegisterAgent status = %d, want 201, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
respBody := w.Body.String()
|
||||
if bytes.Contains([]byte(respBody), []byte("api_key_hash")) {
|
||||
t.Errorf("RegisterAgent response leaked \"api_key_hash\" key:\n%s", respBody)
|
||||
}
|
||||
if bytes.Contains([]byte(respBody), []byte(g2HandlerLeakSentinel)) {
|
||||
t.Errorf("RegisterAgent response leaked sentinel:\n%s", respBody)
|
||||
}
|
||||
if !bytes.Contains([]byte(respBody), []byte("agent-new")) {
|
||||
t.Errorf("RegisterAgent response missing the new agent ID (handler may not be serving data):\n%s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRetiredAgents_DoesNotLeakAPIKeyHash(t *testing.T) {
|
||||
// I-004 surface — separate handler from ListAgents; same leak risk.
|
||||
now := time.Now()
|
||||
retiredAt := now.Add(-1 * time.Hour)
|
||||
reason := "test cascade"
|
||||
mock := &MockAgentService{
|
||||
ListRetiredAgentsFn: func(page, perPage int) ([]domain.Agent, int64, error) {
|
||||
return []domain.Agent{
|
||||
{ID: "ret-1", Name: "retired-one", Hostname: "host-r1",
|
||||
Status: domain.AgentStatusOffline, RegisteredAt: now,
|
||||
RetiredAt: &retiredAt, RetiredReason: &reason,
|
||||
APIKeyHash: g2HandlerLeakSentinel},
|
||||
}, 1, nil
|
||||
},
|
||||
}
|
||||
h := NewAgentHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/retired?page=1&per_page=50", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.ListRetiredAgents(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListRetiredAgents status = %d, want 200, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if bytes.Contains([]byte(body), []byte("api_key_hash")) {
|
||||
t.Errorf("ListRetiredAgents response leaked \"api_key_hash\" key:\n%s", body)
|
||||
}
|
||||
if bytes.Contains([]byte(body), []byte(g2HandlerLeakSentinel)) {
|
||||
t.Errorf("ListRetiredAgents response leaked sentinel:\n%s", body)
|
||||
}
|
||||
if !bytes.Contains([]byte(body), []byte("ret-1")) {
|
||||
t.Errorf("ListRetiredAgents response missing the retired agent ID:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// agentRetireTestSetup builds an AgentHandler with a mock AgentService whose
|
||||
// RetireAgent / ListRetiredAgents / Heartbeat behavior is driven by the
|
||||
// returned mock. Keeps every I-004 handler test self-contained so a single
|
||||
// failing assertion can't cascade through a shared fixture.
|
||||
func agentRetireTestSetup() (*MockAgentService, AgentHandler) {
|
||||
mock := &MockAgentService{}
|
||||
handler := NewAgentHandler(mock)
|
||||
return mock, handler
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_Success_200 pins the happy-path contract for the
|
||||
// soft-retirement HTTP surface: DELETE /api/v1/agents/{id} with no dependency
|
||||
// fallout returns 200 OK and a JSON body echoing retirement metadata
|
||||
// (retired_at timestamp, already_retired=false, cascade=false, zero counts).
|
||||
// Operators building dashboards parse these fields; keep the shape stable.
|
||||
func TestRetireAgentHandler_Success_200(t *testing.T) {
|
||||
retiredAt := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
|
||||
mock, handler := agentRetireTestSetup()
|
||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
if agentID != "a-prod-001" {
|
||||
t.Fatalf("retire handler received agentID=%q want a-prod-001", agentID)
|
||||
}
|
||||
if force {
|
||||
t.Fatalf("retire handler set force=true unexpectedly; default path must be force=false")
|
||||
}
|
||||
return &service.AgentRetirementResult{
|
||||
AlreadyRetired: false,
|
||||
Cascade: false,
|
||||
RetiredAt: retiredAt,
|
||||
Counts: domain.AgentDependencyCounts{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/a-prod-001", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s want 200", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
RetiredAt time.Time `json:"retired_at"`
|
||||
AlreadyRetired bool `json:"already_retired"`
|
||||
Cascade bool `json:"cascade"`
|
||||
Counts domain.AgentDependencyCounts `json:"counts"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode 200 body: %v", err)
|
||||
}
|
||||
if !body.RetiredAt.Equal(retiredAt) {
|
||||
t.Errorf("retired_at=%v want %v", body.RetiredAt, retiredAt)
|
||||
}
|
||||
if body.AlreadyRetired {
|
||||
t.Errorf("already_retired=true want false on clean retire")
|
||||
}
|
||||
if body.Cascade {
|
||||
t.Errorf("cascade=true want false on clean retire")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_AlreadyRetired_204 covers the idempotent contract: a
|
||||
// retire call against an already-retired agent completes with 204 No Content
|
||||
// (no body). This lets operators safely re-issue the DELETE after a network
|
||||
// blip without fearing duplicate audit events or state mutations.
|
||||
func TestRetireAgentHandler_AlreadyRetired_204(t *testing.T) {
|
||||
mock, handler := agentRetireTestSetup()
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
return &service.AgentRetirementResult{
|
||||
AlreadyRetired: true,
|
||||
Cascade: false,
|
||||
RetiredAt: past,
|
||||
Counts: domain.AgentDependencyCounts{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/a-prod-001", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status=%d body=%s want 204", w.Code, w.Body.String())
|
||||
}
|
||||
// 204 No Content must have zero body. If anything leaks through, downstream
|
||||
// clients (curl scripts, dashboards) break.
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("204 body=%q want empty", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_Sentinel_403 covers the hard guard against retiring
|
||||
// any of the four sentinel agents that back discovery sources and the
|
||||
// network scanner. These IDs are reserved; the handler must surface the
|
||||
// service-layer ErrAgentIsSentinel as 403 Forbidden regardless of force/reason
|
||||
// because no operator intent can legitimately retire them.
|
||||
func TestRetireAgentHandler_Sentinel_403(t *testing.T) {
|
||||
sentinels := []string{"server-scanner", "cloud-aws-sm", "cloud-azure-kv", "cloud-gcp-sm"}
|
||||
for _, id := range sentinels {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
mock, handler := agentRetireTestSetup()
|
||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
return nil, service.ErrAgentIsSentinel
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/"+id, nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("sentinel %q status=%d body=%s want 403", id, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_NotFound_404 covers the lookup-miss path. Service
|
||||
// returns a not-found error; handler maps to 404. Keeping the error
|
||||
// discrimination at the service layer (sentinel errors.Is) rather than string
|
||||
// matching is the whole point of wrapping.
|
||||
func TestRetireAgentHandler_NotFound_404(t *testing.T) {
|
||||
mock, handler := agentRetireTestSetup()
|
||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
// S-2 closure (cat-s6-efc7f6f6bd50): wrap repository.ErrNotFound
|
||||
// so the handler's errors.Is dispatch resolves to 404.
|
||||
return nil, ErrMockNotFound
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/unknown-id", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d body=%s want 404", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_Blocked_409_WithCounts covers the preflight-blocked
|
||||
// path. Service returns *BlockedByDependenciesError wrapping
|
||||
// ErrBlockedByDependencies; handler unwraps via errors.As, maps to 409, and
|
||||
// MUST include the counts in the response body so operators know what's
|
||||
// blocking them. Without counts the 409 is useless — the operator has to
|
||||
// guess which downstream dependency is holding up the retirement.
|
||||
func TestRetireAgentHandler_Blocked_409_WithCounts(t *testing.T) {
|
||||
mock, handler := agentRetireTestSetup()
|
||||
blockCounts := domain.AgentDependencyCounts{
|
||||
ActiveTargets: 3,
|
||||
ActiveCertificates: 7,
|
||||
PendingJobs: 2,
|
||||
}
|
||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
return nil, &service.BlockedByDependenciesError{Counts: blockCounts}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/a-prod-001", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s want 409", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Counts domain.AgentDependencyCounts `json:"counts"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode 409 body: %v", err)
|
||||
}
|
||||
if body.Counts.ActiveTargets != 3 {
|
||||
t.Errorf("counts.active_targets=%d want 3", body.Counts.ActiveTargets)
|
||||
}
|
||||
if body.Counts.ActiveCertificates != 7 {
|
||||
t.Errorf("counts.active_certificates=%d want 7", body.Counts.ActiveCertificates)
|
||||
}
|
||||
if body.Counts.PendingJobs != 2 {
|
||||
t.Errorf("counts.pending_jobs=%d want 2", body.Counts.PendingJobs)
|
||||
}
|
||||
if body.Message == "" {
|
||||
t.Errorf("409 body missing human-readable message; operators need guidance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_Force_NoReason_400 covers the force-escape-hatch
|
||||
// guardrail: force=true without a non-empty reason must be rejected at the
|
||||
// handler seam BEFORE the service performs any DB work, because a
|
||||
// reason-less cascade is unauditable. Service returns ErrForceReasonRequired;
|
||||
// handler maps to 400.
|
||||
func TestRetireAgentHandler_Force_NoReason_400(t *testing.T) {
|
||||
mock, handler := agentRetireTestSetup()
|
||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
if !force {
|
||||
t.Fatalf("handler did not forward force=true; force query param was dropped")
|
||||
}
|
||||
if reason != "" {
|
||||
t.Fatalf("handler passed reason=%q; empty reason must reach service for error path", reason)
|
||||
}
|
||||
return nil, service.ErrForceReasonRequired
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/a-prod-001?force=true", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s want 400", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_ForceCascade_200 covers the successful force-cascade
|
||||
// path: DELETE ?force=true&reason=... → service executes transactional
|
||||
// cascade → 200 with cascade=true and the pre-cascade counts echoed back so
|
||||
// the operator's confirmation dialog can show "I just retired N targets,
|
||||
// M certificates, K pending jobs."
|
||||
func TestRetireAgentHandler_ForceCascade_200(t *testing.T) {
|
||||
mock, handler := agentRetireTestSetup()
|
||||
retiredAt := time.Date(2026, 4, 18, 14, 30, 0, 0, time.UTC)
|
||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||
if !force {
|
||||
t.Fatalf("handler did not forward force=true; query-param parsing broken")
|
||||
}
|
||||
if reason != "decommissioning rack 7" {
|
||||
t.Fatalf("handler forwarded reason=%q want %q", reason, "decommissioning rack 7")
|
||||
}
|
||||
return &service.AgentRetirementResult{
|
||||
AlreadyRetired: false,
|
||||
Cascade: true,
|
||||
RetiredAt: retiredAt,
|
||||
Counts: domain.AgentDependencyCounts{
|
||||
ActiveTargets: 2,
|
||||
ActiveCertificates: 5,
|
||||
PendingJobs: 1,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
url := "/api/v1/agents/a-prod-001?force=true&reason=decommissioning+rack+7"
|
||||
req := httptest.NewRequest(http.MethodDelete, url, nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s want 200", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
RetiredAt time.Time `json:"retired_at"`
|
||||
AlreadyRetired bool `json:"already_retired"`
|
||||
Cascade bool `json:"cascade"`
|
||||
Counts domain.AgentDependencyCounts `json:"counts"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode force-cascade 200 body: %v", err)
|
||||
}
|
||||
if !body.Cascade {
|
||||
t.Errorf("cascade=false want true on ?force=true successful retire")
|
||||
}
|
||||
if body.Counts.ActiveTargets != 2 || body.Counts.ActiveCertificates != 5 || body.Counts.PendingJobs != 1 {
|
||||
t.Errorf("counts=%+v want {ActiveTargets:2 ActiveCertificates:5 PendingJobs:1}", body.Counts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatHandler_RetiredAgent_410 covers the agent-shutdown signal. A
|
||||
// retired agent that is still polling must be told its identity is gone
|
||||
// (410 Gone) rather than offered the normal 200 "recorded" response.
|
||||
// cmd/agent treats 410 as a terminal signal and exits rather than looping
|
||||
// forever against a decommissioned identity. Service returns ErrAgentRetired;
|
||||
// handler maps to 410.
|
||||
func TestHeartbeatHandler_RetiredAgent_410(t *testing.T) {
|
||||
mock, handler := agentRetireTestSetup()
|
||||
mock.HeartbeatFn = func(agentID string, metadata *domain.AgentMetadata) error {
|
||||
return service.ErrAgentRetired
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/heartbeat", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.Heartbeat(w, req)
|
||||
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("heartbeat(retired) status=%d body=%s want 410", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRetiredAgentsHandler_Success covers the audit/forensics-facing
|
||||
// endpoint GET /api/v1/agents/retired. Returns a paged list of retired rows
|
||||
// alongside total count so the GUI can render a "Retired Agents" tab with
|
||||
// pagination. Default listing (GET /agents) hides retired rows; this is the
|
||||
// opt-in surface for them.
|
||||
func TestListRetiredAgentsHandler_Success(t *testing.T) {
|
||||
past := time.Now().Add(-48 * time.Hour)
|
||||
reason := "old hardware"
|
||||
retired := []domain.Agent{
|
||||
{
|
||||
ID: "agent-retired-01",
|
||||
Name: "decom-01",
|
||||
Hostname: "server-old",
|
||||
Status: domain.AgentStatusOffline,
|
||||
RegisteredAt: past,
|
||||
RetiredAt: &past,
|
||||
RetiredReason: &reason,
|
||||
},
|
||||
}
|
||||
|
||||
mock, handler := agentRetireTestSetup()
|
||||
mock.ListRetiredAgentsFn = func(page, perPage int) ([]domain.Agent, int64, error) {
|
||||
if page != 1 || perPage != 50 {
|
||||
t.Fatalf("ListRetired handler received page=%d perPage=%d want 1/50 defaults", page, perPage)
|
||||
}
|
||||
return retired, 1, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/retired", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListRetiredAgents(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s want 200", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode list-retired body: %v", err)
|
||||
}
|
||||
if response.Total != 1 {
|
||||
t.Errorf("total=%d want 1", response.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetireAgentHandler_MethodNotAllowed covers defense-in-depth: only
|
||||
// DELETE is valid on /api/v1/agents/{id} for retirement. Using POST/PUT/PATCH
|
||||
// must be rejected with 405 so misconfigured callers don't accidentally
|
||||
// trigger retirement via a wrong-method request.
|
||||
func TestRetireAgentHandler_MethodNotAllowed(t *testing.T) {
|
||||
_, handler := agentRetireTestSetup()
|
||||
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
req := httptest.NewRequest(method, "/api/v1/agents/a-prod-001", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RetireAgent(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("method=%s status=%d want 405", method, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time asserts: the mock must satisfy the handler's AgentService
|
||||
// interface. Red state: this fails until the interface grows RetireAgent +
|
||||
// ListRetiredAgents. Once Phase 2b adds those methods to AgentService, this
|
||||
// assertion goes green along with every test above.
|
||||
var _ AgentService = (*MockAgentService)(nil)
|
||||
|
||||
// Unused-import suppressor for context — the package-level tests already
|
||||
// pull context from agent_handler_test.go, but leaving this here documents
|
||||
// that the mock methods receive context.Context values even though this
|
||||
// file's tests don't construct them directly (they ride on httptest.NewRequest).
|
||||
var _ = context.Background
|
||||
@@ -1,17 +1,27 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// AgentService defines the service interface for agent operations.
|
||||
//
|
||||
// I-004 expansion: RetireAgent + ListRetiredAgents back the soft-retirement
|
||||
// surface. The handler depends on the service-package's AgentRetirementResult
|
||||
// and BlockedByDependenciesError types for result shape + errors.As unwrap,
|
||||
// which is why this file imports internal/service.
|
||||
type AgentService interface {
|
||||
ListAgents(ctx context.Context, page, perPage int) ([]domain.Agent, int64, error)
|
||||
GetAgent(ctx context.Context, id string) (*domain.Agent, error)
|
||||
@@ -23,6 +33,10 @@ type AgentService interface {
|
||||
GetWork(ctx context.Context, agentID string) ([]domain.Job, error)
|
||||
GetWorkWithTargets(ctx context.Context, agentID string) ([]domain.WorkItem, error)
|
||||
UpdateJobStatus(ctx context.Context, agentID string, jobID string, status string, errMsg string) error
|
||||
// I-004 soft-retirement API. Both default to no-op (nil result / nil error)
|
||||
// in mocks that don't override them — handler tests opt in per suite.
|
||||
RetireAgent(ctx context.Context, agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error)
|
||||
ListRetiredAgents(ctx context.Context, page, perPage int) ([]domain.Agent, int64, error)
|
||||
}
|
||||
|
||||
// AgentHandler handles HTTP requests for agent operations.
|
||||
@@ -134,6 +148,11 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
created, err := h.svc.RegisterAgent(r.Context(), agent)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, "Agent with this name already exists", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||
return
|
||||
}
|
||||
@@ -184,6 +203,20 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.Heartbeat(r.Context(), agentID, metadata); err != nil {
|
||||
// I-004: a retired agent still polling must receive 410 Gone so
|
||||
// cmd/agent detects the terminal signal and shuts down cleanly
|
||||
// instead of looping forever against a decommissioned identity.
|
||||
// Check this FIRST — before "not found" string matching — so the
|
||||
// retired-path is never masked by a sibling error branch.
|
||||
if errors.Is(err, service.ErrAgentRetired) {
|
||||
ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("Heartbeat failed", "agent_id", agentID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
||||
return
|
||||
}
|
||||
@@ -241,6 +274,7 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("CSR submission failed", "agent_id", agentID, "certificate_id", req.CertificateID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to submit CSR", requestID)
|
||||
return
|
||||
}
|
||||
@@ -263,9 +297,10 @@ func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Requ
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract agent ID and certificate ID from path /api/v1/agents/{id}/certificates/{cert_id}
|
||||
// After TrimPrefix, path is "{id}/certificates/{cert_id}" → split gives [id, "certificates", cert_id]
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 4 || parts[0] == "" || parts[2] == "" {
|
||||
if len(parts) < 3 || parts[0] == "" || parts[2] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID and Certificate ID are required", requestID)
|
||||
return
|
||||
}
|
||||
@@ -363,3 +398,181 @@ func (h AgentHandler) AgentReportJobStatus(w http.ResponseWriter, r *http.Reques
|
||||
"status": "updated",
|
||||
})
|
||||
}
|
||||
|
||||
// RetireAgent executes the I-004 soft-retirement surface.
|
||||
// DELETE /api/v1/agents/{id}[?force=true&reason=...]
|
||||
//
|
||||
// Contract (pinned by agent_retire_handler_test.go):
|
||||
//
|
||||
// 405 any method other than DELETE
|
||||
// 200 clean retire (body: retired_at, already_retired=false, cascade=false, counts=0s)
|
||||
// 200 force-cascade retire (body: cascade=true, counts=pre-cascade snapshot)
|
||||
// 204 idempotent retire of an already-retired agent (NO body — downstream
|
||||
// clients that tee responses into dashboards break on spurious bodies)
|
||||
// 400 force=true without a non-empty reason (ErrForceReasonRequired)
|
||||
// 403 one of the four reserved sentinel IDs (ErrAgentIsSentinel)
|
||||
// 404 agent does not exist ("not found" string match, kept for compat with
|
||||
// repo error strings; sentinel checks run first so they never mask)
|
||||
// 409 blocked by preflight counts (*BlockedByDependenciesError) — body
|
||||
// carries the per-bucket counts so the operator UI can tell the
|
||||
// human which downstream dependency is holding up the retirement,
|
||||
// rather than forcing them to re-run the DELETE with ?force=true
|
||||
// and guess
|
||||
// 500 anything else
|
||||
//
|
||||
// The 409 body intentionally does NOT go through ErrorWithRequestID because
|
||||
// that helper's ErrorResponse shape has no `counts` field — we inline-marshal
|
||||
// a custom body instead. Keeping this shape stable is important: the GUI
|
||||
// pattern is "show the 409 dialog, list the N targets / M certs / K jobs
|
||||
// blocking, let the operator retire them first or tick the force checkbox."
|
||||
func (h AgentHandler) RetireAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract {id} from /api/v1/agents/{id}. Mirror GetAgent's pattern so
|
||||
// the path parser is identical across the agent handler surface and a
|
||||
// future refactor can extract it once without introducing drift.
|
||||
rawID := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
||||
parts := strings.Split(rawID, "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID is required", requestID)
|
||||
return
|
||||
}
|
||||
id := parts[0]
|
||||
|
||||
// Parse optional force + reason. A missing `force` param is treated as
|
||||
// force=false (the default, safe path); anything strconv.ParseBool rejects
|
||||
// is also force=false so a malformed query can never silently enable the
|
||||
// cascade. The reason string is passed through verbatim — the service
|
||||
// owns the "force=true requires reason" rule.
|
||||
query := r.URL.Query()
|
||||
force := false
|
||||
if fv := query.Get("force"); fv != "" {
|
||||
if parsed, err := strconv.ParseBool(fv); err == nil {
|
||||
force = parsed
|
||||
}
|
||||
}
|
||||
reason := query.Get("reason")
|
||||
|
||||
actor := resolveActor(r.Context())
|
||||
|
||||
result, err := h.svc.RetireAgent(r.Context(), id, actor, force, reason)
|
||||
if err != nil {
|
||||
// Sentinel + typed-error checks run BEFORE string matching on "not
|
||||
// found" so a repo error that happens to contain those words can
|
||||
// never mask a structural refusal (403/400/409). Order matters.
|
||||
if errors.Is(err, service.ErrAgentIsSentinel) {
|
||||
ErrorWithRequestID(w, http.StatusForbidden, "Agent is a reserved sentinel and cannot be retired", requestID)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrForceReasonRequired) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "force=true requires a non-empty reason", requestID)
|
||||
return
|
||||
}
|
||||
var blocked *service.BlockedByDependenciesError
|
||||
if errors.As(err, &blocked) {
|
||||
// Custom 409 body with per-bucket counts. ErrorResponse has no
|
||||
// `counts` field, so we marshal a bespoke struct instead.
|
||||
// Keep `error`/`message`/`counts` as the stable shape — any
|
||||
// dashboard parsing this relies on those three keys.
|
||||
body := struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Counts domain.AgentDependencyCounts `json:"counts"`
|
||||
}{
|
||||
Error: "blocked_by_dependencies",
|
||||
Message: "Agent has active downstream dependencies. Retire or reassign them " +
|
||||
"first, or re-run with ?force=true&reason=... to cascade.",
|
||||
Counts: blocked.Counts,
|
||||
}
|
||||
JSON(w, http.StatusConflict, body)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("RetireAgent failed", "agent_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to retire agent", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotent retire: the agent was already retired, so we return 204 No
|
||||
// Content with a ZERO-length body. The Red contract (test line 106) fails
|
||||
// if even a trailing newline leaks into the response. WriteHeader alone
|
||||
// emits the status without invoking the JSON encoder.
|
||||
if result.AlreadyRetired {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Clean retire (force=false) or successful cascade (force=true). Body
|
||||
// shape pinned by Red contract: retired_at, already_retired, cascade,
|
||||
// counts. Omitempty is deliberately NOT used — operators parsing the
|
||||
// response expect every field to always be present.
|
||||
JSON(w, http.StatusOK, struct {
|
||||
RetiredAt time.Time `json:"retired_at"`
|
||||
AlreadyRetired bool `json:"already_retired"`
|
||||
Cascade bool `json:"cascade"`
|
||||
Counts domain.AgentDependencyCounts `json:"counts"`
|
||||
}{
|
||||
RetiredAt: result.RetiredAt,
|
||||
AlreadyRetired: result.AlreadyRetired,
|
||||
Cascade: result.Cascade,
|
||||
Counts: result.Counts,
|
||||
})
|
||||
}
|
||||
|
||||
// ListRetiredAgents returns the opt-in listing of retired agents for the
|
||||
// operator UI's "Retired" tab and for audit/forensics workflows.
|
||||
// GET /api/v1/agents/retired?page=1&per_page=50
|
||||
//
|
||||
// The default ListAgents handler hides retired rows; this is the dedicated
|
||||
// surface for reading them back. Pagination defaults match ListAgents so
|
||||
// the GUI can reuse the same query hook (page=1, per_page=50, cap 500).
|
||||
//
|
||||
// Go 1.22's enhanced ServeMux routes `/agents/retired` to this handler via
|
||||
// the literal-beats-pattern-var precedence rule (literal `retired` wins over
|
||||
// `{id}` in the sibling GET /api/v1/agents/{id} route), so both entries can
|
||||
// coexist without conflict. If that precedence ever regresses, the failure
|
||||
// mode is TestListRetiredAgentsHandler_Success blowing up with a 404 — which
|
||||
// is the fast signal we want.
|
||||
func (h AgentHandler) ListRetiredAgents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
page := 1
|
||||
perPage := 50
|
||||
query := r.URL.Query()
|
||||
if p := query.Get("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
if pp := query.Get("per_page"); pp != "" {
|
||||
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
|
||||
perPage = parsed
|
||||
}
|
||||
}
|
||||
|
||||
agents, total, err := h.svc.ListRetiredAgents(r.Context(), page, perPage)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list retired agents", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, PagedResponse{
|
||||
Data: agents,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -11,8 +12,8 @@ import (
|
||||
|
||||
// AuditService defines the service interface for audit event operations.
|
||||
type AuditService interface {
|
||||
ListAuditEvents(page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
GetAuditEvent(id string) (*domain.AuditEvent, error)
|
||||
ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
GetAuditEvent(ctx context.Context, id string) (*domain.AuditEvent, error)
|
||||
}
|
||||
|
||||
// AuditHandler handles HTTP requests for audit event operations.
|
||||
@@ -49,7 +50,7 @@ func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
events, total, err := h.svc.ListAuditEvents(page, perPage)
|
||||
events, total, err := h.svc.ListAuditEvents(r.Context(), page, perPage)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID)
|
||||
return
|
||||
@@ -83,7 +84,7 @@ func (h AuditHandler) GetAuditEvent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id = parts[0]
|
||||
|
||||
event, err := h.svc.GetAuditEvent(id)
|
||||
event, err := h.svc.GetAuditEvent(r.Context(), id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Audit event not found", requestID)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
)
|
||||
|
||||
// mockAuditService implements AuditService for testing.
|
||||
type mockAuditService struct {
|
||||
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
getFunc func(id string) (*domain.AuditEvent, error)
|
||||
}
|
||||
|
||||
func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
if m.listFunc != nil {
|
||||
return m.listFunc(page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.AuditEvent, error) {
|
||||
if m.getFunc != nil {
|
||||
return m.getFunc(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestListAuditEvents_Success(t *testing.T) {
|
||||
events := []domain.AuditEvent{
|
||||
{
|
||||
ID: "ev-1",
|
||||
Action: "certificate_issued",
|
||||
Actor: "user@example.com",
|
||||
ActorType: domain.ActorTypeUser,
|
||||
ResourceID: "mc-api-prod",
|
||||
ResourceType: "Certificate",
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "ev-2",
|
||||
Action: "certificate_renewed",
|
||||
Actor: "user@example.com",
|
||||
ActorType: domain.ActorTypeUser,
|
||||
ResourceID: "mc-api-prod",
|
||||
ResourceType: "Certificate",
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc := &mockAuditService{
|
||||
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
if page != 1 || perPage != 50 {
|
||||
t.Errorf("ListAuditEvents called with page=%d, perPage=%d, expected 1, 50", page, perPage)
|
||||
}
|
||||
return events, 2, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
// Add request ID to context
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListAuditEvents(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusOK {
|
||||
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
||||
}
|
||||
|
||||
var result PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.Total != 2 {
|
||||
t.Errorf("Total = %d, want 2", result.Total)
|
||||
}
|
||||
|
||||
if result.Page != 1 {
|
||||
t.Errorf("Page = %d, want 1", result.Page)
|
||||
}
|
||||
|
||||
if result.PerPage != 50 {
|
||||
t.Errorf("PerPage = %d, want 50", result.PerPage)
|
||||
}
|
||||
|
||||
// Check data is present
|
||||
if result.Data == nil {
|
||||
t.Error("Data is nil, want events slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditEvents_WithPagination(t *testing.T) {
|
||||
events := []domain.AuditEvent{
|
||||
{
|
||||
ID: "ev-5",
|
||||
Action: "certificate_issued",
|
||||
Actor: "user@example.com",
|
||||
ActorType: domain.ActorTypeUser,
|
||||
ResourceID: "mc-api-prod",
|
||||
ResourceType: "Certificate",
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc := &mockAuditService{
|
||||
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
if page != 2 || perPage != 25 {
|
||||
t.Errorf("ListAuditEvents called with page=%d, perPage=%d, expected 2, 25", page, perPage)
|
||||
}
|
||||
return events, 100, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit?page=2&per_page=25", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListAuditEvents(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusOK {
|
||||
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
||||
}
|
||||
|
||||
var result PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.Page != 2 {
|
||||
t.Errorf("Page = %d, want 2", result.Page)
|
||||
}
|
||||
|
||||
if result.PerPage != 25 {
|
||||
t.Errorf("PerPage = %d, want 25", result.PerPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditEvents_PerPageMaxLimit(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
// Should be capped at 500
|
||||
if perPage > 500 {
|
||||
t.Errorf("perPage = %d, expected <= 500", perPage)
|
||||
}
|
||||
return []domain.AuditEvent{}, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit?per_page=1000", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListAuditEvents(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusOK {
|
||||
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
||||
}
|
||||
|
||||
var result PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.PerPage > 500 {
|
||||
t.Errorf("PerPage = %d, want <= 500", result.PerPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditEvents_EmptyResult(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
return []domain.AuditEvent{}, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListAuditEvents(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusOK {
|
||||
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
||||
}
|
||||
|
||||
var result PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditEvents_ServiceError(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
return nil, 0, errors.New("database error")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListAuditEvents(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
||||
t.Fatalf("failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if errResp.Message != "Failed to list audit events" {
|
||||
t.Errorf("Message = %q, want 'Failed to list audit events'", errResp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditEvents_MethodNotAllowed(t *testing.T) {
|
||||
mockSvc := &mockAuditService{}
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "/api/v1/audit", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ListAuditEvents(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusMethodNotAllowed {
|
||||
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuditEvent_Success(t *testing.T) {
|
||||
event := &domain.AuditEvent{
|
||||
ID: "ev-123",
|
||||
Action: "certificate_issued",
|
||||
Actor: "user@example.com",
|
||||
ActorType: domain.ActorTypeUser,
|
||||
ResourceID: "mc-api-prod",
|
||||
ResourceType: "Certificate",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
mockSvc := &mockAuditService{
|
||||
getFunc: func(id string) (*domain.AuditEvent, error) {
|
||||
if id != "ev-123" {
|
||||
t.Errorf("GetAuditEvent called with id=%q, expected ev-123", id)
|
||||
}
|
||||
return event, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/ev-123", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetAuditEvent(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusOK {
|
||||
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusOK)
|
||||
}
|
||||
|
||||
var result domain.AuditEvent
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.ID != "ev-123" {
|
||||
t.Errorf("ID = %q, want ev-123", result.ID)
|
||||
}
|
||||
|
||||
if result.Action != "certificate_issued" {
|
||||
t.Errorf("Action = %q, want certificate_issued", result.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuditEvent_NotFound(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
getFunc: func(id string) (*domain.AuditEvent, error) {
|
||||
return nil, errors.New("not found")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/nonexistent", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetAuditEvent(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusNotFound {
|
||||
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
||||
t.Fatalf("failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if errResp.Message != "Audit event not found" {
|
||||
t.Errorf("Message = %q, want 'Audit event not found'", errResp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuditEvent_MethodNotAllowed(t *testing.T) {
|
||||
mockSvc := &mockAuditService{}
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, "/api/v1/audit/ev-123", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetAuditEvent(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuditEvent_EmptyID(t *testing.T) {
|
||||
mockSvc := &mockAuditService{}
|
||||
handler := NewAuditHandler(mockSvc)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetAuditEvent(w, req)
|
||||
|
||||
if status := w.Code; status != http.StatusBadRequest {
|
||||
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
||||
t.Fatalf("failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if errResp.Message != "Audit event ID is required" {
|
||||
t.Errorf("Message = %q, want 'Audit event ID is required'", errResp.Message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// BulkReassignmentService defines the service interface for bulk
|
||||
// owner-reassignment operations.
|
||||
type BulkReassignmentService interface {
|
||||
BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
|
||||
}
|
||||
|
||||
// BulkReassignmentHandler handles HTTP requests for bulk reassignment
|
||||
// operations.
|
||||
type BulkReassignmentHandler struct {
|
||||
svc BulkReassignmentService
|
||||
}
|
||||
|
||||
// NewBulkReassignmentHandler creates a new BulkReassignmentHandler.
|
||||
func NewBulkReassignmentHandler(svc BulkReassignmentService) BulkReassignmentHandler {
|
||||
return BulkReassignmentHandler{svc: svc}
|
||||
}
|
||||
|
||||
// bulkReassignRequest is the JSON shape decoded from the request body.
|
||||
type bulkReassignRequest struct {
|
||||
CertificateIDs []string `json:"certificate_ids"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
}
|
||||
|
||||
// BulkReassign handles POST /api/v1/certificates/bulk-reassign
|
||||
//
|
||||
// L-2 closure (cat-l-8a1fb258a38a): pre-L-2 the GUI looped
|
||||
// `await updateCertificate(id, { owner_id })`. Post-L-2 the GUI POSTs
|
||||
// once and the server mutates owner_id (and optionally team_id) on N
|
||||
// certs, returning per-cert success/skip/error counts.
|
||||
//
|
||||
// Narrower contract than bulk-renew: explicit IDs only, no criteria-mode.
|
||||
// OwnerID is required; TeamID is optional and updates the team only when
|
||||
// non-empty (matches the existing per-cert PUT contract).
|
||||
//
|
||||
// Auth: any authenticated caller can reassign certs they own/have
|
||||
// access to. NOT admin-gated — operators reassign ownership during
|
||||
// team transitions all the time and gating that on admin would block
|
||||
// the common-case workflow.
|
||||
//
|
||||
// Validation order: empty body → 400; empty IDs → 400; missing
|
||||
// owner_id → 400; non-existent owner_id → 400 via the
|
||||
// ErrBulkReassignOwnerNotFound sentinel mapped here.
|
||||
func (h BulkReassignmentHandler) BulkReassign(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
var req bulkReassignRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
request := domain.BulkReassignmentRequest{
|
||||
CertificateIDs: req.CertificateIDs,
|
||||
OwnerID: req.OwnerID,
|
||||
TeamID: req.TeamID,
|
||||
}
|
||||
if request.IsEmpty() {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||
"At least one certificate_id is required",
|
||||
requestID)
|
||||
return
|
||||
}
|
||||
if request.OwnerID == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "owner_id is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
actor := resolveActor(r.Context())
|
||||
|
||||
result, err := h.svc.BulkReassign(r.Context(), request, actor)
|
||||
if err != nil {
|
||||
// Sentinel-error → 400 mapping. ErrBulkReassignOwnerNotFound
|
||||
// means the operator picked an owner that doesn't exist; this
|
||||
// is bad input (400), not a server error (500). Mirrors the
|
||||
// post-M-1 errToStatus convention rather than substring-matching
|
||||
// err.Error().
|
||||
if errors.Is(err, service.ErrBulkReassignOwnerNotFound) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk reassignment failed: "+err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user