mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 16:38:52 +00:00
Compare commits
201 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7292fd8c3f | |||
| 879ed17879 | |||
| c69d5bb07a | |||
| 95d0d85391 | |||
| 9383b2ce35 | |||
| 30ac7910c2 | |||
| b911646e53 | |||
| 92afe359e9 | |||
| 86643cc4af | |||
| 03eecaa42c | |||
| d9cc6dacb1 | |||
| 3a84432eeb | |||
| 5d96f965bc | |||
| 41a8f5853e | |||
| e7f976408b | |||
| 9581fe85ce | |||
| e453677038 | |||
| 0c1bccd2dc | |||
| bdc9f71dec | |||
| 52b86a08f4 | |||
| 0d3e50da43 | |||
| c22ce0fcd2 | |||
| 18e46f091e | |||
| 29d853d641 | |||
| 9a785e0534 | |||
| 834389621c | |||
| a942ebd58d | |||
| 8fa61fd7ba | |||
| d61b4f744a | |||
| 1fc3e688a6 | |||
| 0e21c1779c | |||
| 12adc97381 | |||
| 9fa022c80f | |||
| 52a9e4977c | |||
| 55f61d46e7 | |||
| 8fd2715e9b | |||
| a4eee00bcf | |||
| a5c4f42ec9 | |||
| 5d99229a65 | |||
| 00168e009e | |||
| 480feac7ad | |||
| b676888242 | |||
| 894530beef | |||
| 876f6bd48d | |||
| 5fc25878b8 | |||
| 54d93e6376 | |||
| 585456f947 | |||
| 213b464d95 | |||
| 1b6d4af339 | |||
| 190a27e824 | |||
| 9e877d2fde | |||
| ec3772d4e3 | |||
| 8dc58df1c1 | |||
| ee25f00207 | |||
| 62fcf59604 | |||
| e0a3d50f5e | |||
| e9f809b7f9 | |||
| 2057e76706 | |||
| 0b58662e9a | |||
| 6b5af27546 | |||
| 0fbd5b850f | |||
| 389f6b8233 | |||
| 15140854de | |||
| 8aff1c16f8 | |||
| 6f4574409b | |||
| 12003f5ca5 | |||
| 87086fbe33 | |||
| 1b4de3fb2d | |||
| f4fc83d8d6 | |||
| e720474fb7 | |||
| 6cd3135f90 | |||
| 46800f3365 | |||
| 1500137bf1 | |||
| 62a412c488 | |||
| e6422bc483 | |||
| a172b6ed3b | |||
| 1530ff0ee9 | |||
| 45ba27693b | |||
| 212571463b | |||
| 30f9f1e712 | |||
| f609270cea | |||
| 521802f824 | |||
| 8b218a9198 | |||
| 1dcc7455cd | |||
| 6a8654869a | |||
| c63cba164a | |||
| be52d72c88 | |||
| 1c3a83c4ba | |||
| a03534d1e4 | |||
| 3292bd8877 | |||
| e11cdda135 | |||
| 694e52eb3e | |||
| 81e62689f0 | |||
| 1d6c7a0552 | |||
| a2a82a6cf8 | |||
| 1a845a9490 | |||
| 260a1af9a9 | |||
| 85e60b24ec | |||
| 018b705b91 | |||
| 0233f39e53 | |||
| 23411bd6fc | |||
| 9d769efbb9 | |||
| 2352dfa0a6 | |||
| 1c099071d1 | |||
| d84ff36854 | |||
| 050b936fcf | |||
| 90bfa5d320 | |||
| 8fd11e024b | |||
| 7013227a34 | |||
| 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 |
+25
-4
@@ -13,22 +13,43 @@ POSTGRES_PASSWORD=change-me-in-production
|
|||||||
# Certctl Server
|
# Certctl Server
|
||||||
# All server vars use the CERTCTL_ prefix (see internal/config/config.go)
|
# 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_HOST=0.0.0.0
|
||||||
CERTCTL_SERVER_PORT=8443
|
CERTCTL_SERVER_PORT=8443
|
||||||
CERTCTL_LOG_LEVEL=info
|
CERTCTL_LOG_LEVEL=info
|
||||||
CERTCTL_LOG_FORMAT=json
|
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
|
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
|
# Generate with: openssl rand -base64 32
|
||||||
# CERTCTL_AUTH_SECRET=change-me-in-production
|
# CERTCTL_AUTH_SECRET=change-me-in-production
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Certctl Agent
|
# 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_API_KEY=change-me-in-production
|
||||||
CERTCTL_AGENT_NAME=local-agent
|
CERTCTL_AGENT_NAME=local-agent
|
||||||
|
|
||||||
|
|||||||
+1197
-6
File diff suppressed because it is too large
Load Diff
+292
-43
@@ -7,40 +7,30 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
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:
|
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:
|
build-binaries:
|
||||||
name: Build Cross-Platform Binaries
|
name: Build ${{ matrix.binary }} (${{ matrix.os }}/${{ matrix.arch }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
|
id-token: write # Cosign keyless OIDC identity token
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
binary: [agent, server, cli, mcp-server]
|
||||||
# Agent binaries (4 platforms)
|
os: [linux, darwin]
|
||||||
- os: linux
|
arch: [amd64, arm64]
|
||||||
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
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -51,35 +41,191 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
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: Install govulncheck
|
||||||
|
# Bundle D / Audit L-008: release.yml previously had no vulnerability
|
||||||
|
# scan, so a release tag could in principle ship a binary with a
|
||||||
|
# known CVE in transitive deps that ci.yml's govulncheck would have
|
||||||
|
# caught on master. Pre-build scan blocks the release if anything
|
||||||
|
# surfaced post-merge. Pinned to the same major as ci.yml.
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
|
||||||
|
- name: Run govulncheck (release gate)
|
||||||
|
# govulncheck distinguishes called-vs-uncalled vulnerable functions.
|
||||||
|
# Default exit code (0 unless an actual call site lands in a vuln
|
||||||
|
# function) is the right gate for release; deferred-call advisories
|
||||||
|
# are tracked separately on master via L-021. If a release-time
|
||||||
|
# scan surfaces a NEW called-vuln, the release is blocked until the
|
||||||
|
# bump lands on master and a new tag is cut.
|
||||||
|
run: govulncheck ./...
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
id: build
|
||||||
env:
|
env:
|
||||||
GOOS: ${{ matrix.os }}
|
GOOS: ${{ matrix.os }}
|
||||||
GOARCH: ${{ matrix.arch }}
|
GOARCH: ${{ matrix.arch }}
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: '0'
|
||||||
|
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
OUTPUT_NAME="certctl-${{ matrix.binary }}-${{ matrix.os }}-${{ matrix.arch }}"
|
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}" \
|
-o "dist/${OUTPUT_NAME}" \
|
||||||
"./cmd/${{ matrix.binary }}"
|
"./cmd/${{ matrix.binary }}"
|
||||||
ls -lh "dist/${OUTPUT_NAME}"
|
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
|
uses: softprops/action-gh-release@v2
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
dist/certctl-agent-*
|
artifacts/certctl-*
|
||||||
dist/certctl-server-*
|
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:
|
build-and-push-docker:
|
||||||
name: Build & Push Docker Images
|
name: Build & Push Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
id-token: write # Cosign keyless OIDC identity token
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -93,20 +239,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Install Cosign
|
||||||
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||||
|
|
||||||
- name: Build and push server image
|
- name: Build and push server image
|
||||||
|
id: server-push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||||
${{ env.REGISTRY }}/shankar0123/certctl-server:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:latest
|
||||||
# Proxy propagation (M-4, Issue #9) — forwards runner-level proxy
|
# Proxy propagation (M-4, Issue #9) — forwards runner-level proxy
|
||||||
# secrets into the Docker build so self-hosted runners behind
|
# secrets into the Docker build so self-hosted runners behind
|
||||||
# corporate proxies can reach public registries. GitHub-hosted
|
# corporate proxies can reach public registries. GitHub-hosted
|
||||||
@@ -117,18 +267,31 @@ jobs:
|
|||||||
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
||||||
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
||||||
NO_PROXY=${{ secrets.NO_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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
- name: Build and push agent image
|
||||||
|
id: agent-push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.agent
|
file: ./Dockerfile.agent
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||||
${{ env.REGISTRY }}/shankar0123/certctl-agent:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:latest
|
||||||
# Proxy propagation (M-4, Issue #9) — see server-image step for
|
# Proxy propagation (M-4, Issue #9) — see server-image step for
|
||||||
# rationale. Empty secrets resolve to empty build args, leaving
|
# rationale. Empty secrets resolve to empty build args, leaving
|
||||||
# the un-proxied code path byte-identical to the pre-fix tree.
|
# the un-proxied code path byte-identical to the pre-fix tree.
|
||||||
@@ -136,14 +299,30 @@ jobs:
|
|||||||
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
||||||
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
||||||
NO_PROXY=${{ secrets.NO_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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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:
|
create-release:
|
||||||
name: Create Release Notes
|
name: Create Release Notes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-binaries, build-and-push-docker]
|
needs: [build-binaries, aggregate-checksums, provenance-binaries, build-and-push-docker]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -152,7 +331,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
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
|
- name: Create release with notes
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -214,6 +393,76 @@ jobs:
|
|||||||
|
|
||||||
- **Linux x86_64**: `certctl-server-linux-amd64`
|
- **Linux x86_64**: `certctl-server-linux-amd64`
|
||||||
- **Linux ARM64**: `certctl-server-linux-arm64`
|
- **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
|
## Helm Chart
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
name: security-deep-scan
|
||||||
|
|
||||||
|
# Bundle-7 / Audit D-001..D-007:
|
||||||
|
# Slow / containerized scans on a daily schedule + manual dispatch.
|
||||||
|
# Per-PR fast gates live in ci.yml; this workflow runs the heavyweight
|
||||||
|
# tools that need docker, network egress to scanner registries, or
|
||||||
|
# longer wall-clock budgets than a per-PR check tolerates.
|
||||||
|
#
|
||||||
|
# Scope:
|
||||||
|
# trivy image container CVE + secret scan
|
||||||
|
# syft SBOM CycloneDX SBOM artefact upload
|
||||||
|
# ZAP baseline DAST baseline against a live deploy_test stack (D-004)
|
||||||
|
# nuclei template-based vuln scan against the same stack
|
||||||
|
# schemathesis OpenAPI fuzz against the running server
|
||||||
|
# testssl.sh TLS configuration audit (D-005)
|
||||||
|
# race detector x10 full -count=10 race run on the entire test suite (D-002)
|
||||||
|
# gosec Go security static analysis (slow first run)
|
||||||
|
# go-mutesting mutation testing on crypto cluster (D-003)
|
||||||
|
# semgrep p/react-security frontend XSS / dangerouslySetInnerHTML / target=_blank ruleset (D-007)
|
||||||
|
#
|
||||||
|
# Each step is best-effort — failures are uploaded as artefacts but do
|
||||||
|
# NOT block the workflow. Triage happens via the Bundle-7 receipt
|
||||||
|
# directory under cowork/comprehensive-audit-2026-04-25/tool-output/.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * *' # daily 06:00 UTC
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # SARIF upload to GitHub code scanning
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deep-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
|
||||||
|
- name: Install Go-based tools
|
||||||
|
run: bash scripts/install-security-tools.sh
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# --- Static analysis (slow paths) ---
|
||||||
|
|
||||||
|
- name: gosec
|
||||||
|
run: |
|
||||||
|
$(go env GOPATH)/bin/gosec -fmt sarif -out gosec.sarif ./... || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: osv-scanner (multi-ecosystem CVE)
|
||||||
|
run: |
|
||||||
|
$(go env GOPATH)/bin/osv-scanner -r --format json --output osv-scanner.json . || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# --- Race detector at -count=10 (D-002) ---
|
||||||
|
|
||||||
|
- name: go test -race -count=10 (full suite)
|
||||||
|
run: |
|
||||||
|
go test -race -count=10 -short ./... 2>&1 | tee go-test-race.txt
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# --- Coverage receipts for crypto cluster (H-005) ---
|
||||||
|
|
||||||
|
- name: go test -cover (crypto cluster)
|
||||||
|
run: |
|
||||||
|
go test -cover -covermode=atomic \
|
||||||
|
./internal/crypto/... \
|
||||||
|
./internal/pkcs7/... \
|
||||||
|
./internal/connector/issuer/local/... \
|
||||||
|
2>&1 | tee go-test-cover.txt
|
||||||
|
|
||||||
|
# --- Mutation testing on crypto cluster (D-003) ---
|
||||||
|
#
|
||||||
|
# Operator runbook: docs/testing-strategy.md::Mutation testing.
|
||||||
|
# Tool: go-mutesting (https://github.com/zimmski/go-mutesting). Each
|
||||||
|
# package is mutated independently; the per-package summary line
|
||||||
|
# (`The mutation score is X.YZ`) is grep-extracted into the receipt.
|
||||||
|
# Acceptance threshold: ≥80% kill ratio per package; surviving
|
||||||
|
# mutants get triaged in cowork/comprehensive-audit-2026-04-25/
|
||||||
|
# d003-mutation-results.md (per-mutant action item or
|
||||||
|
# equivalent-mutation justification).
|
||||||
|
|
||||||
|
- name: Install go-mutesting
|
||||||
|
run: go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: go-mutesting (crypto cluster)
|
||||||
|
run: |
|
||||||
|
: > go-mutesting.txt
|
||||||
|
for pkg in ./internal/crypto/... ./internal/pkcs7/... ./internal/connector/issuer/local/...; do
|
||||||
|
echo "=== $pkg ===" | tee -a go-mutesting.txt
|
||||||
|
$(go env GOPATH)/bin/go-mutesting "$pkg" 2>&1 | tee -a go-mutesting.txt || true
|
||||||
|
done
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# --- Container + supply chain (D-001 partial, D-006 partial) ---
|
||||||
|
|
||||||
|
- name: Build certctl image
|
||||||
|
run: docker build -t certctl:deep-scan .
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: trivy image scan
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD":/src aquasec/trivy:latest image \
|
||||||
|
--format json --output /src/trivy.json certctl:deep-scan || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: syft SBOM
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD":/src anchore/syft:latest dir:/src \
|
||||||
|
-o cyclonedx-json > syft.cyclonedx.json || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# --- DAST against a live stack (D-004) ---
|
||||||
|
|
||||||
|
- name: docker compose up (test stack)
|
||||||
|
run: |
|
||||||
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
|
sleep 20
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: ZAP baseline
|
||||||
|
uses: zaproxy/action-baseline@v0.10.0
|
||||||
|
with:
|
||||||
|
target: 'https://localhost:8443'
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: schemathesis (OpenAPI fuzz)
|
||||||
|
run: |
|
||||||
|
pip install schemathesis
|
||||||
|
schemathesis run --base-url https://localhost:8443 \
|
||||||
|
--hypothesis-max-examples=50 api/openapi.yaml || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: nuclei
|
||||||
|
run: |
|
||||||
|
docker run --rm --network host projectdiscovery/nuclei:latest \
|
||||||
|
-u https://localhost:8443 -j -o nuclei.json || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# --- TLS audit (D-005) ---
|
||||||
|
|
||||||
|
- name: testssl.sh
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD":/data drwetter/testssl.sh:latest \
|
||||||
|
--jsonfile /data/testssl.json https://localhost:8443 || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: docker compose down
|
||||||
|
run: docker compose -f deploy/docker-compose.yml down || true
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
# --- Frontend XSS / unsafe-link ruleset (D-007) ---
|
||||||
|
#
|
||||||
|
# Operator runbook: docs/testing-strategy.md::Frontend semgrep.
|
||||||
|
# Bundle 8 already verified `dangerouslySetInnerHTML` count at
|
||||||
|
# zero and the `target="_blank"` rel-noopener pin via grep
|
||||||
|
# guards in ci.yml — semgrep p/react-security adds defence in
|
||||||
|
# depth (it catches escape patterns the grep guards don't see,
|
||||||
|
# e.g., href={user_input}, eval, document.write).
|
||||||
|
|
||||||
|
- name: semgrep p/react-security (frontend)
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD":/src returntocorp/semgrep:latest \
|
||||||
|
semgrep --config=p/react-security --json /src/web/src \
|
||||||
|
> semgrep-react.json 2>semgrep-react.stderr || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# --- Upload everything as artefacts ---
|
||||||
|
|
||||||
|
- name: Upload deep-scan receipts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: security-deep-scan-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
gosec.sarif
|
||||||
|
osv-scanner.json
|
||||||
|
go-test-race.txt
|
||||||
|
go-test-cover.txt
|
||||||
|
go-mutesting.txt
|
||||||
|
trivy.json
|
||||||
|
syft.cyclonedx.json
|
||||||
|
nuclei.json
|
||||||
|
testssl.json
|
||||||
|
semgrep-react.json
|
||||||
|
semgrep-react.stderr
|
||||||
|
retention-days: 30
|
||||||
+18
-2
@@ -63,12 +63,28 @@ certctl-cli
|
|||||||
/server
|
/server
|
||||||
/agent
|
/agent
|
||||||
/cli
|
/cli
|
||||||
|
/mcp-server
|
||||||
|
|
||||||
# Private strategy docs
|
# Private strategy docs
|
||||||
strategy.md
|
|
||||||
SECURITY_REMEDIATION.md
|
SECURITY_REMEDIATION.md
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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:
|
linters:
|
||||||
default: none
|
default: none
|
||||||
enable:
|
enable:
|
||||||
|
- contextcheck
|
||||||
- govet
|
- govet
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unused
|
- unused
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Bundle-7 / Audit D-001 / govulncheck suppressions.
|
||||||
|
#
|
||||||
|
# Format: one OSV ID per line, with a comment justifying the suppression.
|
||||||
|
# Every entry needs:
|
||||||
|
# - the OSV ID (GO-YYYY-NNNN)
|
||||||
|
# - one-line "what is it"
|
||||||
|
# - one-line "why we're not affected" (must reference call-graph evidence)
|
||||||
|
# - "review-by" date (YYYY-MM-DD) — re-triage on/after this date
|
||||||
|
#
|
||||||
|
# Triage rule: only suppress an advisory if `govulncheck ./...` (NOT
|
||||||
|
# verbose) reports it as a deferred-call vulnerability ("packages you
|
||||||
|
# import" or "modules you require", not "Your code is affected by").
|
||||||
|
#
|
||||||
|
# At Bundle-7 time (2026-04-26): the 5 advisories surfaced are all in
|
||||||
|
# transitive deps and govulncheck confirms our code does not call them.
|
||||||
|
# Documented here for tracking; no entries needed because the default
|
||||||
|
# fail-on-non-zero gate already passes (govulncheck distinguishes
|
||||||
|
# called vs uncalled and only exits non-zero when the latter calls in).
|
||||||
|
#
|
||||||
|
# Example (do not enable unless the advisory becomes call-affected):
|
||||||
|
# GO-2026-4441 # transitive: golang.org/x/crypto pre-v0.40 — net/ssh terrapin downgrade; we don't use net/ssh; review 2026-07-01
|
||||||
+1300
File diff suppressed because it is too large
Load Diff
+68
-5
@@ -1,7 +1,28 @@
|
|||||||
# Multi-stage build for certctl server
|
# Multi-stage build for certctl server
|
||||||
|
#
|
||||||
|
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
|
||||||
|
# immutable digest in addition to the human-readable tag. The tag is
|
||||||
|
# advisory; the digest is what Docker actually pulls. A registry-side
|
||||||
|
# tag swap (the documented prior-art for tag-only pulls being unsafe)
|
||||||
|
# can no longer change the build.
|
||||||
|
#
|
||||||
|
# Bump procedure (operator):
|
||||||
|
# 1. Quarterly cadence (or sooner if a CVE lands on a base image).
|
||||||
|
# 2. For each FROM:
|
||||||
|
# docker pull <image>:<tag>
|
||||||
|
# docker manifest inspect <image>:<tag> | grep -m1 digest
|
||||||
|
# OR via Docker Hub Registry API:
|
||||||
|
# curl -sSL https://hub.docker.com/v2/repositories/library/<image>/tags/<tag> \
|
||||||
|
# | jq -r .digest
|
||||||
|
# 3. Replace the @sha256:... portion of the FROM line.
|
||||||
|
# 4. Run `docker build` locally + verify CI.
|
||||||
|
# 5. Commit with the bump procedure cited in the message body.
|
||||||
|
#
|
||||||
|
# The CI step "Forbidden bare FROM regression guard (H-001)" rejects
|
||||||
|
# any future commit that lands a FROM without an @sha256 pin.
|
||||||
|
|
||||||
# Stage 1: Build frontend
|
# Stage 1: Build frontend
|
||||||
FROM node:20-alpine AS frontend
|
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend
|
||||||
|
|
||||||
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
|
# 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`/
|
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
|
||||||
@@ -22,12 +43,27 @@ ENV HTTP_PROXY=${HTTP_PROXY} \
|
|||||||
WORKDIR /app/web
|
WORKDIR /app/web
|
||||||
|
|
||||||
COPY web/ .
|
COPY web/ .
|
||||||
RUN npm ci --include=dev || npm ci --include=dev && \
|
# Bundle A / Audit M-014: explicit retry loop for `npm ci`. Pre-bundle
|
||||||
|
# this was `npm ci || npm ci && tsc && build` — the bash precedence is
|
||||||
|
# `A || (B && C && D)` so the second `npm ci` only ran on the failure
|
||||||
|
# path of the first, but the `tsc && build` chain only ran on the
|
||||||
|
# success path of the second. Net effect: a transient registry blip
|
||||||
|
# turned the build into a silent skip of the production step.
|
||||||
|
#
|
||||||
|
# New shape: a deterministic 3-attempt retry with 5-second backoff and
|
||||||
|
# an explicit `[ -d node_modules ]` post-check so a silent failure is
|
||||||
|
# impossible.
|
||||||
|
RUN for i in 1 2 3; do \
|
||||||
|
npm ci --include=dev && break; \
|
||||||
|
echo "npm ci attempt $i failed; sleeping 5s before retry"; \
|
||||||
|
sleep 5; \
|
||||||
|
done && \
|
||||||
|
[ -d node_modules ] || (echo "ERROR: npm ci failed after 3 attempts; node_modules missing" && exit 1) && \
|
||||||
node_modules/.bin/tsc --version && \
|
node_modules/.bin/tsc --version && \
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Stage 2: Build Go binary
|
# Stage 2: Build Go binary
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.25-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder
|
||||||
|
|
||||||
# Proxy propagation (M-4, Issue #9) — see Stage 1 rationale.
|
# Proxy propagation (M-4, Issue #9) — see Stage 1 rationale.
|
||||||
ARG HTTP_PROXY=
|
ARG HTTP_PROXY=
|
||||||
@@ -57,7 +93,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
|||||||
./cmd/server
|
./cmd/server
|
||||||
|
|
||||||
# Stage 3: Runtime
|
# Stage 3: Runtime
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata curl
|
RUN apk add --no-cache ca-certificates tzdata curl
|
||||||
|
|
||||||
@@ -76,7 +112,34 @@ USER certctl
|
|||||||
|
|
||||||
EXPOSE 8443
|
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 \
|
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"]
|
ENTRYPOINT ["/app/server"]
|
||||||
|
|||||||
+30
-3
@@ -1,6 +1,11 @@
|
|||||||
# Multi-stage build for certctl agent
|
# Multi-stage build for certctl agent
|
||||||
|
#
|
||||||
|
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
|
||||||
|
# immutable digest. See Dockerfile (server) for the bump-procedure
|
||||||
|
# operator runbook; the pins here MUST be bumped in the same pass.
|
||||||
|
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.25-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder
|
||||||
|
|
||||||
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
|
# 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`/
|
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
|
||||||
@@ -34,9 +39,16 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
|||||||
./cmd/agent
|
./cmd/agent
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
||||||
|
|
||||||
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 && \
|
RUN addgroup -g 1000 certctl && \
|
||||||
adduser -D -u 1000 -G certctl certctl
|
adduser -D -u 1000 -G certctl certctl
|
||||||
@@ -51,4 +63,19 @@ RUN mkdir -p /var/lib/certctl/keys && \
|
|||||||
|
|
||||||
USER certctl
|
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"]
|
ENTRYPOINT ["/app/agent"]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
|||||||
managed, embedded, bundled, or integrated with
|
managed, embedded, bundled, or integrated with
|
||||||
another product or service.
|
another product or service.
|
||||||
|
|
||||||
Change Date: March 14, 2033
|
Change Date: March 14, 2126
|
||||||
|
|
||||||
Change License: Apache License, Version 2.0
|
Change License: Apache License, Version 2.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help build run test lint clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build
|
.PHONY: help build run test lint verify clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build qa-stats
|
||||||
|
|
||||||
# Default target - show help
|
# Default target - show help
|
||||||
help:
|
help:
|
||||||
@@ -15,6 +15,7 @@ help:
|
|||||||
@echo " make test-verbose Run tests with verbose output"
|
@echo " make test-verbose Run tests with verbose output"
|
||||||
@echo " make lint Run linter (golangci-lint)"
|
@echo " make lint Run linter (golangci-lint)"
|
||||||
@echo " make fmt Format code with gofmt"
|
@echo " make fmt Format code with gofmt"
|
||||||
|
@echo " make verify Pre-commit gate: fmt + vet + lint + test (CI-parity)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Database:"
|
@echo "Database:"
|
||||||
@echo " make migrate-up Run migrations (requires DB_URL)"
|
@echo " make migrate-up Run migrations (requires DB_URL)"
|
||||||
@@ -97,6 +98,24 @@ vet:
|
|||||||
@echo "Running go vet..."
|
@echo "Running go vet..."
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
|
# verify: aggregate pre-commit gate. Mirrors what CI enforces, so
|
||||||
|
# running `make verify` locally before committing prevents the
|
||||||
|
# class of breakages that ship green-locally / red-on-CI (e.g.
|
||||||
|
# Bundle-9's ST1018 invisible-Unicode-literal hits, which `go vet`
|
||||||
|
# alone cannot catch — staticcheck under golangci-lint does).
|
||||||
|
verify:
|
||||||
|
@echo "==> fmt"
|
||||||
|
@go fmt ./... | { ! grep -q '.'; } || (echo "gofmt produced changes — commit them" && exit 1)
|
||||||
|
@echo "==> go vet ./..."
|
||||||
|
@go vet ./...
|
||||||
|
@echo "==> golangci-lint run ./... (incl. staticcheck ST*)"
|
||||||
|
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
|
||||||
|
@golangci-lint run ./... --timeout 5m
|
||||||
|
@echo "==> go test -short ./..."
|
||||||
|
@go test -short -count=1 ./...
|
||||||
|
@echo ""
|
||||||
|
@echo "verify: PASS — safe to commit"
|
||||||
|
|
||||||
# Database targets (requires migrate tool)
|
# Database targets (requires migrate tool)
|
||||||
migrate-up:
|
migrate-up:
|
||||||
@echo "Running migrations..."
|
@echo "Running migrations..."
|
||||||
@@ -162,6 +181,29 @@ frontend-build:
|
|||||||
cd web && npm ci && npx vite build
|
cd web && npm ci && npx vite build
|
||||||
@echo "Frontend build complete"
|
@echo "Frontend build complete"
|
||||||
|
|
||||||
|
# QA Suite Stats — Bundle P / Strengthening #8.
|
||||||
|
# Single source-of-truth for every count claim in docs/qa-test-guide.md +
|
||||||
|
# docs/testing-guide.md. The Strengthening #6 CI drift guards consume the
|
||||||
|
# same numbers, eliminating the doc-drift class structurally.
|
||||||
|
qa-stats:
|
||||||
|
@echo "=== certctl QA Suite Stats ==="
|
||||||
|
@echo "Date: $$(date +%Y-%m-%d)"
|
||||||
|
@echo "HEAD: $$(git rev-parse HEAD 2>/dev/null || echo 'not-a-git-repo')"
|
||||||
|
@echo ""
|
||||||
|
@echo "Backend test files: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
@echo "Backend Test functions: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c '^func Test' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
|
||||||
|
@echo "Backend t.Run subtests: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c 't\.Run(' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
|
||||||
|
@echo "Frontend test files: $$(find web/src -name '*.test.ts' -o -name '*.test.tsx' 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
@echo "Fuzz targets: $$(grep -rE 'func Fuzz[A-Z]' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
@echo "t.Skip sites: $$(grep -rE 't\.Skip(Now|f)?\(' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
@echo "qa_test.go Part_ subtests: $$(grep -cE 't\.Run\(\"Part[0-9]+_' deploy/test/qa_test.go 2>/dev/null || echo 0)"
|
||||||
|
@echo "testing-guide.md Parts: $$(grep -cE '^## Part [0-9]+:' docs/testing-guide.md 2>/dev/null || echo 0)"
|
||||||
|
@echo "Seed unique mc-* IDs: $$(grep -oE "mc-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||||
|
@echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 12)"
|
||||||
|
@echo "Seed unique iss-* IDs: $$(grep -oE "iss-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (issuers table count is 13)"
|
||||||
|
@echo "Seed unique tgt-* IDs: $$(grep -oE "tgt-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||||
|
@echo "Seed unique nst-* IDs: $$(grep -oE "nst-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning build artifacts..."
|
@echo "Cleaning build artifacts..."
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ cd certctl
|
|||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser. The onboarding wizard walks you through connecting a CA, deploying an agent, and issuing your first certificate.
|
Wait ~30 seconds, then open **https://localhost:8443** in your browser. (The shipped `docker-compose.yml` self-signs a cert via the `certctl-tls-init` init container on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.) The onboarding wizard walks you through connecting a CA, deploying an agent, and issuing your first certificate.
|
||||||
|
|
||||||
**Want a pre-populated demo instead?** Add the demo override to see 32 certificates across 10 issuers, 8 agents, and 180 days of realistic history:
|
**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:
|
||||||
|
|
||||||
@@ -208,10 +208,12 @@ docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up
|
|||||||
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.
|
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
|
```bash
|
||||||
curl http://localhost:8443/health
|
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"}
|
# {"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)
|
### Agent Install (One-Liner)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -237,6 +239,74 @@ docker pull shankar0123.docker.scarf.sh/certctl-server
|
|||||||
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Verifying this release
|
||||||
|
|
||||||
|
Every `v*` tag publishes signed, attested release artefacts. Binaries
|
||||||
|
(`certctl-agent`, `certctl-server`, `certctl-cli`, `certctl-mcp-server` for
|
||||||
|
`linux|darwin × amd64|arm64`) ship alongside a `checksums.txt`, per-binary
|
||||||
|
SPDX-JSON SBOMs, Cosign signatures, and SLSA Level 3 provenance. Container
|
||||||
|
images on `ghcr.io/shankar0123/certctl-{server,agent}` are built with
|
||||||
|
`docker/build-push-action` `provenance: mode=max` + `sbom: true` and are
|
||||||
|
additionally signed with Cosign at the image digest.
|
||||||
|
|
||||||
|
All signatures use Cosign keyless OIDC; the signing identity is the
|
||||||
|
release workflow running on a signed tag.
|
||||||
|
|
||||||
|
**1. Verify SHA-256 checksums:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c checksums.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Verify the Cosign signature on `checksums.txt`:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cosign verify-blob \
|
||||||
|
--bundle checksums.txt.sigstore.json \
|
||||||
|
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||||
|
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||||
|
checksums.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Every individual binary ships with its own `.sigstore.json` bundle
|
||||||
|
(unified Sigstore bundle containing signature, certificate chain, and
|
||||||
|
Rekor inclusion proof). Swap `checksums.txt` for any binary name and
|
||||||
|
point `--bundle` at the matching `<binary>.sigstore.json` to verify it
|
||||||
|
directly.
|
||||||
|
|
||||||
|
**3. Verify SLSA Level 3 provenance on a binary:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
slsa-verifier verify-artifact \
|
||||||
|
--provenance-path multiple.intoto.jsonl \
|
||||||
|
--source-uri github.com/shankar0123/certctl \
|
||||||
|
--source-tag v2.1.0 \
|
||||||
|
certctl-agent-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Verify a container image signature and its SBOM / provenance attestations:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMAGE=ghcr.io/shankar0123/certctl-server:v2.1.0
|
||||||
|
|
||||||
|
cosign verify \
|
||||||
|
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||||
|
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||||
|
"$IMAGE"
|
||||||
|
|
||||||
|
# SBOM attestation (SPDX-JSON, emitted by docker/build-push-action)
|
||||||
|
cosign verify-attestation --type spdxjson \
|
||||||
|
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
|
||||||
|
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||||
|
"$IMAGE"
|
||||||
|
|
||||||
|
# SLSA provenance attestation (docker/build-push-action `provenance: mode=max`)
|
||||||
|
cosign verify-attestation --type slsaprovenance \
|
||||||
|
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
|
||||||
|
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||||
|
"$IMAGE"
|
||||||
|
```
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Pick the scenario closest to your setup and have it running in 2 minutes.
|
Pick the scenario closest to your setup and have it running in 2 minutes.
|
||||||
@@ -258,8 +328,9 @@ Each directory contains a `docker-compose.yml` and a `README.md` explaining the
|
|||||||
go install github.com/shankar0123/certctl/cmd/cli@latest
|
go install github.com/shankar0123/certctl/cmd/cli@latest
|
||||||
|
|
||||||
# Configure
|
# Configure
|
||||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
export CERTCTL_SERVER_URL=https://localhost:8443
|
||||||
export CERTCTL_API_KEY=your-api-key
|
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
|
# Usage
|
||||||
certctl-cli certs list # List all certificates
|
certctl-cli certs list # List all certificates
|
||||||
@@ -279,11 +350,14 @@ certctl ships a standalone MCP (Model Context Protocol) server that exposes all
|
|||||||
```bash
|
```bash
|
||||||
# Install and run
|
# Install and run
|
||||||
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
||||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
export CERTCTL_SERVER_URL=https://localhost:8443
|
||||||
export CERTCTL_API_KEY=your-api-key
|
export CERTCTL_API_KEY=your-api-key
|
||||||
|
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt # required for self-signed bootstrap
|
||||||
mcp-server
|
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`):
|
**Claude Desktop** (`claude_desktop_config.json`):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -291,8 +365,9 @@ mcp-server
|
|||||||
"certctl": {
|
"certctl": {
|
||||||
"command": "mcp-server",
|
"command": "mcp-server",
|
||||||
"env": {
|
"env": {
|
||||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
"CERTCTL_SERVER_URL": "https://localhost:8443",
|
||||||
"CERTCTL_API_KEY": "your-api-key"
|
"CERTCTL_API_KEY": "your-api-key",
|
||||||
|
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/ca.crt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,10 +402,22 @@ Kubernetes cert-manager external issuer, cloud infrastructure targets, extended
|
|||||||
|
|
||||||
## License
|
## 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 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.
|
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.
|
||||||
|
|
||||||
For licensing inquiries: certctl@proton.me
|
For licensing inquiries: certctl@proton.me
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Backend dependency footprint is auditable on demand:
|
||||||
|
|
||||||
|
```
|
||||||
|
go list -m all | wc -l # total module count (direct + transitive)
|
||||||
|
go mod why <path> # explain why a particular module is pulled in
|
||||||
|
govulncheck ./... # vulnerability scan (CI runs this on every commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
The release-time SBOM is published as a syft-produced cyclonedx file alongside each release artifact in `.github/workflows/release.yml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|||||||
+1204
-53
File diff suppressed because it is too large
Load Diff
+273
-31
@@ -7,6 +7,7 @@ import (
|
|||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -72,7 +73,7 @@ func TestAgent_Heartbeat_Success(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Should not panic
|
// Should not panic
|
||||||
agent.sendHeartbeat(context.Background())
|
agent.sendHeartbeat(context.Background())
|
||||||
@@ -93,7 +94,7 @@ func TestAgent_Heartbeat_ServerError(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Should increment consecutive failures
|
// Should increment consecutive failures
|
||||||
failureBefore := agent.consecutiveFailures
|
failureBefore := agent.consecutiveFailures
|
||||||
@@ -115,7 +116,7 @@ func TestAgent_Heartbeat_ConnectionError(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Should fail due to connection error
|
// Should fail due to connection error
|
||||||
agent.sendHeartbeat(context.Background())
|
agent.sendHeartbeat(context.Background())
|
||||||
@@ -150,7 +151,7 @@ func TestAgent_PollWork_NoWork(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Should not panic
|
// Should not panic
|
||||||
agent.pollForWork(context.Background())
|
agent.pollForWork(context.Background())
|
||||||
@@ -195,7 +196,7 @@ func TestAgent_PollWork_Success(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Should not panic; work items are processed in separate gorines in real usage
|
// Should not panic; work items are processed in separate gorines in real usage
|
||||||
agent.pollForWork(context.Background())
|
agent.pollForWork(context.Background())
|
||||||
@@ -285,7 +286,7 @@ func TestParsePEMFile(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Parse the file
|
// Parse the file
|
||||||
entries := agent.parsePEMFile(certPath)
|
entries := agent.parsePEMFile(certPath)
|
||||||
@@ -336,7 +337,7 @@ func TestParsePEMFile_MultipleCerts(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
entries := agent.parsePEMFile(certPath)
|
entries := agent.parsePEMFile(certPath)
|
||||||
|
|
||||||
@@ -362,7 +363,7 @@ func TestParseDERFile(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
entry, err := agent.parseDERFile(derPath)
|
entry, err := agent.parseDERFile(derPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -397,7 +398,7 @@ func TestParseDERFile_Invalid(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
_, err := agent.parseDERFile(derPath)
|
_, err := agent.parseDERFile(derPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -439,7 +440,7 @@ func TestScanDirectory(t *testing.T) {
|
|||||||
DiscoveryDirs: []string{tmpdir},
|
DiscoveryDirs: []string{tmpdir},
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Simulate directory walk manually (as runDiscoveryScan does)
|
// Simulate directory walk manually (as runDiscoveryScan does)
|
||||||
var certs []discoveredCertEntry
|
var certs []discoveredCertEntry
|
||||||
@@ -474,7 +475,7 @@ func TestCreateTargetConnector_NGINX(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
|
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
|
||||||
connector, err := agent.createTargetConnector("NGINX", configJSON)
|
connector, err := agent.createTargetConnector("NGINX", configJSON)
|
||||||
@@ -496,7 +497,7 @@ func TestCreateTargetConnector_Unsupported(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
_, err := agent.createTargetConnector("UnsupportedType", nil)
|
_, err := agent.createTargetConnector("UnsupportedType", nil)
|
||||||
|
|
||||||
@@ -530,7 +531,7 @@ func TestFetchCertificate_Success(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
certPEM, err := agent.fetchCertificate(context.Background(), "mc-001")
|
certPEM, err := agent.fetchCertificate(context.Background(), "mc-001")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -556,7 +557,7 @@ func TestFetchCertificate_NotFound(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
_, err := agent.fetchCertificate(context.Background(), "mc-nonexistent")
|
_, err := agent.fetchCertificate(context.Background(), "mc-nonexistent")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -592,7 +593,7 @@ func TestReportJobStatus_Success(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
err := agent.reportJobStatus(context.Background(), "j-001", "Completed", "")
|
err := agent.reportJobStatus(context.Background(), "j-001", "Completed", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -624,7 +625,7 @@ func TestReportJobStatus_WithError(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
err := agent.reportJobStatus(context.Background(), "j-001", "Failed", "deployment failed")
|
err := agent.reportJobStatus(context.Background(), "j-001", "Failed", "deployment failed")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -658,7 +659,7 @@ func TestMakeRequest_Success(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
resp, err := agent.makeRequest(context.Background(), http.MethodPost, "/test", map[string]string{"key": "value"})
|
resp, err := agent.makeRequest(context.Background(), http.MethodPost, "/test", map[string]string{"key": "value"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -680,7 +681,7 @@ func TestMakeRequest_InvalidURL(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
_, err := agent.makeRequest(context.Background(), http.MethodGet, "/test", nil)
|
_, err := agent.makeRequest(context.Background(), http.MethodGet, "/test", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -765,7 +766,7 @@ func TestNewAgent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
if agent.config != cfg {
|
if agent.config != cfg {
|
||||||
t.Error("config not set correctly")
|
t.Error("config not set correctly")
|
||||||
@@ -791,7 +792,7 @@ func TestNewAgent_WithLogger(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
|
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
if agent.logger != logger {
|
if agent.logger != logger {
|
||||||
t.Error("logger not set correctly")
|
t.Error("logger not set correctly")
|
||||||
@@ -954,7 +955,7 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -1007,7 +1008,7 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
invalidJSON := json.RawMessage("{invalid json}")
|
invalidJSON := json.RawMessage("{invalid json}")
|
||||||
|
|
||||||
@@ -1031,7 +1032,7 @@ func TestCreateTargetConnector_UnknownType(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
_, err := agent.createTargetConnector("MagicBox", nil)
|
_, err := agent.createTargetConnector("MagicBox", nil)
|
||||||
|
|
||||||
@@ -1061,7 +1062,7 @@ func TestCreateTargetConnector_EmptyConfig(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
for _, typeName := range tests {
|
for _, typeName := range tests {
|
||||||
t.Run(typeName, func(t *testing.T) {
|
t.Run(typeName, func(t *testing.T) {
|
||||||
@@ -1137,7 +1138,7 @@ func TestRunDiscoveryScan_ValidCerts(t *testing.T) {
|
|||||||
DiscoveryDirs: []string{tmpDir},
|
DiscoveryDirs: []string{tmpDir},
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Run discovery scan
|
// Run discovery scan
|
||||||
agent.runDiscoveryScan(context.Background())
|
agent.runDiscoveryScan(context.Background())
|
||||||
@@ -1165,7 +1166,7 @@ func TestRunDiscoveryScan_NoCertificates(t *testing.T) {
|
|||||||
DiscoveryDirs: []string{tmpDir},
|
DiscoveryDirs: []string{tmpDir},
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Run discovery scan - should complete without error even with empty directory
|
// Run discovery scan - should complete without error even with empty directory
|
||||||
agent.runDiscoveryScan(context.Background())
|
agent.runDiscoveryScan(context.Background())
|
||||||
@@ -1222,7 +1223,7 @@ func TestRunDiscoveryScan_MultipleCerts(t *testing.T) {
|
|||||||
DiscoveryDirs: []string{tmpDir},
|
DiscoveryDirs: []string{tmpDir},
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Run discovery scan
|
// Run discovery scan
|
||||||
agent.runDiscoveryScan(context.Background())
|
agent.runDiscoveryScan(context.Background())
|
||||||
@@ -1273,7 +1274,7 @@ func TestRunDiscoveryScan_DERCertificate(t *testing.T) {
|
|||||||
DiscoveryDirs: []string{tmpDir},
|
DiscoveryDirs: []string{tmpDir},
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Run discovery scan
|
// Run discovery scan
|
||||||
agent.runDiscoveryScan(context.Background())
|
agent.runDiscoveryScan(context.Background())
|
||||||
@@ -1331,7 +1332,7 @@ func TestRunDiscoveryScan_Subdirectories(t *testing.T) {
|
|||||||
DiscoveryDirs: []string{tmpDir},
|
DiscoveryDirs: []string{tmpDir},
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Run discovery scan - should recursively find certs in subdirs
|
// Run discovery scan - should recursively find certs in subdirs
|
||||||
agent.runDiscoveryScan(context.Background())
|
agent.runDiscoveryScan(context.Background())
|
||||||
@@ -1369,7 +1370,7 @@ func TestRunDiscoveryScan_ServerError(t *testing.T) {
|
|||||||
DiscoveryDirs: []string{tmpDir},
|
DiscoveryDirs: []string{tmpDir},
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
// Should handle server error gracefully without panicking
|
// Should handle server error gracefully without panicking
|
||||||
agent.runDiscoveryScan(context.Background())
|
agent.runDiscoveryScan(context.Background())
|
||||||
@@ -1396,7 +1397,7 @@ func TestDiscoveredCertEntry_ValidFields(t *testing.T) {
|
|||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
agent := NewAgent(cfg, logger)
|
agent, _ := NewAgent(cfg, logger)
|
||||||
|
|
||||||
entries := agent.parsePEMFile(certPath)
|
entries := agent.parsePEMFile(certPath)
|
||||||
|
|
||||||
@@ -1447,3 +1448,244 @@ func TestDiscoveredCertEntry_ValidFields(t *testing.T) {
|
|||||||
t.Error("PEMData should not be empty")
|
t.Error("PEMData should not be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTPS-Everywhere milestone (v2.2, §3.2 / §7) — Phase 5 client-side tests.
|
||||||
|
//
|
||||||
|
// These tests pin the agent's pre-flight HTTPS-scheme guard and the TLS
|
||||||
|
// configuration surface (CA bundle loading + TLS 1.3 round-trip) so that
|
||||||
|
// regressions surface at unit-test time, not at the first heartbeat of a
|
||||||
|
// production rollout. Matches the same contract asserted by the sibling
|
||||||
|
// binaries cmd/cli/main_test.go and cmd/mcp-server/main_test.go — the three
|
||||||
|
// must stay in lock-step because all three are HTTPS-only clients of the
|
||||||
|
// same control plane.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
|
||||||
|
// HTTPS-Everywhere milestone requires on the agent binary startup path. The
|
||||||
|
// agent's diagnostic is distinct from the CLI/MCP variants because it names
|
||||||
|
// CERTCTL_SERVER_URL (the only input channel — no --server flag on the
|
||||||
|
// agent). Every case here mirrors the dispatch arms in cmd/agent/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
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
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 names CERTCTL_SERVER_URL",
|
||||||
|
serverURL: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrSub: "CERTCTL_SERVER_URL is empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plaintext http rejected",
|
||||||
|
serverURL: "http://certctl-server:8443",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrSub: "plaintext http://",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare host missing scheme falls through to unsupported",
|
||||||
|
serverURL: "localhost:8443",
|
||||||
|
wantErr: true,
|
||||||
|
// url.Parse treats "localhost:8443" as scheme=localhost,
|
||||||
|
// opaque=8443 — exercises the default arm (unsupported scheme)
|
||||||
|
// rather than the empty-scheme arm. Both are fail-closed, which
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTestCABundle PEM-encodes a cert's DER bytes and writes the result to a
|
||||||
|
// tmp file inside dir. Used by CA-bundle tests so each case owns a distinct
|
||||||
|
// file path (matters for the "missing file" case which must point at a path
|
||||||
|
// that provably does not exist). Returns the path.
|
||||||
|
func writeTestCABundle(t *testing.T, dir string, certDER []byte, filename string) string {
|
||||||
|
t.Helper()
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
path := filepath.Join(dir, filename)
|
||||||
|
if err := os.WriteFile(path, pemBytes, 0644); err != nil {
|
||||||
|
t.Fatalf("writing CA bundle %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAgent_CABundle_Success confirms that a well-formed PEM bundle gets
|
||||||
|
// parsed into an x509.CertPool and wired onto the agent's HTTP client
|
||||||
|
// transport. This is the happy path the docs/tls.md "Private CA signed
|
||||||
|
// server cert" section depends on.
|
||||||
|
func TestNewAgent_CABundle_Success(t *testing.T) {
|
||||||
|
cert, err := generateTestCertWithCN("test.certctl.local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateTestCertWithCN: %v", err)
|
||||||
|
}
|
||||||
|
bundlePath := writeTestCABundle(t, t.TempDir(), cert.Raw, "ca-bundle.pem")
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent, err := NewAgent(&AgentConfig{
|
||||||
|
ServerURL: "https://certctl-server:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
CABundlePath: bundlePath,
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAgent with valid CA bundle err=%v want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport, ok := agent.client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("agent.client.Transport is %T; want *http.Transport", agent.client.Transport)
|
||||||
|
}
|
||||||
|
if transport.TLSClientConfig == nil {
|
||||||
|
t.Fatal("TLSClientConfig is nil; HTTPS-everywhere milestone requires a non-nil TLS config")
|
||||||
|
}
|
||||||
|
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
|
||||||
|
t.Errorf("MinVersion=%x want TLS 1.3 (%x) per §2.3 of the milestone spec",
|
||||||
|
transport.TLSClientConfig.MinVersion, tls.VersionTLS13)
|
||||||
|
}
|
||||||
|
if transport.TLSClientConfig.RootCAs == nil {
|
||||||
|
t.Error("RootCAs is nil; the configured CA bundle was silently dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAgent_CABundle_MissingFile pins the fail-loud behavior when the
|
||||||
|
// operator points CERTCTL_SERVER_CA_BUNDLE_PATH at a path that does not
|
||||||
|
// exist. Falling back to system roots here would mask a misconfiguration as
|
||||||
|
// a much harder-to-debug TLS handshake failure downstream.
|
||||||
|
func TestNewAgent_CABundle_MissingFile(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
missingPath := filepath.Join(t.TempDir(), "does-not-exist.pem")
|
||||||
|
_, err := NewAgent(&AgentConfig{
|
||||||
|
ServerURL: "https://certctl-server:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
CABundlePath: missingPath,
|
||||||
|
}, logger)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewAgent err=nil for missing CA bundle path; must fail loud at startup")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reading CA bundle") {
|
||||||
|
t.Errorf("err=%q must contain \"reading CA bundle\" so operators can trace the cause", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAgent_CABundle_EmptyPEM covers the "file exists but contains no
|
||||||
|
// valid certs" case (garbage, wrong-format, stripped PEM). AppendCertsFromPEM
|
||||||
|
// returns false in this case; NewAgent must translate that into a fail-loud
|
||||||
|
// startup error rather than quietly carry on with an empty pool.
|
||||||
|
func TestNewAgent_CABundle_EmptyPEM(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
bundlePath := filepath.Join(t.TempDir(), "empty.pem")
|
||||||
|
if err := os.WriteFile(bundlePath, []byte("not a pem-encoded certificate, just garbage\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("writing garbage bundle: %v", err)
|
||||||
|
}
|
||||||
|
_, err := NewAgent(&AgentConfig{
|
||||||
|
ServerURL: "https://certctl-server:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
CABundlePath: bundlePath,
|
||||||
|
}, logger)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewAgent err=nil for empty-PEM CA bundle; must fail loud at startup")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no valid PEM-encoded certificates") {
|
||||||
|
t.Errorf("err=%q must contain \"no valid PEM-encoded certificates\" so operators see why the bundle was rejected", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAgent_TLSRoundTrip is the end-to-end integration-style check: spin
|
||||||
|
// up an httptest.NewTLSServer (which presents a self-signed cert over TLS
|
||||||
|
// 1.3), feed that cert into the agent as a CA bundle, and confirm the agent
|
||||||
|
// successfully completes a heartbeat round-trip over HTTPS. This proves that
|
||||||
|
// (a) the CA pool is actually being consulted during verification and (b)
|
||||||
|
// the TLS 1.3 MinVersion doesn't break against httptest's default
|
||||||
|
// negotiation. Equivalent to the "TLS handshake succeeds against a
|
||||||
|
// self-signed control plane" integration gate, but runs in-process with no
|
||||||
|
// Docker dependency.
|
||||||
|
func TestNewAgent_TLSRoundTrip(t *testing.T) {
|
||||||
|
var heartbeatHit int
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/v1/agents/a-tls-test/heartbeat" && r.Method == http.MethodPost {
|
||||||
|
heartbeatHit++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// server.Certificate() returns the *x509.Certificate httptest presents;
|
||||||
|
// PEM-encode its DER bytes so NewAgent's AppendCertsFromPEM can ingest it.
|
||||||
|
bundlePath := writeTestCABundle(t, t.TempDir(), server.Certificate().Raw, "httptest-ca.pem")
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent, err := NewAgent(&AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-tls-test",
|
||||||
|
Hostname: "tls-test-host",
|
||||||
|
CABundlePath: bundlePath,
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAgent with httptest CA bundle err=%v want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.sendHeartbeat(context.Background())
|
||||||
|
|
||||||
|
if heartbeatHit != 1 {
|
||||||
|
t.Fatalf("heartbeat handler hit %d times; want 1 — the TLS round-trip must actually complete", heartbeatHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bundle-9 / Audit L-002 + L-003 (agent edition).
|
||||||
|
//
|
||||||
|
// The agent generates an ECDSA P-256 key locally and writes it to disk with
|
||||||
|
// mode 0600 in a directory it expects to be 0700. The duplication of the
|
||||||
|
// local-issuer helpers (instead of importing from internal/...) is deliberate:
|
||||||
|
//
|
||||||
|
// - cmd/agent is a separate binary with its own threat model (runs on every
|
||||||
|
// deployment target, not just the control plane). Coupling it to
|
||||||
|
// internal/connector/issuer/local would pull deployment-target footprint
|
||||||
|
// into a connector that's only relevant on the server.
|
||||||
|
// - The behavior is small and self-contained; copy-paste is cheaper than
|
||||||
|
// a refactor that introduces an internal/keystore package.
|
||||||
|
//
|
||||||
|
// If a third call site emerges, lift these into internal/keystore.
|
||||||
|
|
||||||
|
// marshalAgentKeyAndZeroize marshals an ECDSA private key to DER and invokes
|
||||||
|
// onDER with the bytes; the buffer is zeroized via builtin clear() after
|
||||||
|
// onDER returns. Caller must NOT retain the slice.
|
||||||
|
func marshalAgentKeyAndZeroize(priv *ecdsa.PrivateKey, onDER func([]byte) error) error {
|
||||||
|
if priv == nil {
|
||||||
|
return fmt.Errorf("marshalAgentKeyAndZeroize: nil private key")
|
||||||
|
}
|
||||||
|
der, err := x509.MarshalECPrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal EC private key: %w", err)
|
||||||
|
}
|
||||||
|
defer clear(der)
|
||||||
|
return onDER(der)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAgentKeyDirSecure creates dir (and ancestors) with mode 0700 or
|
||||||
|
// asserts an existing dir is owner-only. If a pre-existing dir is more
|
||||||
|
// permissive than 0700 we tighten it to 0700 (logging-free; this is a
|
||||||
|
// startup-style invariant, not a per-request check).
|
||||||
|
func ensureAgentKeyDirSecure(dir string) error {
|
||||||
|
if dir == "" || dir == "." || dir == "/" {
|
||||||
|
return fmt.Errorf("ensureAgentKeyDirSecure: refuse empty/root dir %q", dir)
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(dir)
|
||||||
|
info, err := os.Stat(clean)
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
if mkErr := os.MkdirAll(clean, 0o700); mkErr != nil {
|
||||||
|
return fmt.Errorf("create agent key dir %q: %w", clean, mkErr)
|
||||||
|
}
|
||||||
|
info, err = os.Stat(clean)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat newly-created agent key dir %q: %w", clean, err)
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
|
case err == nil:
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode == 0o700 || mode&0o077 == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if chmodErr := os.Chmod(clean, 0o700); chmodErr != nil {
|
||||||
|
return fmt.Errorf("tighten agent key dir %q from %#o to 0700: %w", clean, mode, chmodErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("stat agent key dir %q: %w", clean, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Bundle 0.7 (Coverage Audit Closure) — cmd/agent key-handling regression coverage.
|
||||||
|
//
|
||||||
|
// Closes finding C-008 (CRTCTL-COVAUDIT-2026-04-27-0034). The two functions in
|
||||||
|
// keymem.go are the agent's defense-in-depth for ECDSA P-256 private-key
|
||||||
|
// memory hygiene (Bundle 9 / Audit L-002 + L-003 — agent edition). They
|
||||||
|
// shipped with regression-test coverage of 0.0% / 11.1% respectively. This
|
||||||
|
// file pins:
|
||||||
|
//
|
||||||
|
// - marshalAgentKeyAndZeroize: rejects nil keys, propagates onDER errors,
|
||||||
|
// and ZEROIZES the DER backing buffer after onDER returns regardless of
|
||||||
|
// whether onDER errored. The zeroization invariant is verified observably
|
||||||
|
// (capture the slice header inside onDER, then assert every byte is 0x00
|
||||||
|
// after the function returns) — NOT just asserted in prose.
|
||||||
|
//
|
||||||
|
// - ensureAgentKeyDirSecure: refuses empty / "." / "/", creates missing
|
||||||
|
// dirs with mode 0700 (incl. nested ancestors), accepts existing 0700
|
||||||
|
// and any owner-only-no-write mode (mode&0o077 == 0), tightens any other
|
||||||
|
// mode to 0700, normalizes paths via filepath.Clean, is idempotent, is
|
||||||
|
// safe under concurrent invocation, and propagates the documented error
|
||||||
|
// messages from os.Stat / os.MkdirAll / os.Chmod failures.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func mustGenAgentECDSAKey(t *testing.T) *ecdsa.PrivateKey {
|
||||||
|
t.Helper()
|
||||||
|
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// marshalAgentKeyAndZeroize
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestMarshalAgentKeyAndZeroize_HappyPath confirms onDER receives well-formed
|
||||||
|
// DER bytes that the caller can use during the closure (e.g. to PEM-encode).
|
||||||
|
func TestMarshalAgentKeyAndZeroize_HappyPath(t *testing.T) {
|
||||||
|
k := mustGenAgentECDSAKey(t)
|
||||||
|
called := false
|
||||||
|
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||||
|
called = true
|
||||||
|
if len(der) == 0 {
|
||||||
|
t.Fatalf("der is empty inside onDER")
|
||||||
|
}
|
||||||
|
// First byte of an ECPrivateKey DER blob is the ASN.1 SEQUENCE tag 0x30.
|
||||||
|
if der[0] != 0x30 {
|
||||||
|
t.Errorf("expected DER to start with SEQUENCE tag 0x30, got %#x", der[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("onDER was never invoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarshalAgentKeyAndZeroize_NilKey confirms the early-return guard;
|
||||||
|
// onDER must NOT be invoked when priv is nil.
|
||||||
|
func TestMarshalAgentKeyAndZeroize_NilKey(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
err := marshalAgentKeyAndZeroize(nil, func([]byte) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on nil key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "nil private key") {
|
||||||
|
t.Errorf("expected error mentioning %q, got: %v", "nil private key", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("onDER must not be invoked when priv is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarshalAgentKeyAndZeroize_OnDERReturnsError confirms upstream errors
|
||||||
|
// are propagated verbatim via errors.Is.
|
||||||
|
func TestMarshalAgentKeyAndZeroize_OnDERReturnsError(t *testing.T) {
|
||||||
|
k := mustGenAgentECDSAKey(t)
|
||||||
|
sentinel := errors.New("simulated downstream failure")
|
||||||
|
got := marshalAgentKeyAndZeroize(k, func([]byte) error { return sentinel })
|
||||||
|
if !errors.Is(got, sentinel) {
|
||||||
|
t.Errorf("expected upstream sentinel via errors.Is; got: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarshalAgentKeyAndZeroize_BackingBufferZeroizedAfterReturn is the
|
||||||
|
// CRITICAL invariant test. It captures the slice header (NOT a deep copy)
|
||||||
|
// inside onDER and re-inspects after the function returns. Because Go slices
|
||||||
|
// share their backing array, the captured slice observes the zeroization
|
||||||
|
// performed by `defer clear(der)` in marshalAgentKeyAndZeroize.
|
||||||
|
//
|
||||||
|
// A future refactor that drops the `defer clear(der)` would break this test
|
||||||
|
// even if HappyPath / NilKey / OnDERReturnsError still pass.
|
||||||
|
func TestMarshalAgentKeyAndZeroize_BackingBufferZeroizedAfterReturn(t *testing.T) {
|
||||||
|
k := mustGenAgentECDSAKey(t)
|
||||||
|
var captured []byte
|
||||||
|
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||||
|
// SHARE the backing array — do NOT take a defensive copy.
|
||||||
|
captured = der
|
||||||
|
if len(der) == 0 {
|
||||||
|
t.Fatal("der is empty inside onDER")
|
||||||
|
}
|
||||||
|
// Sanity check: while still inside onDER, the bytes are live
|
||||||
|
// (defer clear has NOT run yet).
|
||||||
|
nonZero := false
|
||||||
|
for _, b := range der {
|
||||||
|
if b != 0 {
|
||||||
|
nonZero = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !nonZero {
|
||||||
|
t.Fatal("DER is all-zero INSIDE onDER; that should be impossible (clear hasn't run yet)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||||
|
}
|
||||||
|
if len(captured) == 0 {
|
||||||
|
t.Fatal("captured slice is empty post-return")
|
||||||
|
}
|
||||||
|
// After return, defer clear(der) has run. The captured slice shares the
|
||||||
|
// backing array, so every byte must read 0x00.
|
||||||
|
for i, b := range captured {
|
||||||
|
if b != 0 {
|
||||||
|
t.Errorf("captured[%d] = %#x; expected 0x00 (zeroized)", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarshalAgentKeyAndZeroize_BufferZeroizedEvenOnError confirms the
|
||||||
|
// `defer clear(der)` fires regardless of onDER's return — the security
|
||||||
|
// invariant is "buffer is always zeroized after the function returns,"
|
||||||
|
// happy path or error path.
|
||||||
|
func TestMarshalAgentKeyAndZeroize_BufferZeroizedEvenOnError(t *testing.T) {
|
||||||
|
k := mustGenAgentECDSAKey(t)
|
||||||
|
sentinel := errors.New("upstream boom")
|
||||||
|
var captured []byte
|
||||||
|
gotErr := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||||
|
captured = der // share backing array
|
||||||
|
return sentinel
|
||||||
|
})
|
||||||
|
if !errors.Is(gotErr, sentinel) {
|
||||||
|
t.Fatalf("expected sentinel via errors.Is, got: %v", gotErr)
|
||||||
|
}
|
||||||
|
if len(captured) == 0 {
|
||||||
|
t.Fatal("captured slice empty post-return")
|
||||||
|
}
|
||||||
|
for i, b := range captured {
|
||||||
|
if b != 0 {
|
||||||
|
t.Errorf("captured[%d] = %#x; expected 0x00 (defer clear must run on error path)", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarshalAgentKeyAndZeroize_ContractViolatorSeesZeros frames the same
|
||||||
|
// observation as a defense-in-depth contract test. The docstring states
|
||||||
|
// "Caller must NOT retain the slice." If a caller violates that contract
|
||||||
|
// and reads the slice after onDER returns, they observe zeros — not the
|
||||||
|
// private scalar. This test pins that defense.
|
||||||
|
func TestMarshalAgentKeyAndZeroize_ContractViolatorSeesZeros(t *testing.T) {
|
||||||
|
k := mustGenAgentECDSAKey(t)
|
||||||
|
var leaked []byte // simulating a buggy caller that retains the slice
|
||||||
|
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||||
|
leaked = der
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||||
|
}
|
||||||
|
// The contract violator now reads from `leaked`. Defense-in-depth: it's zeros.
|
||||||
|
for i, b := range leaked {
|
||||||
|
if b != 0 {
|
||||||
|
t.Errorf("contract-violator read leaked[%d] = %#x; expected 0x00", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ensureAgentKeyDirSecure — table-driven coverage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestEnsureAgentKeyDirSecure(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
type tc struct {
|
||||||
|
name string
|
||||||
|
// setup returns the dir argument to pass to ensureAgentKeyDirSecure.
|
||||||
|
// base is a fresh t.TempDir() unique to each subtest.
|
||||||
|
setup func(t *testing.T, base string) string
|
||||||
|
// wantErrSubstr; "" means no error is expected.
|
||||||
|
wantErrSubstr string
|
||||||
|
// wantMode; if set, asserted via os.Stat after the call. Set to 0
|
||||||
|
// to skip the mode assertion (e.g. for error-path rows where the
|
||||||
|
// dir wasn't created or wasn't intended to change).
|
||||||
|
wantMode os.FileMode
|
||||||
|
}
|
||||||
|
cases := []tc{
|
||||||
|
// Refuse-empty/root invariants
|
||||||
|
{
|
||||||
|
name: "empty_string_refused",
|
||||||
|
setup: func(t *testing.T, _ string) string {
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
wantErrSubstr: `refuse empty/root dir ""`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot_refused",
|
||||||
|
setup: func(t *testing.T, _ string) string {
|
||||||
|
return "."
|
||||||
|
},
|
||||||
|
wantErrSubstr: `refuse empty/root dir "."`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root_refused",
|
||||||
|
setup: func(t *testing.T, _ string) string {
|
||||||
|
return "/"
|
||||||
|
},
|
||||||
|
wantErrSubstr: `refuse empty/root dir "/"`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Non-existent path — MkdirAll(0700) path
|
||||||
|
{
|
||||||
|
name: "creates_with_0700",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
return filepath.Join(base, "newdir")
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "creates_nested_0700",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
return filepath.Join(base, "a", "b", "c")
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Existing 0700 — no-op (mode == 0o700 branch).
|
||||||
|
{
|
||||||
|
name: "existing_0700_noop",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
d := filepath.Join(base, "exists0700")
|
||||||
|
if err := os.Mkdir(d, 0o700); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Existing more-permissive — chmod tighten to 0700.
|
||||||
|
{
|
||||||
|
name: "existing_0750_tightened",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
d := filepath.Join(base, "exists0750")
|
||||||
|
if err := os.Mkdir(d, 0o750); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(d, 0o750); err != nil {
|
||||||
|
t.Fatalf("setup chmod: %v", err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing_0755_tightened",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
d := filepath.Join(base, "exists0755")
|
||||||
|
if err := os.Mkdir(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("setup chmod: %v", err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing_0777_tightened",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
d := filepath.Join(base, "exists0777")
|
||||||
|
if err := os.Mkdir(d, 0o777); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(d, 0o777); err != nil {
|
||||||
|
t.Fatalf("setup chmod: %v", err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Existing owner-only-no-write modes accepted as-is via the
|
||||||
|
// `mode&0o077 == 0` branch (no chmod, mode preserved).
|
||||||
|
{
|
||||||
|
name: "existing_0500_accepted_no_chmod",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
d := filepath.Join(base, "exists0500")
|
||||||
|
if err := os.Mkdir(d, 0o700); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(d, 0o500); err != nil {
|
||||||
|
t.Fatalf("setup chmod: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(d, 0o700) }) // let TempDir cleanup
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
wantMode: 0o500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing_0400_accepted_no_chmod",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
d := filepath.Join(base, "exists0400")
|
||||||
|
if err := os.Mkdir(d, 0o700); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(d, 0o400); err != nil {
|
||||||
|
t.Fatalf("setup chmod: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(d, 0o700) })
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
wantMode: 0o400,
|
||||||
|
},
|
||||||
|
|
||||||
|
// filepath.Clean normalization paths.
|
||||||
|
{
|
||||||
|
name: "trailing_slash_normalized",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
d := filepath.Join(base, "trail")
|
||||||
|
if err := os.Mkdir(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("setup chmod: %v", err)
|
||||||
|
}
|
||||||
|
return d + "/"
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot_prefix_normalized",
|
||||||
|
setup: func(t *testing.T, base string) string {
|
||||||
|
// The function uses filepath.Clean which strips redundant
|
||||||
|
// "./" segments. We only need to verify Clean is invoked,
|
||||||
|
// not that we end up at a relative path; pass an absolute
|
||||||
|
// path with an embedded "./".
|
||||||
|
d := filepath.Join(base, "dotprefix")
|
||||||
|
if err := os.Mkdir(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("setup mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("setup chmod: %v", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(base, ".", "dotprefix")
|
||||||
|
},
|
||||||
|
wantMode: 0o700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
dir := tc.setup(t, base)
|
||||||
|
|
||||||
|
err := ensureAgentKeyDirSecure(dir)
|
||||||
|
if tc.wantErrSubstr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErrSubstr) {
|
||||||
|
t.Errorf("error %q does not contain %q", err, tc.wantErrSubstr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensureAgentKeyDirSecure: %v", err)
|
||||||
|
}
|
||||||
|
if tc.wantMode != 0 {
|
||||||
|
clean := filepath.Clean(dir)
|
||||||
|
info, statErr := os.Stat(clean)
|
||||||
|
if statErr != nil {
|
||||||
|
t.Fatalf("post-call stat: %v", statErr)
|
||||||
|
}
|
||||||
|
if got := info.Mode().Perm(); got != tc.wantMode {
|
||||||
|
t.Errorf("dir mode = %#o; want %#o", got, tc.wantMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureAgentKeyDirSecure_Idempotent confirms a second call on a
|
||||||
|
// just-created dir is a no-op (hits the `mode == 0o700` short-circuit).
|
||||||
|
func TestEnsureAgentKeyDirSecure_Idempotent(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(t.TempDir(), "idempotent")
|
||||||
|
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||||
|
t.Fatalf("first call: %v", err)
|
||||||
|
}
|
||||||
|
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||||
|
t.Fatalf("second call: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o700 {
|
||||||
|
t.Errorf("expected 0700, got %#o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureAgentKeyDirSecure_Concurrent runs the function from many
|
||||||
|
// goroutines simultaneously on the same fresh path. This is a safety smoke
|
||||||
|
// test under -race; it is NOT a functional correctness claim about
|
||||||
|
// concurrent agents (the agent has a single goroutine). The MkdirAll call
|
||||||
|
// is the load-bearing primitive here — it's documented as safe to call
|
||||||
|
// repeatedly with no error if the dir already exists.
|
||||||
|
func TestEnsureAgentKeyDirSecure_Concurrent(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(t.TempDir(), "concurrent")
|
||||||
|
const workers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, workers)
|
||||||
|
wg.Add(workers)
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
for err := range errCh {
|
||||||
|
t.Errorf("concurrent caller returned error: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post-concurrent stat: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o700 {
|
||||||
|
t.Errorf("expected 0700 after concurrent calls, got %#o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureAgentKeyDirSecure_PathIsAFile pins the function's behavior when
|
||||||
|
// passed a regular file. The function does not type-check (no IsDir()), so
|
||||||
|
// it stat's the file, sees mode 0o644 (or whatever), and chmod's it to 0700.
|
||||||
|
//
|
||||||
|
// This is "silently accepts a file path" behavior. It is not a correctness
|
||||||
|
// bug per the function's caller (cmd/agent/main.go always passes
|
||||||
|
// filepath.Dir(keyPath), which is a directory), but it is a hardening
|
||||||
|
// candidate. Captured as a finding observation in the test docstring rather
|
||||||
|
// than fixed in this bundle (Bundle 0.7 ships no production-code changes).
|
||||||
|
func TestEnsureAgentKeyDirSecure_PathIsAFile(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
base := t.TempDir()
|
||||||
|
filePath := filepath.Join(base, "not-a-dir.txt")
|
||||||
|
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("setup writefile: %v", err)
|
||||||
|
}
|
||||||
|
err := ensureAgentKeyDirSecure(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("current behavior: function chmod's a file silently and returns nil; got err = %v", err)
|
||||||
|
}
|
||||||
|
info, statErr := os.Stat(filePath)
|
||||||
|
if statErr != nil {
|
||||||
|
t.Fatalf("post-call stat: %v", statErr)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
t.Fatal("file became a directory; that's not a thing")
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o700 {
|
||||||
|
t.Errorf("expected mode 0700 (current behavior), got %#o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureAgentKeyDirSecure_MkdirErrorPropagated forces the MkdirAll
|
||||||
|
// branch to fail by chmod'ing the parent to 0o500 (read+exec but no write).
|
||||||
|
// On linux/darwin running as a non-root uid, MkdirAll on a child of such a
|
||||||
|
// parent fails with EACCES. We assert the error message wraps with the
|
||||||
|
// documented "create agent key dir" prefix.
|
||||||
|
//
|
||||||
|
// Skipped if running as root (root bypasses unix dir-write checks).
|
||||||
|
func TestEnsureAgentKeyDirSecure_MkdirErrorPropagated(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("running as root; cannot revoke parent dir write permission")
|
||||||
|
}
|
||||||
|
parent := t.TempDir()
|
||||||
|
if err := os.Chmod(parent, 0o500); err != nil {
|
||||||
|
t.Fatalf("setup chmod parent: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||||
|
|
||||||
|
child := filepath.Join(parent, "no-can-create")
|
||||||
|
err := ensureAgentKeyDirSecure(child)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when MkdirAll cannot write to read-only parent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "create agent key dir") {
|
||||||
|
t.Errorf("error %q should contain %q", err.Error(), "create agent key dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureAgentKeyDirSecure_StatErrorPropagated forces os.Stat to fail
|
||||||
|
// with a non-IsNotExist error by chmod'ing the parent to 0o000 (no
|
||||||
|
// read+exec). On linux/darwin running as a non-root uid, stat on a child
|
||||||
|
// of such a parent fails with EACCES. We assert the error message wraps
|
||||||
|
// with "stat agent key dir".
|
||||||
|
//
|
||||||
|
// Skipped if running as root.
|
||||||
|
func TestEnsureAgentKeyDirSecure_StatErrorPropagated(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("running as root; cannot revoke parent dir read+exec permission")
|
||||||
|
}
|
||||||
|
parent := t.TempDir()
|
||||||
|
child := filepath.Join(parent, "victim")
|
||||||
|
if err := os.Chmod(parent, 0o000); err != nil {
|
||||||
|
t.Fatalf("setup chmod parent: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||||
|
|
||||||
|
err := ensureAgentKeyDirSecure(child)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when stat cannot traverse unreadable parent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "stat agent key dir") {
|
||||||
|
t.Errorf("error %q should contain %q", err.Error(), "stat agent key dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureAgentKeyDirSecure_ChmodErrorPropagated forces os.Chmod to fail
|
||||||
|
// on an existing more-permissive dir. We achieve this by:
|
||||||
|
// 1. Creating an intermediate dir at 0o755 (so the function takes the
|
||||||
|
// tighten-via-chmod branch).
|
||||||
|
// 2. Replacing the real dir with a read-only-from-parent bind: chmod the
|
||||||
|
// grandparent to 0o500 so the chmod syscall on the child fails with
|
||||||
|
// EACCES (the syscall needs write on the path's containing dir for
|
||||||
|
// metadata updates on most unix filesystems — actually no, chmod only
|
||||||
|
// needs ownership, not parent write. So we instead drop the file's
|
||||||
|
// owner via... no — we cannot change ownership without root.)
|
||||||
|
//
|
||||||
|
// Reaching the chmod-error branch from a non-root test is awkward because
|
||||||
|
// chmod only requires ownership (which we always have on t.TempDir()).
|
||||||
|
// The cleanest way is to skip on non-root and exercise the branch in CI
|
||||||
|
// images that run as root; but our CI runs as non-root. We DO trigger the
|
||||||
|
// branch via a different mechanism: replace the path with a SYMLINK to
|
||||||
|
// /proc/1/root (or similar) where the eventual stat resolves but chmod
|
||||||
|
// fails — but that's brittle and OS-specific.
|
||||||
|
//
|
||||||
|
// Acceptable closure: document that this branch is exercised by the
|
||||||
|
// existing chmod-fails errno path, but the test as written can only assert
|
||||||
|
// the wrap-prefix when the branch IS reached. We use a synthetic approach:
|
||||||
|
// chmod-tighten a dir we then immediately delete, racing the syscall —
|
||||||
|
// not deterministic.
|
||||||
|
//
|
||||||
|
// Pragmatic resolution: the chmod-error branch is structurally identical
|
||||||
|
// to the mkdir-error and stat-error branches (errors.Wrap with a
|
||||||
|
// distinct prefix), and is exercised in production via os.Chmod ENOENT
|
||||||
|
// or read-only-filesystem failures. We add a unit test that asserts the
|
||||||
|
// branch's MESSAGE format by passing through a wrap helper construct.
|
||||||
|
// This test instead documents that the branch is structural and any new
|
||||||
|
// failure mode (read-only fs, immutable bit, ACLs) inherits the wrap
|
||||||
|
// prefix automatically.
|
||||||
|
//
|
||||||
|
// To still get coverage on the chmod-error branch, we use os.Chmod against
|
||||||
|
// a dir whose immediate parent we delete mid-call. This is racy. Instead,
|
||||||
|
// we make chmod fail by passing a path that filepath.Clean rewrites to
|
||||||
|
// a symlink whose target was just chmod-stripped. Too brittle.
|
||||||
|
//
|
||||||
|
// CLEANEST APPROACH: rely on the OS's read-only filesystem semantics under
|
||||||
|
// /sys (which is RO on linux). os.Chmod on a path under /sys returns EROFS.
|
||||||
|
// But /sys is owned by root — stat would succeed only on existing entries,
|
||||||
|
// and the function would then attempt chmod, which fails with EROFS (the
|
||||||
|
// non-root caller still gets a clean error wrap).
|
||||||
|
//
|
||||||
|
// We cannot find a well-defined non-root chmod-fail path on darwin. So the
|
||||||
|
// test runs only on linux and skips elsewhere.
|
||||||
|
func TestEnsureAgentKeyDirSecure_ChmodErrorPropagated(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("chmod-error branch is only reliably triggerable on linux via /sys (read-only fs)")
|
||||||
|
}
|
||||||
|
// /sys is mounted read-only on Linux. Pick a stable subdir we can stat
|
||||||
|
// (kernel-class). os.Chmod against it returns EROFS regardless of uid
|
||||||
|
// (well — root can remount, but the call against /sys/* still EROFS).
|
||||||
|
candidate := "/sys/kernel"
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Skipf("/sys/kernel not stat-able as a dir on this host; skipping (%v)", err)
|
||||||
|
}
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode == 0o700 || mode&0o077 == 0 {
|
||||||
|
// Already in the no-chmod branch; this test cannot exercise the
|
||||||
|
// chmod-fail branch on this host. Skip rather than false-positive.
|
||||||
|
t.Skipf("/sys/kernel mode %#o already satisfies no-chmod branch", mode)
|
||||||
|
}
|
||||||
|
chmodErr := ensureAgentKeyDirSecure(candidate)
|
||||||
|
if chmodErr == nil {
|
||||||
|
t.Fatal("expected chmod failure on /sys (read-only fs)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(chmodErr.Error(), "tighten agent key dir") {
|
||||||
|
t.Errorf("error %q should contain %q", chmodErr.Error(), "tighten agent key dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureAgentKeyDirSecure_FmtErrorMessageIncludesPath confirms each
|
||||||
|
// error wrap includes the cleaned path (debuggability invariant).
|
||||||
|
func TestEnsureAgentKeyDirSecure_FmtErrorMessageIncludesPath(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("running as root; cannot revoke parent dir write permission")
|
||||||
|
}
|
||||||
|
parent := t.TempDir()
|
||||||
|
if err := os.Chmod(parent, 0o500); err != nil {
|
||||||
|
t.Fatalf("setup chmod parent: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||||
|
child := filepath.Join(parent, "child")
|
||||||
|
want := filepath.Clean(child)
|
||||||
|
|
||||||
|
err := ensureAgentKeyDirSecure(child)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q should reference cleaned path %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cross-cutting: end-to-end smoke confirming the two functions compose
|
||||||
|
// the way main.go uses them (Bundle 9 / L-002 / L-003 flow).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestKeymem_AgentMainFlowSmoke replays the cmd/agent/main.go composition:
|
||||||
|
// ensureAgentKeyDirSecure(dir) → marshalAgentKeyAndZeroize(priv, onDER).
|
||||||
|
// Closes the contract that both helpers cooperate cleanly under realistic
|
||||||
|
// fixture conditions, and that the DER buffer is zeroized at the end of
|
||||||
|
// the marshal call.
|
||||||
|
func TestKeymem_AgentMainFlowSmoke(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("permission semantics differ on windows")
|
||||||
|
}
|
||||||
|
keyDir := filepath.Join(t.TempDir(), "agent-keys")
|
||||||
|
if err := ensureAgentKeyDirSecure(keyDir); err != nil {
|
||||||
|
t.Fatalf("ensureAgentKeyDirSecure: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(keyDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o700 {
|
||||||
|
t.Fatalf("key dir not at 0700, got %#o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
priv := mustGenAgentECDSAKey(t)
|
||||||
|
var captured []byte
|
||||||
|
if err := marshalAgentKeyAndZeroize(priv, func(der []byte) error {
|
||||||
|
captured = der // share backing array
|
||||||
|
// Pretend caller does pem.EncodeToMemory(...) here; we just check
|
||||||
|
// the DER is a valid SEQUENCE.
|
||||||
|
if len(der) == 0 || der[0] != 0x30 {
|
||||||
|
return fmt.Errorf("unexpected DER shape (len=%d, first=%#x)", len(der), der)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||||
|
}
|
||||||
|
for i, b := range captured {
|
||||||
|
if b != 0 {
|
||||||
|
t.Fatalf("post-flow DER buffer not zeroized at byte %d (%#x)", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+271
-32
@@ -8,21 +8,25 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -44,15 +48,27 @@ import (
|
|||||||
|
|
||||||
// AgentConfig represents the agent-side configuration.
|
// AgentConfig represents the agent-side configuration.
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
ServerURL string // Control plane server URL (e.g., http://localhost:8443)
|
ServerURL string // Control plane server URL (e.g., https://localhost:8443) — must be https:// scheme
|
||||||
APIKey string // Agent API key for authentication
|
APIKey string // Agent API key for authentication
|
||||||
AgentName string // Agent name for identification
|
AgentName string // Agent name for identification
|
||||||
AgentID string // Agent ID for API calls (set after registration or from env)
|
AgentID string // Agent ID for API calls (set after registration or from env)
|
||||||
Hostname string // Server hostname
|
Hostname string // Server hostname
|
||||||
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
|
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
|
||||||
DiscoveryDirs []string // Directories to scan for certificates (comma-separated via env)
|
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.
|
// Agent represents the local agent that runs on target servers.
|
||||||
// It periodically sends heartbeats, polls for work, executes deployment and CSR jobs,
|
// It periodically sends heartbeats, polls for work, executes deployment and CSR jobs,
|
||||||
// and scans configured directories for existing certificates.
|
// and scans configured directories for existing certificates.
|
||||||
@@ -68,6 +84,17 @@ type Agent struct {
|
|||||||
pollInterval time.Duration
|
pollInterval time.Duration
|
||||||
discoveryInterval time.Duration
|
discoveryInterval time.Duration
|
||||||
consecutiveFailures int
|
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.
|
// WorkResponse represents the response from the work polling endpoint.
|
||||||
@@ -90,15 +117,78 @@ type JobItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent instance.
|
// 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{
|
return &Agent{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
client: httpClient,
|
||||||
heartbeatInterval: 60 * time.Second,
|
heartbeatInterval: 60 * time.Second,
|
||||||
pollInterval: 30 * time.Second,
|
pollInterval: 30 * time.Second,
|
||||||
discoveryInterval: 6 * time.Hour, // scan for certs every 6 hours
|
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.
|
// Run starts the agent's main loop.
|
||||||
@@ -154,6 +244,19 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
a.logger.Info("agent shutting down", "reason", ctx.Err())
|
a.logger.Info("agent shutting down", "reason", ctx.Err())
|
||||||
return 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:
|
case <-heartbeatTicker.C:
|
||||||
a.sendHeartbeat(ctx)
|
a.sendHeartbeat(ctx)
|
||||||
|
|
||||||
@@ -166,7 +269,14 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
a.logger.Warn("backing off due to consecutive failures",
|
a.logger.Warn("backing off due to consecutive failures",
|
||||||
"failures", a.consecutiveFailures,
|
"failures", a.consecutiveFailures,
|
||||||
"backoff", backoff.String())
|
"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)
|
a.pollForWork(ctx)
|
||||||
|
|
||||||
@@ -209,6 +319,22 @@ func (a *Agent) sendHeartbeat(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
a.logger.Error("heartbeat rejected",
|
a.logger.Error("heartbeat rejected",
|
||||||
@@ -237,6 +363,19 @@ func (a *Agent) pollForWork(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
a.logger.Error("work poll rejected",
|
a.logger.Error("work poll rejected",
|
||||||
@@ -306,23 +445,40 @@ func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
|||||||
"job_id", job.ID,
|
"job_id", job.ID,
|
||||||
"certificate_id", job.CertificateID)
|
"certificate_id", job.CertificateID)
|
||||||
|
|
||||||
// Step 2: Store private key to disk with secure permissions
|
// Step 2: Store private key to disk with secure permissions.
|
||||||
|
//
|
||||||
|
// Bundle-9 / Audit L-002 + L-003: marshal+write through helpers that
|
||||||
|
// (a) zeroize the in-heap DER buffer immediately after the PEM block is
|
||||||
|
// constructed so the private scalar's exposure window is bounded by
|
||||||
|
// this function call, and (b) assert the key directory is mode 0700
|
||||||
|
// before any write touches disk. Also defer-clear the PEM buffer for
|
||||||
|
// the same reason — the encoded key isn't sensitive in transit (it's
|
||||||
|
// going to disk) but lingers on the heap if we don't.
|
||||||
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
|
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
|
||||||
privKeyDER, err := x509.MarshalECPrivateKey(privKey)
|
if err := ensureAgentKeyDirSecure(filepath.Dir(keyPath)); err != nil {
|
||||||
if err != nil {
|
a.logger.Error("agent key dir hardening failed", "job_id", job.ID, "error", err)
|
||||||
a.logger.Error("failed to marshal private key",
|
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key dir hardening failed: %v", err)); reportErr != nil {
|
||||||
"job_id", job.ID,
|
|
||||||
"error", err)
|
|
||||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key marshal failed: %v", err)); reportErr != nil {
|
|
||||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var privKeyPEM []byte
|
||||||
privKeyPEM := pem.EncodeToMemory(&pem.Block{
|
if marshalErr := marshalAgentKeyAndZeroize(privKey, func(der []byte) error {
|
||||||
Type: "EC PRIVATE KEY",
|
privKeyPEM = pem.EncodeToMemory(&pem.Block{
|
||||||
Bytes: privKeyDER,
|
Type: "EC PRIVATE KEY",
|
||||||
})
|
Bytes: der,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}); marshalErr != nil {
|
||||||
|
a.logger.Error("failed to marshal private key",
|
||||||
|
"job_id", job.ID,
|
||||||
|
"error", marshalErr)
|
||||||
|
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key marshal failed: %v", marshalErr)); reportErr != nil {
|
||||||
|
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer clear(privKeyPEM)
|
||||||
|
|
||||||
if err := os.WriteFile(keyPath, privKeyPEM, 0600); err != nil {
|
if err := os.WriteFile(keyPath, privKeyPEM, 0600); err != nil {
|
||||||
a.logger.Error("failed to write private key to disk",
|
a.logger.Error("failed to write private key to disk",
|
||||||
@@ -1031,12 +1187,14 @@ func certKeyInfo(cert *x509.Certificate) (string, int) {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse command-line flags (with env var fallbacks for Docker deployment)
|
// 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")
|
apiKey := flag.String("api-key", getEnvDefault("CERTCTL_API_KEY", ""), "Agent API key")
|
||||||
agentName := flag.String("name", getEnvDefault("CERTCTL_AGENT_NAME", "certctl-agent"), "Agent name")
|
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)")
|
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")
|
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")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if *apiKey == "" {
|
if *apiKey == "" {
|
||||||
@@ -1050,6 +1208,18 @@ func main() {
|
|||||||
os.Exit(1)
|
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
|
// Set up structured logging
|
||||||
logLevel := slog.LevelInfo
|
logLevel := slog.LevelInfo
|
||||||
if getEnvDefault("CERTCTL_LOG_LEVEL", "info") == "debug" {
|
if getEnvDefault("CERTCTL_LOG_LEVEL", "info") == "debug" {
|
||||||
@@ -1078,17 +1248,27 @@ func main() {
|
|||||||
|
|
||||||
// Create agent configuration
|
// Create agent configuration
|
||||||
agentCfg := &AgentConfig{
|
agentCfg := &AgentConfig{
|
||||||
ServerURL: *serverURL,
|
ServerURL: *serverURL,
|
||||||
APIKey: *apiKey,
|
APIKey: *apiKey,
|
||||||
AgentName: *agentName,
|
AgentName: *agentName,
|
||||||
AgentID: *agentID,
|
AgentID: *agentID,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
KeyDir: *keyDir,
|
KeyDir: *keyDir,
|
||||||
DiscoveryDirs: discoveryDirs,
|
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
|
// 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
|
// Create context with cancellation for graceful shutdown
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -1117,6 +1297,19 @@ func main() {
|
|||||||
cancel()
|
cancel()
|
||||||
<-errChan
|
<-errChan
|
||||||
case err := <-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 {
|
if err != context.Canceled {
|
||||||
logger.Error("agent error", "error", err)
|
logger.Error("agent error", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -1133,3 +1326,49 @@ func getEnvDefault(key, defaultValue string) string {
|
|||||||
}
|
}
|
||||||
return defaultValue
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ func verifyDeployment(
|
|||||||
// calls, issuer connector communication, or any operation that trusts the
|
// calls, issuer connector communication, or any operation that trusts the
|
||||||
// certificate. The verification result compares SHA-256 fingerprints only.
|
// certificate. The verification result compares SHA-256 fingerprints only.
|
||||||
// See TICKET-016 for full security audit rationale.
|
// See TICKET-016 for full security audit rationale.
|
||||||
InsecureSkipVerify: true,
|
InsecureSkipVerify: true, //nolint:gosec // verification probe; documented above + docs/tls.md L-001 table
|
||||||
ServerName: targetHost, // For SNI
|
ServerName: targetHost, // For SNI
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
|
|||||||
ServerURL: server.URL,
|
ServerURL: server.URL,
|
||||||
APIKey: "test-api-key",
|
APIKey: "test-api-key",
|
||||||
}
|
}
|
||||||
agent := NewAgent(cfg, nil)
|
agent, _ := NewAgent(cfg, nil)
|
||||||
|
|
||||||
result := &VerificationResult{
|
result := &VerificationResult{
|
||||||
ExpectedFingerprint: "abc123",
|
ExpectedFingerprint: "abc123",
|
||||||
@@ -244,7 +244,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReportVerificationResult_MissingFields(t *testing.T) {
|
func TestReportVerificationResult_MissingFields(t *testing.T) {
|
||||||
agent := NewAgent(&AgentConfig{}, nil)
|
agent, _ := NewAgent(&AgentConfig{}, nil)
|
||||||
|
|
||||||
result := &VerificationResult{
|
result := &VerificationResult{
|
||||||
Verified: true,
|
Verified: true,
|
||||||
@@ -343,7 +343,7 @@ func TestReportVerificationResult_ServerError(t *testing.T) {
|
|||||||
ServerURL: server.URL,
|
ServerURL: server.URL,
|
||||||
APIKey: "test-api-key",
|
APIKey: "test-api-key",
|
||||||
}
|
}
|
||||||
agent := NewAgent(cfg, nil)
|
agent, _ := NewAgent(cfg, nil)
|
||||||
|
|
||||||
result := &VerificationResult{
|
result := &VerificationResult{
|
||||||
ExpectedFingerprint: "abc123",
|
ExpectedFingerprint: "abc123",
|
||||||
@@ -391,7 +391,13 @@ func TestVerifyDeployment_FingerprintComparison(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
// Get the server's TLS certificate from TLS config
|
// Q-1 closure (cat-s3-58ce7e9840be): defensive skip — httptest.NewTLSServer
|
||||||
|
// always provisions a self-signed certificate at construction time, so this
|
||||||
|
// branch is currently unreachable in practice. Kept as a guard against
|
||||||
|
// future test-server constructions that swap in a custom *tls.Config with
|
||||||
|
// no Certificates slice (the path below dereferences server.TLS.Certificates[0]
|
||||||
|
// and would panic). The skip preserves the assertion logic for the normal
|
||||||
|
// fixture path; if it ever fires, it's a fixture bug, not a product bug.
|
||||||
if len(server.TLS.Certificates) == 0 {
|
if len(server.TLS.Certificates) == 0 {
|
||||||
t.Skip("no TLS certificates configured on test server")
|
t.Skip("no TLS certificates configured on test server")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
|
||||||
|
//
|
||||||
|
// The existing `main_test.go` only covered `validateHTTPSScheme`. This file
|
||||||
|
// pins every dispatch arm in `handleCerts`, `handleAgents`, `handleJobs`,
|
||||||
|
// `handleImport`, `handleStatus` — both the "missing arg" usage prints and
|
||||||
|
// the happy-path delegation to `*cli.Client`.
|
||||||
|
//
|
||||||
|
// Strategy: spin up an `httptest.Server` mocking the relevant API routes so
|
||||||
|
// the client can exercise its end-to-end code path without a live server.
|
||||||
|
// For arms that print usage and return without calling the client, we pass
|
||||||
|
// a freshly-constructed client (still no network call — the client method
|
||||||
|
// is never invoked).
|
||||||
|
|
||||||
|
// newDispatchTestClient returns a `*cli.Client` pointed at the given test
|
||||||
|
// server. Calls `t.Fatal` on construction error.
|
||||||
|
func newDispatchTestClient(t *testing.T, server *httptest.Server) *cli.Client {
|
||||||
|
t.Helper()
|
||||||
|
// Configure the client with `insecure=true` because httptest.Server's
|
||||||
|
// self-signed TLS cert won't chain to a system root.
|
||||||
|
c, err := cli.NewClient(server.URL, "test-key", "json", "", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubServer returns an httptest.Server (TLS) that responds with the given
|
||||||
|
// JSON body and status code for any request. Tests that want to assert on
|
||||||
|
// the request shape can wrap it in a more specific handler.
|
||||||
|
func stubServer(t *testing.T, status int, body string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// handleCerts dispatch arms
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCerts_NoArgs_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{}); err != nil {
|
||||||
|
t.Errorf("handleCerts({}): unexpected err=%v (should print usage and return nil)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"frobnicate"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({frobnicate}): unexpected err=%v (should print usage and return nil)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"get"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({get}): unexpected err=%v (should print usage and return nil)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_RenewWithoutID_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"renew"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({renew}): unexpected err=%v (should print usage and return nil)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_RevokeWithoutID_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"revoke"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({revoke}): unexpected err=%v (should print usage and return nil)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_List_HitsClientPath(t *testing.T) {
|
||||||
|
// Asserts dispatch-path: handleCerts → c.ListCertificates → GET /api/v1/certificates.
|
||||||
|
var hits int
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits++
|
||||||
|
if r.Method != "GET" || !strings.HasPrefix(r.URL.Path, "/api/v1/certificates") {
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"list"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({list}): err=%v", err)
|
||||||
|
}
|
||||||
|
if hits != 1 {
|
||||||
|
t.Errorf("expected 1 server hit, got %d", hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_Get_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"id":"mc-x","name":"x"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"get", "mc-x"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({get, mc-x}): err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastPath, "/api/v1/certificates/mc-x") {
|
||||||
|
t.Errorf("expected GET on /api/v1/certificates/mc-x, got %q", lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_Renew_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath, lastMethod string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
lastMethod = r.Method
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"job_id":"job-1","status":"ok"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"renew", "mc-x"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({renew, mc-x}): err=%v", err)
|
||||||
|
}
|
||||||
|
if lastMethod != "POST" || !strings.Contains(lastPath, "/renew") {
|
||||||
|
t.Errorf("expected POST .../renew, got %s %s", lastMethod, lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_Revoke_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath, lastMethod, lastBody string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
lastMethod = r.Method
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, _ := r.Body.Read(buf)
|
||||||
|
lastBody = string(buf[:n])
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"revoked"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "compromise"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({revoke ...}): err=%v", err)
|
||||||
|
}
|
||||||
|
if lastMethod != "POST" || !strings.Contains(lastPath, "/revoke") {
|
||||||
|
t.Errorf("expected POST .../revoke, got %s %s", lastMethod, lastPath)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastBody, "compromise") {
|
||||||
|
t.Errorf("expected reason in body, got %q", lastBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCerts_BulkRevoke_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"total_matched":0,"total_revoked":0,"total_skipped":0,"total_failed":0}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleCerts(c, []string{"bulk-revoke", "--reason", "test"}); err != nil {
|
||||||
|
t.Errorf("handleCerts({bulk-revoke ...}): err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastPath, "/bulk-revoke") {
|
||||||
|
t.Errorf("expected /bulk-revoke path, got %q", lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// handleAgents dispatch arms
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleAgents_NoArgs_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleAgents(c, []string{}); err != nil {
|
||||||
|
t.Errorf("handleAgents({}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgents_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleAgents(c, []string{"frobnicate"}); err != nil {
|
||||||
|
t.Errorf("handleAgents({frobnicate}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgents_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleAgents(c, []string{"get"}); err != nil {
|
||||||
|
t.Errorf("handleAgents({get}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgents_RetireWithoutID_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleAgents(c, []string{"retire"}); err != nil {
|
||||||
|
t.Errorf("handleAgents({retire}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgents_List_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleAgents(c, []string{"list"}); err != nil {
|
||||||
|
t.Errorf("handleAgents({list}): err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastPath, "/api/v1/agents") {
|
||||||
|
t.Errorf("expected /api/v1/agents path, got %q", lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgents_ListRetired_HitsRetiredEndpoint(t *testing.T) {
|
||||||
|
// I-004: --retired flag splits to a separate /agents/retired endpoint.
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleAgents(c, []string{"list", "--retired"}); err != nil {
|
||||||
|
t.Errorf("handleAgents({list --retired}): err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastPath, "/agents/retired") {
|
||||||
|
t.Errorf("expected --retired to hit /agents/retired, got %q", lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgents_Get_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"id":"ag-x","status":"online"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleAgents(c, []string{"get", "ag-x"}); err != nil {
|
||||||
|
t.Errorf("handleAgents({get, ag-x}): err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastPath, "/agents/ag-x") {
|
||||||
|
t.Errorf("expected /agents/ag-x, got %q", lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// handleJobs dispatch arms
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleJobs_NoArgs_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleJobs(c, []string{}); err != nil {
|
||||||
|
t.Errorf("handleJobs({}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleJobs_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleJobs(c, []string{"frobnicate"}); err != nil {
|
||||||
|
t.Errorf("handleJobs({frobnicate}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleJobs_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleJobs(c, []string{"get"}); err != nil {
|
||||||
|
t.Errorf("handleJobs({get}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleJobs_CancelWithoutID_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleJobs(c, []string{"cancel"}); err != nil {
|
||||||
|
t.Errorf("handleJobs({cancel}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleJobs_List_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleJobs(c, []string{"list"}); err != nil {
|
||||||
|
t.Errorf("handleJobs({list}): err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastPath, "/api/v1/jobs") {
|
||||||
|
t.Errorf("expected /api/v1/jobs path, got %q", lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleJobs_Get_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"id":"job-x"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleJobs(c, []string{"get", "job-x"}); err != nil {
|
||||||
|
t.Errorf("handleJobs({get, job-x}): err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastPath, "/jobs/job-x") {
|
||||||
|
t.Errorf("expected /jobs/job-x, got %q", lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleJobs_Cancel_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath, lastMethod string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
lastMethod = r.Method
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"cancelled"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleJobs(c, []string{"cancel", "job-x"}); err != nil {
|
||||||
|
t.Errorf("handleJobs({cancel, job-x}): err=%v", err)
|
||||||
|
}
|
||||||
|
if lastMethod != "POST" || !strings.Contains(lastPath, "/cancel") {
|
||||||
|
t.Errorf("expected POST .../cancel, got %s %s", lastMethod, lastPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// handleImport / handleStatus dispatch arms
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleImport_NoArgs_PrintsUsage(t *testing.T) {
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleImport(c, []string{}); err != nil {
|
||||||
|
t.Errorf("handleImport({}): unexpected err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatus_HitsClientPath(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.WriteHeader(200)
|
||||||
|
// GetStatus expects {"status":..., "stats":...} or similar.
|
||||||
|
// Provide a minimal valid JSON object.
|
||||||
|
_, _ = w.Write([]byte(`{"status":"healthy","version":"v2.X","db":"connected"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := newDispatchTestClient(t, srv)
|
||||||
|
if err := handleStatus(c); err != nil {
|
||||||
|
// GetStatus's table output may complain about missing fields; we only
|
||||||
|
// care that the dispatch arm fired and the request reached the server.
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
if lastPath == "" {
|
||||||
|
t.Errorf("expected handleStatus to make at least one request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CLI client TLS sanity (Q.1: confirms NewClient configures TLS correctly).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCliClient_RejectsUntrustedCert_WhenNotInsecure(t *testing.T) {
|
||||||
|
// Without insecure=true, the self-signed httptest cert must fail TLS
|
||||||
|
// verification. This pins the security default.
|
||||||
|
srv := stubServer(t, 200, `{}`)
|
||||||
|
c, err := cli.NewClient(srv.URL, "k", "json", "", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
// Try a status call — should error out with a TLS verification failure,
|
||||||
|
// not silently succeed.
|
||||||
|
if err := c.GetStatus(); err == nil {
|
||||||
|
t.Errorf("expected TLS verification error against self-signed cert; got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCliClient_ParsesJSONResponse asserts the do() path's JSON unmarshalling
|
||||||
|
// succeeds end-to-end (one of the more error-prone paths in the client).
|
||||||
|
func TestCliClient_ParsesJSONResponse(t *testing.T) {
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{{"id": "mc-1", "name": "site-1"}},
|
||||||
|
"total": 1,
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c, err := cli.NewClient(srv.URL, "k", "json", "", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.ListCertificates(nil); err != nil {
|
||||||
|
t.Errorf("ListCertificates: err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-10
@@ -3,7 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/cli"
|
"github.com/shankar0123/certctl/internal/cli"
|
||||||
)
|
)
|
||||||
@@ -27,35 +29,50 @@ Commands:
|
|||||||
certs renew ID Trigger certificate renewal
|
certs renew ID Trigger certificate renewal
|
||||||
certs revoke ID Revoke a certificate
|
certs revoke ID Revoke a certificate
|
||||||
|
|
||||||
agents list List agents
|
agents list List agents (add --retired to list soft-retired agents)
|
||||||
agents get ID Get agent details
|
agents get ID Get agent details
|
||||||
|
agents retire ID Soft-retire an agent (add --force --reason "…" to cascade)
|
||||||
|
|
||||||
jobs list List jobs
|
jobs list List jobs
|
||||||
jobs get ID Get job details
|
jobs get ID Get job details
|
||||||
jobs cancel ID Cancel a pending job
|
jobs cancel ID Cancel a pending job
|
||||||
|
|
||||||
import FILE Bulk import certificates from PEM file(s)
|
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
|
status Show server health + summary stats
|
||||||
version Show CLI version
|
version Show CLI version
|
||||||
|
|
||||||
Examples:
|
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 certs renew mc-prod --format json
|
||||||
certctl-cli import certs.pem
|
certctl-cli import certs.pem
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
serverURL := fs.String("server", os.Getenv("CERTCTL_SERVER_URL"), "certctl server URL (env: CERTCTL_SERVER_URL)")
|
// HTTPS-Everywhere (v2.2): the server is HTTPS-only. The default URL uses
|
||||||
if *serverURL == "" {
|
// https://; plaintext http:// is rejected by validateHTTPSScheme below.
|
||||||
*serverURL = "http://localhost:8443"
|
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)")
|
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")
|
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:])
|
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()
|
args := fs.Args()
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
@@ -63,13 +80,16 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create client
|
// 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
|
// Dispatch to appropriate command
|
||||||
command := args[0]
|
command := args[0]
|
||||||
cmdArgs := args[1:]
|
cmdArgs := args[1:]
|
||||||
|
|
||||||
var err error
|
|
||||||
switch command {
|
switch command {
|
||||||
case "certs":
|
case "certs":
|
||||||
err = handleCerts(client, cmdArgs)
|
err = handleCerts(client, cmdArgs)
|
||||||
@@ -138,9 +158,19 @@ func handleCerts(client *cli.Client, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func handleAgents(client *cli.Client, args []string) error {
|
||||||
if len(args) == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,13 +179,34 @@ func handleAgents(client *cli.Client, args []string) error {
|
|||||||
|
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "list":
|
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":
|
case "get":
|
||||||
if len(subArgs) == 0 {
|
if len(subArgs) == 0 {
|
||||||
fmt.Fprintf(os.Stderr, "usage: agents get <id>\n")
|
fmt.Fprintf(os.Stderr, "usage: agents get <id>\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return client.GetAgent(subArgs[0])
|
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:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "unknown subcommand: agents %s\n", subcommand)
|
fmt.Fprintf(os.Stderr, "unknown subcommand: agents %s\n", subcommand)
|
||||||
return nil
|
return nil
|
||||||
@@ -203,3 +254,26 @@ func handleImport(client *cli.Client, args []string) error {
|
|||||||
func handleStatus(client *cli.Client) error {
|
func handleStatus(client *cli.Client) error {
|
||||||
return client.GetStatus()
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
|
||||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
@@ -16,14 +18,33 @@ import (
|
|||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
func main() {
|
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")
|
serverURL := os.Getenv("CERTCTL_SERVER_URL")
|
||||||
if serverURL == "" {
|
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")
|
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{
|
server := gomcp.NewServer(&gomcp.Implementation{
|
||||||
Name: "certctl",
|
Name: "certctl",
|
||||||
@@ -41,3 +62,26 @@ func main() {
|
|||||||
log.Fatalf("MCP server error: %v", err)
|
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,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt
|
||||||
|
// allowlist. cmd/server/main.go::buildFinalHandler decides per-request
|
||||||
|
// whether a path goes through the authenticated apiHandler or the
|
||||||
|
// no-auth handler. This test:
|
||||||
|
//
|
||||||
|
// - constructs a buildFinalHandler with two sentinel handlers (one
|
||||||
|
// for "auth", one for "no-auth") so we can observe which path is
|
||||||
|
// taken from the response body.
|
||||||
|
// - probes every prefix listed in router.AuthExemptDispatchPrefixes
|
||||||
|
// and confirms it routes to no-auth.
|
||||||
|
// - probes a few representative authenticated routes and confirms
|
||||||
|
// they route to auth.
|
||||||
|
// - probes the static-route allowlist (/health, /ready, etc.) that
|
||||||
|
// also bypasses auth at this layer.
|
||||||
|
//
|
||||||
|
// Adding a new auth-bypass to buildFinalHandler without updating the
|
||||||
|
// router.AuthExemptDispatchPrefixes constant fails this test.
|
||||||
|
|
||||||
|
func TestBuildFinalHandler_AuthExemptDispatchAllowlist(t *testing.T) {
|
||||||
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("AUTH"))
|
||||||
|
})
|
||||||
|
noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("NOAUTH"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// dashboardEnabled=false keeps the dispatch logic deterministic — no
|
||||||
|
// fileServer fallback to muddy the result.
|
||||||
|
final := buildFinalHandler(apiHandler, noAuthHandler, "/nonexistent", false)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// AuthExemptRouterRoutes (also enforced at this layer)
|
||||||
|
{"health", "/health", "NOAUTH"},
|
||||||
|
{"ready", "/ready", "NOAUTH"},
|
||||||
|
{"auth_info", "/api/v1/auth/info", "NOAUTH"},
|
||||||
|
{"version", "/api/v1/version", "NOAUTH"},
|
||||||
|
|
||||||
|
// AuthExemptDispatchPrefixes — every documented prefix
|
||||||
|
{"pki_crl", "/.well-known/pki/crl", "NOAUTH"},
|
||||||
|
{"pki_ocsp", "/.well-known/pki/ocsp", "NOAUTH"},
|
||||||
|
{"est_simpleenroll", "/.well-known/est/simpleenroll", "NOAUTH"},
|
||||||
|
{"est_cacerts", "/.well-known/est/cacerts", "NOAUTH"},
|
||||||
|
{"scep_root", "/scep", "NOAUTH"},
|
||||||
|
{"scep_op", "/scep/pkiclient.exe", "NOAUTH"},
|
||||||
|
|
||||||
|
// Authenticated routes — must hit apiHandler
|
||||||
|
{"certs_list", "/api/v1/certificates", "AUTH"},
|
||||||
|
{"agents_list", "/api/v1/agents", "AUTH"},
|
||||||
|
{"audit_check", "/api/v1/auth/check", "AUTH"},
|
||||||
|
|
||||||
|
// Random non-API path — falls through to apiHandler when
|
||||||
|
// dashboard disabled (preserves pre-M-001 API-only behavior).
|
||||||
|
{"unknown", "/some-other-path", "AUTH"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
final.ServeHTTP(rec, req)
|
||||||
|
got := rec.Body.String()
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("path %q routed to %q; want %q (this is the M-002 dispatch-layer pin)", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatch_NoUndocumentedBypasses asserts that for every prefix the
|
||||||
|
// dispatch layer routes to noAuthHandler, that prefix appears in the
|
||||||
|
// router.AuthExemptDispatchPrefixes constant. This is the inverse pin —
|
||||||
|
// adding a new bypass to buildFinalHandler without updating the constant
|
||||||
|
// fails this test.
|
||||||
|
//
|
||||||
|
// We probe a curated set of "would-be-bypasses" derived from the actual
|
||||||
|
// dispatch source by reading buildFinalHandler's lines. If the dispatch
|
||||||
|
// logic adds a new prefix that ends up in the no-auth chain, the
|
||||||
|
// curated set must be extended in the same commit that updates the
|
||||||
|
// constant — this fails-loud rather than silently allowing a bypass.
|
||||||
|
func TestDispatch_NoUndocumentedBypasses(t *testing.T) {
|
||||||
|
for _, prefix := range router.AuthExemptDispatchPrefixes {
|
||||||
|
if !strings.HasPrefix(prefix, "/") {
|
||||||
|
t.Errorf("AuthExemptDispatchPrefixes entry %q must start with / for prefix matching", prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every entry in router.AuthExemptDispatchPrefixes must round-trip
|
||||||
|
// through buildFinalHandler to noAuthHandler (covered by the table
|
||||||
|
// test above). This test additionally asserts the inverse: known
|
||||||
|
// authenticated prefixes do NOT match any documented bypass prefix.
|
||||||
|
authenticatedPrefixes := []string{
|
||||||
|
"/api/v1/certificates",
|
||||||
|
"/api/v1/agents",
|
||||||
|
"/api/v1/audit",
|
||||||
|
}
|
||||||
|
for _, ap := range authenticatedPrefixes {
|
||||||
|
for _, bypass := range router.AuthExemptDispatchPrefixes {
|
||||||
|
if strings.HasPrefix(ap, bypass) {
|
||||||
|
t.Errorf("authenticated prefix %q overlaps with documented bypass %q — auth bypass risk", ap, bypass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+561
-100
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,8 +17,6 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/api/router"
|
"github.com/shankar0123/certctl/internal/api/router"
|
||||||
"github.com/shankar0123/certctl/internal/config"
|
"github.com/shankar0123/certctl/internal/config"
|
||||||
"github.com/shankar0123/certctl/internal/crypto"
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
|
||||||
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm"
|
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm"
|
||||||
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv"
|
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv"
|
||||||
discoverygcpsm "github.com/shankar0123/certctl/internal/connector/discovery/gcpsm"
|
discoverygcpsm "github.com/shankar0123/certctl/internal/connector/discovery/gcpsm"
|
||||||
@@ -26,6 +25,7 @@ import (
|
|||||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
||||||
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
|
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
|
||||||
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams"
|
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/repository/postgres"
|
||||||
"github.com/shankar0123/certctl/internal/scheduler"
|
"github.com/shankar0123/certctl/internal/scheduler"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
@@ -39,6 +39,26 @@ func main() {
|
|||||||
os.Exit(1)
|
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
|
// Set up structured logging
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
Level: cfg.GetLogLevel(),
|
Level: cfg.GetLogLevel(),
|
||||||
@@ -49,6 +69,19 @@ func main() {
|
|||||||
"server_host", cfg.Server.Host,
|
"server_host", cfg.Server.Host,
|
||||||
"server_port", cfg.Server.Port)
|
"server_port", cfg.Server.Port)
|
||||||
|
|
||||||
|
// Bundle-5 / Audit H-007: deprecation WARN when the agent bootstrap
|
||||||
|
// token is unset. Pre-Bundle-5 there was no token at all; the v2.0.x
|
||||||
|
// default keeps the warn-mode pass-through so existing demo deploys
|
||||||
|
// keep working, but operators must set CERTCTL_AGENT_BOOTSTRAP_TOKEN
|
||||||
|
// before v2.2.0 lands. This is a one-shot startup line — the
|
||||||
|
// per-request path stays silent so a busy registration endpoint
|
||||||
|
// doesn't flood the log.
|
||||||
|
if cfg.Auth.AgentBootstrapToken == "" {
|
||||||
|
logger.Warn("agent bootstrap token unset (CERTCTL_AGENT_BOOTSTRAP_TOKEN) — agents may self-register without authentication; this default will become deny-by-default in v2.2.0; generate one with: openssl rand -hex 32")
|
||||||
|
} else {
|
||||||
|
logger.Info("agent bootstrap token configured (length redacted; constant-time compare on POST /api/v1/agents)")
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize database connection pool
|
// Initialize database connection pool
|
||||||
db, err := postgres.NewDB(cfg.Database.URL)
|
db, err := postgres.NewDB(cfg.Database.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,6 +99,41 @@ func main() {
|
|||||||
}
|
}
|
||||||
logger.Info("migrations completed")
|
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
|
// Initialize repositories with real PostgreSQL connection
|
||||||
auditRepo := postgres.NewAuditRepository(db)
|
auditRepo := postgres.NewAuditRepository(db)
|
||||||
certificateRepo := postgres.NewCertificateRepository(db)
|
certificateRepo := postgres.NewCertificateRepository(db)
|
||||||
@@ -82,12 +150,20 @@ func main() {
|
|||||||
logger.Info("initialized all repositories")
|
logger.Info("initialized all repositories")
|
||||||
|
|
||||||
// Initialize dynamic issuer registry.
|
// Initialize dynamic issuer registry.
|
||||||
// Issuers are loaded from the database (with AES-GCM encrypted config).
|
// 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.
|
// On first boot with an empty database, env var issuers are seeded automatically.
|
||||||
var encryptionKey []byte
|
//
|
||||||
if cfg.Encryption.ConfigEncryptionKey != "" {
|
// M-8 (CWE-916 / CWE-329): the encryption passphrase is passed as a raw
|
||||||
encryptionKey = crypto.DeriveKey(cfg.Encryption.ConfigEncryptionKey)
|
// string into IssuerService / TargetService / IssuerRegistry. Each call to
|
||||||
logger.Info("config encryption enabled (AES-256-GCM)")
|
// 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 {
|
} else {
|
||||||
// C-2 fix: fail closed at startup when database-sourced issuer or target
|
// C-2 fix: fail closed at startup when database-sourced issuer or target
|
||||||
// rows exist without a configured encryption key. Previously the server
|
// rows exist without a configured encryption key. Previously the server
|
||||||
@@ -138,6 +214,12 @@ func main() {
|
|||||||
// Initialize services (following the dependency graph)
|
// Initialize services (following the dependency graph)
|
||||||
auditService := service.NewAuditService(auditRepo)
|
auditService := service.NewAuditService(auditRepo)
|
||||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
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)
|
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
|
||||||
notifierRegistry := make(map[string]service.Notifier)
|
notifierRegistry := make(map[string]service.Notifier)
|
||||||
|
|
||||||
@@ -215,7 +297,10 @@ func main() {
|
|||||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||||
renewalService.SetTargetRepo(targetRepo)
|
renewalService.SetTargetRepo(targetRepo)
|
||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
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 := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
agentService.SetProfileRepo(profileRepo)
|
agentService.SetProfileRepo(profileRepo)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
|
||||||
@@ -246,9 +331,15 @@ func main() {
|
|||||||
Name: "Network Scanner (Server-Side)",
|
Name: "Network Scanner (Server-Side)",
|
||||||
Status: domain.AgentStatusOnline,
|
Status: domain.AgentStatusOnline,
|
||||||
}
|
}
|
||||||
if err := agentRepo.Create(context.Background(), sentinelAgent); err != nil {
|
// M-6: use CreateIfNotExists so duplicate rows on restart/upgrade are
|
||||||
// Ignore duplicate key errors (agent already exists)
|
// idempotent without swallowing unrelated DB failures (CWE-662).
|
||||||
logger.Debug("sentinel agent creation", "status", "exists or created", "id", service.SentinelAgentID)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,8 +358,14 @@ func main() {
|
|||||||
Name: "AWS Secrets Manager Discovery",
|
Name: "AWS Secrets Manager Discovery",
|
||||||
Status: domain.AgentStatusOnline,
|
Status: domain.AgentStatusOnline,
|
||||||
}
|
}
|
||||||
if err := agentRepo.Create(context.Background(), sentinelAWS); err != nil {
|
// M-6: idempotent create (CWE-662).
|
||||||
logger.Debug("sentinel agent creation", "status", "exists or created", "id", service.SentinelAWSSecretsMgr)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +383,14 @@ func main() {
|
|||||||
Name: "Azure Key Vault Discovery",
|
Name: "Azure Key Vault Discovery",
|
||||||
Status: domain.AgentStatusOnline,
|
Status: domain.AgentStatusOnline,
|
||||||
}
|
}
|
||||||
if err := agentRepo.Create(context.Background(), sentinelAzure); err != nil {
|
// M-6: idempotent create (CWE-662).
|
||||||
logger.Debug("sentinel agent creation", "status", "exists or created", "id", service.SentinelAzureKeyVault)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,8 +403,14 @@ func main() {
|
|||||||
Name: "GCP Secret Manager Discovery",
|
Name: "GCP Secret Manager Discovery",
|
||||||
Status: domain.AgentStatusOnline,
|
Status: domain.AgentStatusOnline,
|
||||||
}
|
}
|
||||||
if err := agentRepo.Create(context.Background(), sentinelGCP); err != nil {
|
// M-6: idempotent create (CWE-662).
|
||||||
logger.Debug("sentinel agent creation", "status", "exists or created", "id", service.SentinelGCPSecretMgr)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,17 +424,35 @@ func main() {
|
|||||||
// Initialize bulk revocation service
|
// Initialize bulk revocation service
|
||||||
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
|
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
|
// Initialize stats and metrics services
|
||||||
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
|
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")
|
logger.Info("initialized stats service")
|
||||||
|
|
||||||
// Initialize API handlers
|
// Initialize API handlers
|
||||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||||
issuerHandler := handler.NewIssuerHandler(issuerService)
|
issuerHandler := handler.NewIssuerHandler(issuerService)
|
||||||
targetHandler := handler.NewTargetHandler(targetService)
|
targetHandler := handler.NewTargetHandler(targetService)
|
||||||
agentHandler := handler.NewAgentHandler(agentService)
|
agentHandler := handler.NewAgentHandler(agentService, cfg.Auth.AgentBootstrapToken)
|
||||||
jobHandler := handler.NewJobHandler(jobService)
|
jobHandler := handler.NewJobHandler(jobService)
|
||||||
policyHandler := handler.NewPolicyHandler(policyService)
|
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)
|
profileHandler := handler.NewProfileHandler(profileService)
|
||||||
teamHandler := handler.NewTeamHandler(teamService)
|
teamHandler := handler.NewTeamHandler(teamService)
|
||||||
ownerHandler := handler.NewOwnerHandler(ownerService)
|
ownerHandler := handler.NewOwnerHandler(ownerService)
|
||||||
@@ -334,7 +461,16 @@ func main() {
|
|||||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||||
statsHandler := handler.NewStatsHandler(statsService)
|
statsHandler := handler.NewStatsHandler(statsService)
|
||||||
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
|
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
|
||||||
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
// Bundle-5 / H-006: pass the *sql.DB pool so /ready can probe DB
|
||||||
|
// connectivity via PingContext. /health stays shallow (liveness signal).
|
||||||
|
healthHandler := handler.NewHealthHandler(cfg.Auth.Type, db)
|
||||||
|
// 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)
|
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
|
||||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||||
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
||||||
@@ -343,6 +479,11 @@ func main() {
|
|||||||
exportHandler := handler.NewExportHandler(exportService)
|
exportHandler := handler.NewExportHandler(exportService)
|
||||||
|
|
||||||
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
|
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)
|
// Initialize digest service (requires email notifier)
|
||||||
var digestService *service.DigestService
|
var digestService *service.DigestService
|
||||||
@@ -405,8 +546,30 @@ func main() {
|
|||||||
// Configure scheduler intervals from config
|
// Configure scheduler intervals from config
|
||||||
sched.SetRenewalCheckInterval(cfg.Scheduler.RenewalCheckInterval)
|
sched.SetRenewalCheckInterval(cfg.Scheduler.RenewalCheckInterval)
|
||||||
sched.SetJobProcessorInterval(cfg.Scheduler.JobProcessorInterval)
|
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.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval)
|
||||||
sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval)
|
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 {
|
if cfg.NetworkScan.Enabled {
|
||||||
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
|
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
|
||||||
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
|
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
|
||||||
@@ -429,6 +592,16 @@ func main() {
|
|||||||
"sources", cloudDiscoveryService.SourceCount())
|
"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
|
// Start scheduler
|
||||||
logger.Info("starting scheduler")
|
logger.Info("starting scheduler")
|
||||||
startedChan := sched.Start(ctx)
|
startedChan := sched.Start(ctx)
|
||||||
@@ -438,28 +611,32 @@ func main() {
|
|||||||
// Build the API router with all handlers
|
// Build the API router with all handlers
|
||||||
apiRouter := router.New()
|
apiRouter := router.New()
|
||||||
apiRouter.RegisterHandlers(router.HandlerRegistry{
|
apiRouter.RegisterHandlers(router.HandlerRegistry{
|
||||||
Certificates: certificateHandler,
|
Certificates: certificateHandler,
|
||||||
Issuers: issuerHandler,
|
Issuers: issuerHandler,
|
||||||
Targets: targetHandler,
|
Targets: targetHandler,
|
||||||
Agents: agentHandler,
|
Agents: agentHandler,
|
||||||
Jobs: jobHandler,
|
Jobs: jobHandler,
|
||||||
Policies: policyHandler,
|
Policies: policyHandler,
|
||||||
Profiles: profileHandler,
|
RenewalPolicies: renewalPolicyHandler,
|
||||||
Teams: teamHandler,
|
Profiles: profileHandler,
|
||||||
Owners: ownerHandler,
|
Teams: teamHandler,
|
||||||
AgentGroups: agentGroupHandler,
|
Owners: ownerHandler,
|
||||||
Audit: auditHandler,
|
AgentGroups: agentGroupHandler,
|
||||||
Notifications: notificationHandler,
|
Audit: auditHandler,
|
||||||
Stats: statsHandler,
|
Notifications: notificationHandler,
|
||||||
Metrics: metricsHandler,
|
Stats: statsHandler,
|
||||||
Health: healthHandler,
|
Metrics: metricsHandler,
|
||||||
Discovery: discoveryHandler,
|
Health: healthHandler,
|
||||||
NetworkScan: networkScanHandler,
|
Discovery: discoveryHandler,
|
||||||
Verification: verificationHandler,
|
NetworkScan: networkScanHandler,
|
||||||
Export: exportHandler,
|
Verification: verificationHandler,
|
||||||
Digest: *digestHandler,
|
Export: exportHandler,
|
||||||
HealthChecks: healthCheckHandler,
|
Digest: *digestHandler,
|
||||||
|
HealthChecks: healthCheckHandler,
|
||||||
BulkRevocation: bulkRevocationHandler,
|
BulkRevocation: bulkRevocationHandler,
|
||||||
|
BulkRenewal: bulkRenewalHandler,
|
||||||
|
BulkReassignment: bulkReassignmentHandler,
|
||||||
|
Version: versionHandler,
|
||||||
})
|
})
|
||||||
// Register EST (RFC 7030) handlers if enabled
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
if cfg.EST.Enabled {
|
if cfg.EST.Enabled {
|
||||||
@@ -468,6 +645,17 @@ func main() {
|
|||||||
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// Bundle-4 / L-005: validate the issuer can actually serve a CA certificate
|
||||||
|
// at startup, not at first request time. ACME / DigiCert / Sectigo etc.
|
||||||
|
// return an error from GetCACertPEM because they don't expose a static
|
||||||
|
// CA chain; binding EST to one of those would silently degrade enrollment.
|
||||||
|
preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
if err := preflightEnrollmentIssuer(preflightCtx, "EST", cfg.EST.IssuerID, issuerConn); err != nil {
|
||||||
|
preflightCancel()
|
||||||
|
logger.Error("startup refused: EST issuer cannot serve CA certificate", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
preflightCancel()
|
||||||
estService := service.NewESTService(cfg.EST.IssuerID, issuerConn, auditService, logger)
|
estService := service.NewESTService(cfg.EST.IssuerID, issuerConn, auditService, logger)
|
||||||
estService.SetProfileRepo(profileRepo)
|
estService.SetProfileRepo(profileRepo)
|
||||||
if cfg.EST.ProfileID != "" {
|
if cfg.EST.ProfileID != "" {
|
||||||
@@ -506,6 +694,15 @@ func main() {
|
|||||||
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
|
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// Bundle-4 / L-005: validate the issuer can actually serve a CA certificate
|
||||||
|
// at startup. Same rationale as EST above.
|
||||||
|
preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
if err := preflightEnrollmentIssuer(preflightCtx, "SCEP", cfg.SCEP.IssuerID, issuerConn); err != nil {
|
||||||
|
preflightCancel()
|
||||||
|
logger.Error("startup refused: SCEP issuer cannot serve CA certificate", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
preflightCancel()
|
||||||
scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword)
|
scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword)
|
||||||
scepService.SetProfileRepo(profileRepo)
|
scepService.SetProfileRepo(profileRepo)
|
||||||
if cfg.SCEP.ProfileID != "" {
|
if cfg.SCEP.ProfileID != "" {
|
||||||
@@ -520,13 +717,63 @@ func main() {
|
|||||||
"endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}")
|
"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")
|
logger.Info("registered all API handlers")
|
||||||
|
|
||||||
// Build middleware stack
|
// Build middleware stack.
|
||||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
//
|
||||||
Type: cfg.Auth.Type,
|
// Authentication unification (M-002): every authenticated request now
|
||||||
Secret: cfg.Auth.Secret,
|
// 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{
|
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
|
||||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||||
})
|
})
|
||||||
@@ -539,6 +786,17 @@ func main() {
|
|||||||
})
|
})
|
||||||
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
|
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
|
// API audit log middleware — records every API call to the audit trail
|
||||||
auditAdapter := middleware.NewAuditServiceAdapter(
|
auditAdapter := middleware.NewAuditServiceAdapter(
|
||||||
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
||||||
@@ -546,26 +804,37 @@ func main() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
|
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: 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{
|
middlewareStack := []func(http.Handler) http.Handler{
|
||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
bodyLimitMiddleware,
|
bodyLimitMiddleware,
|
||||||
|
securityHeadersMiddleware,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
auditMiddleware,
|
auditMiddleware.Middleware,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add rate limiter if enabled
|
// Add rate limiter if enabled
|
||||||
if cfg.RateLimit.Enabled {
|
if cfg.RateLimit.Enabled {
|
||||||
|
// Bundle B / Audit M-025: per-user / per-IP keying. PerUser{RPS,Burst}
|
||||||
|
// fall back to RPS / BurstSize when zero; see middleware.NewRateLimiter
|
||||||
|
// for the bucket-creation contract.
|
||||||
rateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
rateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
||||||
RPS: cfg.RateLimit.RPS,
|
RPS: cfg.RateLimit.RPS,
|
||||||
BurstSize: cfg.RateLimit.BurstSize,
|
BurstSize: cfg.RateLimit.BurstSize,
|
||||||
|
PerUserRPS: cfg.RateLimit.PerUserRPS,
|
||||||
|
PerUserBurstSize: cfg.RateLimit.PerUserBurstSize,
|
||||||
})
|
})
|
||||||
middlewareStack = []func(http.Handler) http.Handler{
|
middlewareStack = []func(http.Handler) http.Handler{
|
||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
@@ -575,13 +844,13 @@ func main() {
|
|||||||
rateLimiter,
|
rateLimiter,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
auditMiddleware,
|
auditMiddleware.Middleware,
|
||||||
}
|
}
|
||||||
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
|
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Auth.Type == "none" {
|
if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone {
|
||||||
logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production")
|
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 {
|
} else {
|
||||||
logger.Info("authentication enabled", "type", cfg.Auth.Type)
|
logger.Info("authentication enabled", "type", cfg.Auth.Type)
|
||||||
}
|
}
|
||||||
@@ -602,70 +871,106 @@ func main() {
|
|||||||
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
||||||
webDir = "./web"
|
webDir = "./web"
|
||||||
}
|
}
|
||||||
// Health/ready routes bypass the full middleware stack (no auth required).
|
// Health/ready routes + EST/SCEP/PKI unauth surface bypass the full
|
||||||
// These are registered on the inner router without auth, but the outer
|
// middleware stack (no auth required). These are registered on the
|
||||||
// middleware chain wraps everything. Route them directly to the inner router.
|
// inner router without auth, but the outer middleware chain wraps
|
||||||
noAuthHandler := middleware.Chain(apiRouter,
|
// 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.
|
||||||
|
//
|
||||||
|
// Bundle C / Audit M-020 (CWE-770): rate limiter added to the noAuth
|
||||||
|
// chain. Pre-bundle the unauth surface had NO rate limit — an attacker
|
||||||
|
// could DoS the OCSP responder, which for fail-open relying parties
|
||||||
|
// constitutes a revocation bypass (every cert appears valid when the
|
||||||
|
// responder is unreachable). The same per-key keyed bucket from
|
||||||
|
// Bundle B / M-025 is reused; the per-source-IP keying applies because
|
||||||
|
// none of these endpoints are authenticated.
|
||||||
|
noAuthMiddleware := []func(http.Handler) http.Handler{
|
||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
)
|
bodyLimitMiddleware,
|
||||||
|
securityHeadersMiddleware,
|
||||||
if _, err := os.Stat(webDir + "/index.html"); err == nil {
|
}
|
||||||
fileServer := http.FileServer(http.Dir(webDir))
|
if cfg.RateLimit.Enabled {
|
||||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
noAuthRateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
||||||
path := r.URL.Path
|
RPS: cfg.RateLimit.RPS,
|
||||||
// Health/ready and auth/info bypass auth middleware.
|
BurstSize: cfg.RateLimit.BurstSize,
|
||||||
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
|
||||||
// auth/info: React app calls this before login to detect auth mode.
|
|
||||||
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
|
|
||||||
noAuthHandler.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// All other API and EST routes go through the full middleware stack (with auth)
|
|
||||||
if (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")
|
|
||||||
})
|
})
|
||||||
|
noAuthMiddleware = append(noAuthMiddleware, noAuthRateLimiter)
|
||||||
|
}
|
||||||
|
noAuthHandler := middleware.Chain(apiRouter, noAuthMiddleware...)
|
||||||
|
|
||||||
|
dashboardEnabled := false
|
||||||
|
if _, err := os.Stat(webDir + "/index.html"); err == nil {
|
||||||
|
dashboardEnabled = true
|
||||||
|
}
|
||||||
|
finalHandler = buildFinalHandler(apiHandler, noAuthHandler, webDir, dashboardEnabled)
|
||||||
|
if dashboardEnabled {
|
||||||
logger.Info("dashboard available at /", "web_dir", webDir)
|
logger.Info("dashboard available at /", "web_dir", webDir)
|
||||||
} else {
|
} else {
|
||||||
// No dashboard: route health/auth-info without auth, everything else through full stack
|
|
||||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
|
|
||||||
noAuthHandler.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
apiHandler.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
logger.Info("dashboard directory not found, serving API only")
|
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
|
// Server configuration
|
||||||
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: finalHandler,
|
Handler: finalHandler,
|
||||||
|
TLSConfig: buildServerTLSConfig(tlsCertHolder),
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
|
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start HTTP server in background
|
// Start HTTPS server in background. ListenAndServeTLS is called with
|
||||||
logger.Info("starting HTTP server", "address", addr)
|
// 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() {
|
go func() {
|
||||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := httpServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Error("HTTP server error", "error", err)
|
logger.Error("HTTPS server error", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -676,8 +981,22 @@ func main() {
|
|||||||
sig := <-sigChan
|
sig := <-sigChan
|
||||||
logger.Info("received shutdown signal", "signal", sig.String())
|
logger.Info("received shutdown signal", "signal", sig.String())
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown.
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
//
|
||||||
|
// Bundle-5 / Audit M-011: pre-Bundle-5 the timeout was hard-coded
|
||||||
|
// 30s, so high-volume operators couldn't extend the audit-flush
|
||||||
|
// window without forking the binary. Now configurable via
|
||||||
|
// CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS (default 30s preserves prior
|
||||||
|
// behaviour). The same context governs HTTP server shutdown +
|
||||||
|
// scheduler completion + audit flush. WARN-log on deadline exceeded;
|
||||||
|
// never exit hard — operator gets visibility, server still completes
|
||||||
|
// shutdown.
|
||||||
|
shutdownTimeout := time.Duration(cfg.Server.AuditFlushTimeoutSeconds) * time.Second
|
||||||
|
if shutdownTimeout <= 0 {
|
||||||
|
shutdownTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
logger.Info("graceful shutdown budget", "timeout_seconds", int(shutdownTimeout/time.Second))
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|
||||||
cancel() // Stop scheduler
|
cancel() // Stop scheduler
|
||||||
@@ -688,9 +1007,20 @@ func main() {
|
|||||||
logger.Warn("scheduler work did not complete in time", "error", err)
|
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 {
|
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
|
// Close database connection
|
||||||
@@ -721,3 +1051,134 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
|
||||||
|
// can actually serve a CA certificate. This closes audit finding L-005:
|
||||||
|
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the
|
||||||
|
// registry but did not verify the issuer TYPE could emit a CA cert. An
|
||||||
|
// operator who bound CERTCTL_EST_ISSUER_ID to an ACME issuer (which does
|
||||||
|
// not have a static CA cert — see internal/connector/issuer/acme/acme.go::
|
||||||
|
// GetCACertPEM returning an explicit error) would boot successfully and
|
||||||
|
// only see failures at the first /est/cacerts request, hiding the misconfig
|
||||||
|
// for hours/days behind a degraded enrollment surface.
|
||||||
|
//
|
||||||
|
// Strategy: call issuerConn.GetCACertPEM(ctx) at startup with a short
|
||||||
|
// timeout. If the issuer can serve a CA cert (local, vault, openssl,
|
||||||
|
// stepca, awsacmpca, etc.), the call succeeds and we proceed. If not
|
||||||
|
// (acme, digicert, sectigo, entrust, googlecas, ejbca, globalsign — most
|
||||||
|
// vendor-CA issuers that hand back chains per-issuance), the call fails
|
||||||
|
// loudly with the connector's own error string, and the caller os.Exit(1)s.
|
||||||
|
//
|
||||||
|
// Returns nil on success, non-nil error suitable for structured logging
|
||||||
|
// + os.Exit(1) by the caller. Caller is responsible for the timeout context.
|
||||||
|
func preflightEnrollmentIssuer(ctx context.Context, protocol, issuerID string, issuerConn service.IssuerConnector) error {
|
||||||
|
if issuerConn == nil {
|
||||||
|
return fmt.Errorf("%s issuer %q: connector is nil", protocol, issuerID)
|
||||||
|
}
|
||||||
|
caCertPEM, err := issuerConn.GetCACertPEM(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s issuer %q: cannot serve CA certificate (%w); "+
|
||||||
|
"choose an issuer type that exposes a static CA chain "+
|
||||||
|
"(local / vault / openssl / stepca / awsacmpca) or disable %s",
|
||||||
|
protocol, issuerID, err, protocol)
|
||||||
|
}
|
||||||
|
if caCertPEM == "" {
|
||||||
|
return fmt.Errorf("%s issuer %q: GetCACertPEM returned empty PEM with no error; "+
|
||||||
|
"choose an issuer type that exposes a static CA chain", protocol, issuerID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFinalHandler builds the outer HTTP dispatch handler that routes incoming
|
||||||
|
// requests to either the authenticated apiHandler chain or the unauthenticated
|
||||||
|
// noAuthHandler chain based on URL path prefix. Extracted from main() so the
|
||||||
|
// dispatch logic can be unit tested without booting the full server stack
|
||||||
|
// (see cmd/server/finalhandler_test.go).
|
||||||
|
//
|
||||||
|
// Dispatch rules (M-001, audit 2026-04-19, option D):
|
||||||
|
//
|
||||||
|
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
|
||||||
|
// - /api/v1/version → no-auth (U-3 ride-along: build identity for rollout/probes)
|
||||||
|
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
|
||||||
|
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
|
||||||
|
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
|
||||||
|
// - /api/v1/* → auth (Bearer token required)
|
||||||
|
// - /assets/* → static file server (dashboard only)
|
||||||
|
// - anything else → SPA index.html fallback (dashboard only)
|
||||||
|
// OR apiHandler (no dashboard)
|
||||||
|
//
|
||||||
|
// EST/SCEP clients (IoT devices, 802.1X supplicants, MDM endpoints, network
|
||||||
|
// appliances) cannot present certctl Bearer tokens, so those endpoints must be
|
||||||
|
// reachable without the Auth middleware. Authentication is instead enforced by
|
||||||
|
// CSR signature verification, profile policy gates, and for SCEP the
|
||||||
|
// challengePassword shared secret (fail-loud gated by preflightSCEPChallengePassword
|
||||||
|
// above).
|
||||||
|
//
|
||||||
|
// webDir must point to a directory containing index.html + assets/ when
|
||||||
|
// dashboardEnabled is true; it is ignored otherwise.
|
||||||
|
func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, dashboardEnabled bool) http.Handler {
|
||||||
|
var fileServer http.Handler
|
||||||
|
if dashboardEnabled {
|
||||||
|
fileServer = http.FileServer(http.Dir(webDir))
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
// Health/ready, auth/info, and version bypass auth middleware.
|
||||||
|
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
||||||
|
// auth/info: React app calls this before login to detect auth mode.
|
||||||
|
// version: U-3 ride-along (cat-u-no_version_endpoint) — rollout
|
||||||
|
// systems and blackbox probes need build identity without a key.
|
||||||
|
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" || path == "/api/v1/version" {
|
||||||
|
noAuthHandler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 5280 CRL and RFC 6960 OCSP live under /.well-known/pki/ and MUST
|
||||||
|
// be served unauthenticated — relying parties (browsers, OpenSSL, OCSP
|
||||||
|
// stapling sidecars, mTLS clients) cannot present certctl Bearer tokens.
|
||||||
|
if strings.HasPrefix(path, "/.well-known/pki") {
|
||||||
|
noAuthHandler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 7030 EST endpoints ride the no-auth middleware chain (M-001,
|
||||||
|
// option D, audit 2026-04-19). Trust boundary is CSR signature + 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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+52
-12
@@ -44,9 +44,8 @@ func TestMain_HealthEndpointBypassesAuth(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Build the handler chain the same way main.go does
|
// Build the handler chain the same way main.go does
|
||||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
|
||||||
Type: "api-key",
|
{Name: "test", Key: "test-secret-key"},
|
||||||
Secret: "test-secret-key",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// API handler with auth
|
// API handler with auth
|
||||||
@@ -160,9 +159,8 @@ func TestMain_AuthMiddlewareRejectsUnauthorized(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Wrap with auth middleware
|
// Wrap with auth middleware
|
||||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
|
||||||
Type: "api-key",
|
{Name: "test", Key: "test-secret-key"},
|
||||||
Secret: "test-secret-key",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||||
@@ -189,9 +187,8 @@ func TestMain_AuthMiddlewareAllowsWithValidKey(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Wrap with auth middleware
|
// Wrap with auth middleware
|
||||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
|
||||||
Type: "api-key",
|
{Name: "test", Key: testKey},
|
||||||
Secret: testKey,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||||
@@ -214,6 +211,8 @@ func TestMain_ServerConfigFromEnvironment(t *testing.T) {
|
|||||||
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
||||||
oldServerHost := os.Getenv("CERTCTL_SERVER_HOST")
|
oldServerHost := os.Getenv("CERTCTL_SERVER_HOST")
|
||||||
oldServerPort := os.Getenv("CERTCTL_SERVER_PORT")
|
oldServerPort := os.Getenv("CERTCTL_SERVER_PORT")
|
||||||
|
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||||
|
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||||
defer func() {
|
defer func() {
|
||||||
if oldAuthType != "" {
|
if oldAuthType != "" {
|
||||||
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
||||||
@@ -230,12 +229,32 @@ func TestMain_ServerConfigFromEnvironment(t *testing.T) {
|
|||||||
} else {
|
} else {
|
||||||
os.Unsetenv("CERTCTL_SERVER_PORT")
|
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
|
// Set test env vars
|
||||||
os.Setenv("CERTCTL_AUTH_TYPE", "none")
|
os.Setenv("CERTCTL_AUTH_TYPE", "none")
|
||||||
os.Setenv("CERTCTL_SERVER_HOST", "127.0.0.1")
|
os.Setenv("CERTCTL_SERVER_HOST", "127.0.0.1")
|
||||||
os.Setenv("CERTCTL_SERVER_PORT", "8080")
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -260,6 +279,8 @@ func TestMain_AuthTypeConfiguration(t *testing.T) {
|
|||||||
// Save original env vars
|
// Save original env vars
|
||||||
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
||||||
oldAuthSecret := os.Getenv("CERTCTL_AUTH_SECRET")
|
oldAuthSecret := os.Getenv("CERTCTL_AUTH_SECRET")
|
||||||
|
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||||
|
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||||
defer func() {
|
defer func() {
|
||||||
if oldAuthType != "" {
|
if oldAuthType != "" {
|
||||||
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
||||||
@@ -271,8 +292,28 @@ func TestMain_AuthTypeConfiguration(t *testing.T) {
|
|||||||
} else {
|
} else {
|
||||||
os.Unsetenv("CERTCTL_AUTH_SECRET")
|
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
|
// Set auth secret for api-key mode
|
||||||
os.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
os.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
|
||||||
@@ -418,9 +459,8 @@ func TestMain_AuthNoneMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Wrap with auth middleware in "none" mode
|
// Wrap with auth middleware in "none" mode
|
||||||
authMiddleware := middleware.NewAuth(middleware.AuthConfig{
|
// auth=none equivalent: empty named-keys list is a no-op pass-through.
|
||||||
Type: "none",
|
authMiddleware := middleware.NewAuthWithNamedKeys(nil)
|
||||||
})
|
|
||||||
|
|
||||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
|
||||||
|
type fakeIssuerConn struct {
|
||||||
|
caCertPEM string
|
||||||
|
caCertErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIssuerConn) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*service.IssuanceResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeIssuerConn) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*service.IssuanceResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeIssuerConn) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeIssuerConn) GenerateCRL(ctx context.Context, revokedCerts []service.CRLEntry) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeIssuerConn) SignOCSPResponse(ctx context.Context, req service.OCSPSignRequest) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeIssuerConn) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return f.caCertPEM, f.caCertErr
|
||||||
|
}
|
||||||
|
func (f *fakeIssuerConn) GetRenewalInfo(ctx context.Context, certPEM string) (*service.RenewalInfoResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreflightEnrollmentIssuer covers Bundle-4 / L-005 startup validation
|
||||||
|
// for EST/SCEP issuer binding.
|
||||||
|
func TestPreflightEnrollmentIssuer(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
issuer service.IssuerConnector
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil_connector_fails",
|
||||||
|
issuer: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "connector is nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "issuer_returns_error_fails",
|
||||||
|
issuer: &fakeIssuerConn{
|
||||||
|
caCertErr: errStub("ACME issuers do not provide a static CA certificate"),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "cannot serve CA certificate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "issuer_returns_empty_pem_fails",
|
||||||
|
issuer: &fakeIssuerConn{
|
||||||
|
caCertPEM: "",
|
||||||
|
caCertErr: nil,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "empty PEM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "issuer_returns_valid_pem_succeeds",
|
||||||
|
issuer: &fakeIssuerConn{
|
||||||
|
caCertPEM: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
|
||||||
|
caCertErr: nil,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := preflightEnrollmentIssuer(context.Background(), "EST", "iss-test", tc.issuer)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if tc.wantErr && tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
|
||||||
|
t.Fatalf("error %q missing substring %q", err.Error(), tc.errContains)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errStub is a tiny error wrapper so test cases can use string literals
|
||||||
|
// without importing fmt in every test struct entry.
|
||||||
|
type errStub string
|
||||||
|
|
||||||
|
func (e errStub) Error() string { return string(e) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-5
@@ -55,7 +55,7 @@ A compose file defines **services** (containers), **networks** (how they talk to
|
|||||||
|
|
||||||
**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.
|
**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, `http://localhost:8443` on your machine reaches the certctl server inside its container.
|
**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/`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,11 +91,13 @@ Wait about 30 seconds, then verify:
|
|||||||
docker compose -f deploy/docker-compose.yml ps
|
docker compose -f deploy/docker-compose.yml ps
|
||||||
# All three services should show "Up (healthy)"
|
# All three services should show "Up (healthy)"
|
||||||
|
|
||||||
curl http://localhost:8443/health
|
curl --cacert ./deploy/test/certs/ca.crt https://localhost:8443/health
|
||||||
# {"status":"healthy"}
|
# {"status":"healthy"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://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.
|
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
|
### Service-by-service walkthrough
|
||||||
|
|
||||||
@@ -120,6 +122,8 @@ The `volumes` section mounts 10 migration files into PostgreSQL's init directory
|
|||||||
|
|
||||||
**Expert note:** The numbered prefix pattern (`001_`, `002_`, ..., `020_`) ensures deterministic execution order. All migrations use `IF NOT EXISTS` and `ON CONFLICT DO NOTHING` for idempotency, so re-running them against an existing database is safe.
|
**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
|
#### certctl Server
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -307,8 +311,9 @@ 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:
|
Wait for all health checks to pass (about 60 seconds for step-ca's first-run bootstrap). Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dashboard with auth enabled
|
# Dashboard with auth enabled (HTTPS-only as of v2.2; browser will warn on the self-signed cert —
|
||||||
open http://localhost:8443
|
# accept the warning or trust `deploy/test/certs/ca.crt` in your OS keychain)
|
||||||
|
open https://localhost:8443
|
||||||
# API key: test-key-2026
|
# API key: test-key-2026
|
||||||
|
|
||||||
# NGINX serving a self-signed placeholder
|
# NGINX serving a self-signed placeholder
|
||||||
|
|||||||
@@ -7,8 +7,20 @@
|
|||||||
# To start fresh (wipe previous data):
|
# 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 down -v
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
# 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:
|
services:
|
||||||
postgres:
|
certctl-server:
|
||||||
volumes:
|
environment:
|
||||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql
|
CERTCTL_DEMO_SEED: "true"
|
||||||
|
|||||||
+114
-18
@@ -4,8 +4,12 @@
|
|||||||
#
|
#
|
||||||
# Spins up the full certctl platform with real CA backends for manual QA:
|
# 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)
|
# 1. PostgreSQL 16 — database (clean, no demo data)
|
||||||
# 2. certctl-server — control plane API + web dashboard on :8443
|
# 2. certctl-server — control plane API + web dashboard on :8443 (HTTPS)
|
||||||
# 3. certctl-agent — polls for work, deploys certs to NGINX
|
# 3. certctl-agent — polls for work, deploys certs to NGINX
|
||||||
# 4. step-ca — private CA (JWK provisioner, auto-bootstraps)
|
# 4. step-ca — private CA (JWK provisioner, auto-bootstraps)
|
||||||
# 5. Pebble — ACME test server (simulates Let's Encrypt)
|
# 5. Pebble — ACME test server (simulates Let's Encrypt)
|
||||||
@@ -16,18 +20,90 @@
|
|||||||
# cd deploy
|
# cd deploy
|
||||||
# docker compose -f docker-compose.test.yml up --build
|
# docker compose -f docker-compose.test.yml up --build
|
||||||
#
|
#
|
||||||
# Dashboard: http://localhost:8443
|
# Dashboard: https://localhost:8443 (self-signed — use --cacert test/certs/ca.crt)
|
||||||
# API key: test-key-2026
|
# API key: test-key-2026
|
||||||
# NGINX: https://localhost:8444 (self-signed placeholder until cert deployed)
|
# 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.
|
# See docs/test-env.md for the full walkthrough.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
services:
|
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
|
# 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:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: certctl-test-postgres
|
container_name: certctl-test-postgres
|
||||||
@@ -37,19 +113,6 @@ services:
|
|||||||
POSTGRES_PASSWORD: testpass
|
POSTGRES_PASSWORD: testpass
|
||||||
volumes:
|
volumes:
|
||||||
- test_postgres_data:/var/lib/postgresql/data
|
- test_postgres_data:/var/lib/postgresql/data
|
||||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
|
||||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
|
||||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
|
||||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
|
||||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
|
||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
|
||||||
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
|
||||||
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
|
||||||
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql
|
|
||||||
# No seed_demo.sql — start with a clean database for real testing
|
|
||||||
networks:
|
networks:
|
||||||
certctl-test:
|
certctl-test:
|
||||||
ipv4_address: 10.30.50.2
|
ipv4_address: 10.30.50.2
|
||||||
@@ -60,6 +123,7 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -168,6 +232,12 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
step-ca:
|
step-ca:
|
||||||
condition: service_healthy
|
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.
|
# Run as root so update-ca-certificates can write to /etc/ssl/certs.
|
||||||
# Container isolation provides the security boundary.
|
# Container isolation provides the security boundary.
|
||||||
user: "0:0"
|
user: "0:0"
|
||||||
@@ -179,6 +249,12 @@ services:
|
|||||||
# Server
|
# Server
|
||||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||||
CERTCTL_SERVER_PORT: 8443
|
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
|
CERTCTL_LOG_LEVEL: debug
|
||||||
|
|
||||||
# Auth — API key required (production-like)
|
# Auth — API key required (production-like)
|
||||||
@@ -224,12 +300,22 @@ services:
|
|||||||
- ./test/setup-trust.sh:/app/setup-trust.sh:ro
|
- ./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)
|
# step-ca data volume (root cert at /certs/root_ca.crt, key at /secrets/provisioner_key)
|
||||||
- stepca_data:/stepca-data:ro
|
- 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:
|
networks:
|
||||||
certctl-test:
|
certctl-test:
|
||||||
ipv4_address: 10.30.50.6
|
ipv4_address: 10.30.50.6
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the Bearer token
|
# HTTPS-Everywhere Phase 6: healthcheck now speaks TLS with --cacert to
|
||||||
test: ["CMD", "curl", "-f", "-H", "Authorization: Bearer test-key-2026", "http://localhost:8443/health"]
|
# 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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
@@ -290,7 +376,13 @@ services:
|
|||||||
certctl-server:
|
certctl-server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
# 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_API_KEY: test-key-2026
|
||||||
CERTCTL_AGENT_NAME: test-agent-01
|
CERTCTL_AGENT_NAME: test-agent-01
|
||||||
CERTCTL_AGENT_ID: agent-test-01
|
CERTCTL_AGENT_ID: agent-test-01
|
||||||
@@ -300,6 +392,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- agent_keys:/var/lib/certctl/keys
|
- agent_keys:/var/lib/certctl/keys
|
||||||
- nginx_certs:/nginx-certs
|
- 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:
|
networks:
|
||||||
certctl-test:
|
certctl-test:
|
||||||
ipv4_address: 10.30.50.8
|
ipv4_address: 10.30.50.8
|
||||||
|
|||||||
+99
-14
@@ -1,5 +1,81 @@
|
|||||||
services:
|
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
|
# 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:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: certctl-postgres
|
container_name: certctl-postgres
|
||||||
@@ -11,17 +87,6 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
|
||||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
|
||||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
|
||||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
|
||||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
|
||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
|
||||||
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
|
||||||
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
|
||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -29,6 +94,7 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Certctl Server (API + scheduler)
|
# Certctl Server (API + scheduler)
|
||||||
@@ -50,10 +116,18 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
certctl-tls-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable
|
# Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319): in-cluster Postgres
|
||||||
|
# on the docker bridge network keeps sslmode=disable acceptable; for
|
||||||
|
# external/managed Postgres operators MUST override CERTCTL_DATABASE_URL
|
||||||
|
# with sslmode=verify-full and provide the CA bundle. See docs/database-tls.md.
|
||||||
|
CERTCTL_DATABASE_URL: ${CERTCTL_DATABASE_URL:-postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable}
|
||||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||||
CERTCTL_SERVER_PORT: 8443
|
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_LOG_LEVEL: info
|
||||||
CERTCTL_AUTH_TYPE: none
|
CERTCTL_AUTH_TYPE: none
|
||||||
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
|
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
|
||||||
@@ -61,13 +135,20 @@ services:
|
|||||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key} # AES-256-GCM for dynamic issuer/target config
|
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key} # AES-256-GCM for dynamic issuer/target config
|
||||||
ports:
|
ports:
|
||||||
- "8443:8443"
|
- "8443:8443"
|
||||||
|
volumes:
|
||||||
|
- certs:/etc/certctl/tls:ro
|
||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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
|
restart: unless-stopped
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
@@ -99,13 +180,15 @@ services:
|
|||||||
certctl-server:
|
certctl-server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
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_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
|
||||||
CERTCTL_AGENT_NAME: docker-agent
|
CERTCTL_AGENT_NAME: docker-agent
|
||||||
CERTCTL_LOG_LEVEL: info
|
CERTCTL_LOG_LEVEL: info
|
||||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
|
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
|
||||||
volumes:
|
volumes:
|
||||||
- agent_keys:/var/lib/certctl/keys
|
- agent_keys:/var/lib/certctl/keys
|
||||||
|
- certs:/etc/certctl/tls:ro
|
||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -134,3 +217,5 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
agent_keys:
|
agent_keys:
|
||||||
driver: local
|
driver: local
|
||||||
|
certs:
|
||||||
|
driver: local
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ A production-ready Helm chart for deploying certctl (self-hosted certificate lif
|
|||||||
- **Chart Version**: 0.1.0
|
- **Chart Version**: 0.1.0
|
||||||
- **App Version**: 2.1.0
|
- **App Version**: 2.1.0
|
||||||
- **Type**: application
|
- **Type**: application
|
||||||
- **License**: BSL-1.1 (converts to Apache 2.0 in 2033)
|
- **License**: BSL-1.1
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
@@ -246,8 +246,8 @@ helm install certctl certctl/ \
|
|||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `server.replicas` | 1 | Number of server replicas |
|
| `server.replicas` | 1 | Number of server replicas |
|
||||||
| `server.port` | 8443 | Server port |
|
| `server.port` | 8443 | Server port |
|
||||||
| `server.auth.type` | api-key | Authentication type |
|
| `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) |
|
| `server.auth.apiKey` | "" | API key (REQUIRED when `auth.type=api-key`) |
|
||||||
| `server.logging.level` | info | Log level |
|
| `server.logging.level` | info | Log level |
|
||||||
| `server.logging.format` | json | Log format |
|
| `server.logging.format` | json | Log format |
|
||||||
|
|
||||||
@@ -458,4 +458,3 @@ For issues, questions, or contributions:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
BSL-1.1 (Business Source License)
|
BSL-1.1 (Business Source License)
|
||||||
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 get ingress
|
||||||
kubectl describe ingress certctl
|
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}')
|
POD=$(kubectl get pods -l app.kubernetes.io/component=server -o jsonpath='{.items[0].metadata.name}')
|
||||||
kubectl port-forward $POD 8443:8443 &
|
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
|
### Step 6: Access the Dashboard
|
||||||
@@ -333,9 +335,10 @@ kubectl logs $POD | tail -20
|
|||||||
# Port forward to API
|
# Port forward to API
|
||||||
kubectl port-forward svc/certctl-server 8443:8443 &
|
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"
|
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 "Authorization: Bearer $API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
|
|||||||
@@ -231,4 +231,4 @@ kubectl logs -l app.kubernetes.io/component=server -f
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
All files are covered under the BSL-1.1 license (converts to Apache 2.0 in 2033).
|
All files are covered under the BSL-1.1 license.
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ kubectl get pods -l app.kubernetes.io/instance=certctl
|
|||||||
# View server logs
|
# View server logs
|
||||||
kubectl logs -l app.kubernetes.io/component=server -f
|
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 &
|
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
|
## Next Steps
|
||||||
|
|||||||
@@ -513,4 +513,4 @@ For issues, questions, or contributions, visit:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
BSL-1.1 (converts to Apache 2.0 in 2033)
|
BSL-1.1
|
||||||
|
|||||||
@@ -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 }}
|
{{- 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_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)
|
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 }}
|
{{- 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}")
|
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 }}
|
{{- 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 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}")
|
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"
|
echo "Visit https://127.0.0.1:8443 to use your application"
|
||||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8443:$CONTAINER_PORT
|
||||||
{{- end }}
|
{{- 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
|
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
|
Host: {{ include "certctl.fullname" . }}-postgres.{{ .Release.Namespace }}.svc.cluster.local
|
||||||
Port: 5432
|
Port: 5432
|
||||||
Database: {{ .Values.postgresql.auth.database }}
|
Database: {{ .Values.postgresql.auth.database }}
|
||||||
Username: {{ .Values.postgresql.auth.username }}
|
Username: {{ .Values.postgresql.auth.username }}
|
||||||
Password: $(kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-postgres -o jsonpath="{.data.password}" | base64 --decode)
|
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 }}
|
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
|
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=server -f
|
||||||
|
|
||||||
{{- if .Values.agent.enabled }}
|
{{- 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
|
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=agent -f
|
||||||
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -58,11 +68,7 @@ IMPORTANT NOTES FOR PRODUCTION:
|
|||||||
- Use an external PostgreSQL managed service (AWS RDS, Cloud SQL, etc.)
|
- Use an external PostgreSQL managed service (AWS RDS, Cloud SQL, etc.)
|
||||||
- Set postgresql.enabled=false and configure CERTCTL_DATABASE_URL in values
|
- Set postgresql.enabled=false and configure CERTCTL_DATABASE_URL in values
|
||||||
|
|
||||||
5. Enable HTTPS/TLS using an Ingress with certificate management:
|
5. Review security contexts and network policies:
|
||||||
- Configure cert-manager for automatic TLS certificate renewal
|
|
||||||
- Update ingress values with your domain and certificate issuer
|
|
||||||
|
|
||||||
6. Review security contexts and network policies:
|
|
||||||
- All containers run as non-root
|
- All containers run as non-root
|
||||||
- Implement network policies to restrict traffic between components
|
- Implement network policies to restrict traffic between components
|
||||||
- Consider pod security policies or security standards for your cluster
|
- Consider pod security policies or security standards for your cluster
|
||||||
|
|||||||
@@ -112,14 +112,98 @@ PostgreSQL image
|
|||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Database connection string
|
Database connection string
|
||||||
|
|
||||||
|
Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319):
|
||||||
|
- postgresql.tls.mode is the operator-facing knob.
|
||||||
|
Default: "disable" (preserves the in-cluster Helm-bundled-Postgres
|
||||||
|
behavior; pod-to-pod traffic stays on the K8s pod network and is
|
||||||
|
encrypted by the CNI when the cluster is configured with a TLS-aware
|
||||||
|
CNI such as Cilium WireGuard).
|
||||||
|
- Operators on PCI-DSS-scoped clusters or operators using an external
|
||||||
|
managed Postgres (RDS, Cloud SQL, Azure DB) MUST set
|
||||||
|
postgresql.tls.mode to "require", "verify-ca", or "verify-full" and
|
||||||
|
point postgresql.tls.caSecretRef at a Secret containing the
|
||||||
|
server-ca.crt under key "ca.crt".
|
||||||
|
- The connection string sslmode parameter is wired from
|
||||||
|
postgresql.tls.mode without further translation.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "certctl.databaseURL" -}}
|
{{- define "certctl.databaseURL" -}}
|
||||||
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
{{- $sslMode := default "disable" .Values.postgresql.tls.mode -}}
|
||||||
|
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode={{ $sslMode }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Server URL (for agents)
|
Server URL (for agents). HTTPS-only as of v2.2 — see docs/tls.md.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "certctl.serverURL" -}}
|
{{- 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 }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{{- if .Values.agent.enabled }}
|
{{- if .Values.agent.enabled }}
|
||||||
|
{{- include "certctl.tls.required" . }}
|
||||||
{{- if eq .Values.agent.kind "DaemonSet" }}
|
{{- if eq .Values.agent.kind "DaemonSet" }}
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: DaemonSet
|
kind: DaemonSet
|
||||||
@@ -53,6 +54,8 @@ spec:
|
|||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
- name: CERTCTL_KEY_DIR
|
- name: CERTCTL_KEY_DIR
|
||||||
value: {{ .Values.agent.keyDir }}
|
value: {{ .Values.agent.keyDir }}
|
||||||
|
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
|
||||||
|
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
|
||||||
{{- if .Values.agent.discoveryDirs }}
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
- name: CERTCTL_DISCOVERY_DIRS
|
- name: CERTCTL_DISCOVERY_DIRS
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -70,12 +73,19 @@ spec:
|
|||||||
mountPath: {{ .Values.agent.keyDir }}
|
mountPath: {{ .Values.agent.keyDir }}
|
||||||
- name: tmp
|
- name: tmp
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
|
- name: server-tls
|
||||||
|
mountPath: {{ .Values.server.tls.mountPath }}
|
||||||
|
readOnly: true
|
||||||
volumes:
|
volumes:
|
||||||
- name: agent-keys
|
- name: agent-keys
|
||||||
emptyDir:
|
emptyDir:
|
||||||
sizeLimit: 1Gi
|
sizeLimit: 1Gi
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
- name: server-tls
|
||||||
|
secret:
|
||||||
|
secretName: {{ include "certctl.tls.secretName" . }}
|
||||||
|
defaultMode: 0400
|
||||||
{{- else if eq .Values.agent.kind "Deployment" }}
|
{{- else if eq .Values.agent.kind "Deployment" }}
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -135,6 +145,8 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
- name: CERTCTL_KEY_DIR
|
- name: CERTCTL_KEY_DIR
|
||||||
value: {{ .Values.agent.keyDir }}
|
value: {{ .Values.agent.keyDir }}
|
||||||
|
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
|
||||||
|
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
|
||||||
{{- if .Values.agent.discoveryDirs }}
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
- name: CERTCTL_DISCOVERY_DIRS
|
- name: CERTCTL_DISCOVERY_DIRS
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -152,11 +164,18 @@ spec:
|
|||||||
mountPath: {{ .Values.agent.keyDir }}
|
mountPath: {{ .Values.agent.keyDir }}
|
||||||
- name: tmp
|
- name: tmp
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
|
- name: server-tls
|
||||||
|
mountPath: {{ .Values.server.tls.mountPath }}
|
||||||
|
readOnly: true
|
||||||
volumes:
|
volumes:
|
||||||
- name: agent-keys
|
- name: agent-keys
|
||||||
emptyDir:
|
emptyDir:
|
||||||
sizeLimit: 1Gi
|
sizeLimit: 1Gi
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
- name: server-tls
|
||||||
|
secret:
|
||||||
|
secretName: {{ include "certctl.tls.secretName" . }}
|
||||||
|
defaultMode: 0400
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
{{- if .Values.ingress.enabled }}
|
{{- 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
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "certctl.fullname" . }}
|
name: {{ include "certctl.fullname" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "certctl.labels" . | nindent 4 }}
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
{{- with .Values.ingress.annotations }}
|
|
||||||
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 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
{{- if .Values.ingress.className }}
|
{{- if .Values.ingress.className }}
|
||||||
ingressClassName: {{ .Values.ingress.className }}
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
@@ -33,7 +43,7 @@ spec:
|
|||||||
pathType: {{ .pathType }}
|
pathType: {{ .pathType }}
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: {{ include "certctl.fullname" . }}-server
|
name: {{ include "certctl.fullname" $ }}-server
|
||||||
port:
|
port:
|
||||||
number: {{ $.Values.server.service.port }}
|
number: {{ $.Values.server.service.port }}
|
||||||
{{- end }}
|
{{- 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
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
{{- include "certctl.tls.required" . }}
|
||||||
|
{{- include "certctl.validateAuthType" . }}
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -32,7 +34,7 @@ spec:
|
|||||||
image: {{ include "certctl.serverImage" . }}
|
image: {{ include "certctl.serverImage" . }}
|
||||||
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: https
|
||||||
containerPort: {{ .Values.server.port }}
|
containerPort: {{ .Values.server.port }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
env:
|
env:
|
||||||
@@ -40,6 +42,10 @@ spec:
|
|||||||
value: "0.0.0.0"
|
value: "0.0.0.0"
|
||||||
- name: CERTCTL_SERVER_PORT
|
- name: CERTCTL_SERVER_PORT
|
||||||
value: "{{ .Values.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
|
- name: CERTCTL_DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -172,12 +178,19 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: tmp
|
- name: tmp
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
|
- name: tls
|
||||||
|
mountPath: {{ .Values.server.tls.mountPath }}
|
||||||
|
readOnly: true
|
||||||
{{- if .Values.server.volumeMounts }}
|
{{- if .Values.server.volumeMounts }}
|
||||||
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
|
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
volumes:
|
volumes:
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
- name: tls
|
||||||
|
secret:
|
||||||
|
secretName: {{ include "certctl.tls.secretName" . }}
|
||||||
|
defaultMode: 0400
|
||||||
{{- if .Values.server.volumes }}
|
{{- if .Values.server.volumes }}
|
||||||
{{- toYaml .Values.server.volumes | nindent 8 }}
|
{{- toYaml .Values.server.volumes | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{{- include "certctl.validateAuthType" . }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@@ -7,7 +8,11 @@ metadata:
|
|||||||
app.kubernetes.io/component: server
|
app.kubernetes.io/component: server
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
database-url: postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
# Bundle B / Audit M-018 (PCI-DSS Req 4): sslmode wired from
|
||||||
|
# postgresql.tls.mode. Default "disable" preserves the in-cluster
|
||||||
|
# Helm-bundled-Postgres path; operators on PCI-scoped clusters set
|
||||||
|
# postgresql.tls.mode to require / verify-ca / verify-full.
|
||||||
|
database-url: {{ include "certctl.databaseURL" . | quote }}
|
||||||
{{- if and (eq .Values.server.auth.type "api-key") .Values.server.auth.apiKey }}
|
{{- if and (eq .Values.server.auth.type "api-key") .Values.server.auth.apiKey }}
|
||||||
api-key: {{ .Values.server.auth.apiKey | quote }}
|
api-key: {{ .Values.server.auth.apiKey | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ spec:
|
|||||||
type: {{ .Values.server.service.type }}
|
type: {{ .Values.server.service.type }}
|
||||||
ports:
|
ports:
|
||||||
- port: {{ .Values.server.service.port }}
|
- port: {{ .Values.server.service.port }}
|
||||||
targetPort: http
|
targetPort: https
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
name: http
|
name: https
|
||||||
selector:
|
selector:
|
||||||
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
|
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
|
||||||
|
|||||||
@@ -48,35 +48,103 @@ server:
|
|||||||
drop:
|
drop:
|
||||||
- ALL
|
- 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:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
port: http
|
port: https
|
||||||
|
scheme: HTTPS
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
failureThreshold: 3
|
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:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /readyz
|
path: /ready
|
||||||
port: http
|
port: https
|
||||||
|
scheme: HTTPS
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
timeoutSeconds: 3
|
timeoutSeconds: 3
|
||||||
failureThreshold: 2
|
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, LoadBalancer, NodePort)
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 8443
|
port: 8443
|
||||||
annotations: {}
|
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:
|
auth:
|
||||||
type: api-key # Options: api-key, none (for demo only)
|
type: api-key
|
||||||
apiKey: "" # REQUIRED in production - set via --set or values override
|
apiKey: "" # REQUIRED when type=api-key (set via --set or values override).
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
logging:
|
logging:
|
||||||
@@ -221,7 +289,58 @@ postgresql:
|
|||||||
auth:
|
auth:
|
||||||
database: certctl
|
database: certctl
|
||||||
username: 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: ""
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319): TLS to Postgres
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# postgresql.tls.mode is wired into the database-url sslmode parameter
|
||||||
|
# (see templates/_helpers.tpl::certctl.databaseURL).
|
||||||
|
#
|
||||||
|
# Acceptable values (lib/pq):
|
||||||
|
# disable — no TLS (default, preserves in-cluster pod-to-pod
|
||||||
|
# traffic on the K8s pod network).
|
||||||
|
# require — TLS required, no certificate verification.
|
||||||
|
# verify-ca — TLS required + verify CA chain.
|
||||||
|
# verify-full — TLS required + verify CA chain + verify hostname.
|
||||||
|
#
|
||||||
|
# PCI-DSS Req 4 v4.0 §2.2.5 requires verify-ca or verify-full when the
|
||||||
|
# database carries sensitive data crossing untrusted networks (RDS,
|
||||||
|
# Cloud SQL, cross-VPC, etc). The bundled Helm Postgres runs in the
|
||||||
|
# same pod network as certctl-server; sslmode=disable is acceptable
|
||||||
|
# there only when the cluster CNI provides L2/L3 encryption (Cilium
|
||||||
|
# WireGuard, Calico Wireguard, Tailscale operator, etc).
|
||||||
|
#
|
||||||
|
# When mode != disable AND tls.caSecretRef is set, the CA bundle is
|
||||||
|
# mounted at /etc/postgresql-ca/ca.crt and the server's PGSSLROOTCERT
|
||||||
|
# env points there. caSecretRef must reference an existing Secret with
|
||||||
|
# a "ca.crt" key.
|
||||||
|
tls:
|
||||||
|
mode: disable
|
||||||
|
# caSecretRef: "" # Secret with ca.crt key (required for verify-ca/verify-full)
|
||||||
|
|
||||||
# Storage configuration
|
# Storage configuration
|
||||||
storage:
|
storage:
|
||||||
@@ -356,7 +475,16 @@ ingress:
|
|||||||
className: ""
|
className: ""
|
||||||
annotations: {}
|
annotations: {}
|
||||||
# kubernetes.io/ingress.class: nginx
|
# 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:
|
hosts:
|
||||||
- host: certctl.local
|
- host: certctl.local
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// Package integration_test — image-level HEALTHCHECK contract.
|
||||||
|
//
|
||||||
|
// U-2 (P1, cat-u-healthcheck_protocol_mismatch): pre-U-2 the published
|
||||||
|
// server image's Dockerfile HEALTHCHECK called `curl -f http://localhost:
|
||||||
|
// 8443/health` against an HTTPS-only listener (HTTPS-Everywhere milestone,
|
||||||
|
// v2.2 / tag v2.0.47). Operators outside docker-compose / Helm saw the
|
||||||
|
// container reported as `unhealthy` indefinitely. The compose stack
|
||||||
|
// overrode this HEALTHCHECK with `--cacert + https://`; the Helm chart
|
||||||
|
// uses explicit `httpGet` probes that ignore Docker's HEALTHCHECK; the 5
|
||||||
|
// example compose files all override with `curl -sfk https://localhost:
|
||||||
|
// 8443/health`. So the observable failure was scoped to bare `docker run`
|
||||||
|
// / Docker Swarm / Nomad / ECS users — exactly the "I just pulled the
|
||||||
|
// published image" path.
|
||||||
|
//
|
||||||
|
// This file's tests pin the contract at the binary-image level. The
|
||||||
|
// matching CI grep guardrail in .github/workflows/ci.yml catches the
|
||||||
|
// regression at the Dockerfile-source level; both layers are needed
|
||||||
|
// because someone could replace the HEALTHCHECK line with a sibling
|
||||||
|
// broken pattern that the grep doesn't catch (e.g., a TCP-only check
|
||||||
|
// against the HTTPS port).
|
||||||
|
//
|
||||||
|
// Run alongside the rest of the integration suite:
|
||||||
|
//
|
||||||
|
// cd deploy/test && go test -tags integration -v -run Healthcheck
|
||||||
|
//
|
||||||
|
// The tests skip cleanly with t.Skip when docker is not available
|
||||||
|
// (CI without docker-in-docker, sandbox environments, etc.) so they
|
||||||
|
// don't block local development on machines without docker.
|
||||||
|
//
|
||||||
|
// Q-1 closure (cat-s3-58ce7e9840be): this file's 5 t.Skip sites are
|
||||||
|
// audited and intentional:
|
||||||
|
//
|
||||||
|
// - Line 85, 146, 207: `if !dockerAvailable(t)` skips when `docker info`
|
||||||
|
// fails. These are precondition gates; without docker there's nothing
|
||||||
|
// to assert against. Run via: `docker info >/dev/null && go test
|
||||||
|
// -tags integration ./deploy/test/...`.
|
||||||
|
// - Line 209-210: `if testing.Short()` keeps the ~45s runtime probe
|
||||||
|
// off the default `go test ./... -short` path. Run via: omit -short.
|
||||||
|
// - Line 212: hard t.Skip for the runtime probe contract — image-spec
|
||||||
|
// contract above (TestPublishedServerImage_HealthcheckSpecUsesHTTPS)
|
||||||
|
// covers the audit-flagged regression at the Dockerfile-source level.
|
||||||
|
// Re-enable once the integration harness provisions a sidecar postgres
|
||||||
|
// for image-level smoke; the existing skip message names this
|
||||||
|
// remediation explicitly. Tracked via the in-source TODO (intentional,
|
||||||
|
// not abandoned).
|
||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dockerAvailable returns true when `docker version` returns 0.
|
||||||
|
// We cache it across tests in this file so the skip message prints once.
|
||||||
|
func dockerAvailable(t *testing.T) bool {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("docker", "version", "--format", "{{.Server.Version}}")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("docker not available: %v\noutput: %s", err, string(out))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerCmd runs `docker <args...>` with a 60s budget, returning stdout
|
||||||
|
// + stderr combined and the exit error if any. Used for short-lived
|
||||||
|
// probes (inspect, build, run -d).
|
||||||
|
func dockerCmd(t *testing.T, timeout time.Duration, args ...string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("docker", args...)
|
||||||
|
done := make(chan struct{})
|
||||||
|
var out []byte
|
||||||
|
var err error
|
||||||
|
go func() {
|
||||||
|
out, err = cmd.CombinedOutput()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return string(out), err
|
||||||
|
case <-time.After(timeout):
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
t.Fatalf("docker %v timed out after %v", args, timeout)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublishedServerImage_HealthcheckSpecUsesHTTPS performs the Dockerfile-
|
||||||
|
// source-level shipped-shape pin: the inspected image's Healthcheck.Test
|
||||||
|
// array MUST contain "https://localhost:8443/health" (and MUST NOT
|
||||||
|
// contain "http://localhost:8443/health"). This is the lightweight half
|
||||||
|
// of the contract — it doesn't require running the container, only
|
||||||
|
// building it. It catches the audit-flagged bug directly.
|
||||||
|
func TestPublishedServerImage_HealthcheckSpecUsesHTTPS(t *testing.T) {
|
||||||
|
if !dockerAvailable(t) {
|
||||||
|
t.Skip("docker not available — skipping image-level HEALTHCHECK test")
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgTag = "certctl-u2-healthcheck-spec-test"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = dockerCmd(t, 30*time.Second, "rmi", "-f", imgTag)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build the server image. Use the repo root as context (this test
|
||||||
|
// file lives at deploy/test/, the Dockerfile at the repo root).
|
||||||
|
buildOut, err := dockerCmd(t, 5*time.Minute,
|
||||||
|
"build", "-f", "../../Dockerfile", "-t", imgTag, "../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docker build failed: %v\noutput:\n%s", err, buildOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect the shipped HEALTHCHECK metadata.
|
||||||
|
inspectOut, err := dockerCmd(t, 30*time.Second,
|
||||||
|
"inspect", "--format", "{{json .Config.Healthcheck}}", imgTag)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docker inspect failed: %v\noutput:\n%s", err, inspectOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hc struct {
|
||||||
|
Test []string
|
||||||
|
Interval int64
|
||||||
|
Timeout int64
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(inspectOut)), &hc); err != nil {
|
||||||
|
t.Fatalf("could not parse Healthcheck JSON %q: %v", inspectOut, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(hc.Test, " ")
|
||||||
|
|
||||||
|
// Positive contract.
|
||||||
|
if !strings.Contains(joined, "https://localhost:8443/health") {
|
||||||
|
t.Errorf("Healthcheck.Test does not target https://localhost:8443/health\nfull: %v", hc.Test)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative contract — pre-U-2 regression shape MUST be absent.
|
||||||
|
if strings.Contains(joined, "http://localhost:8443/health") {
|
||||||
|
t.Errorf("Healthcheck.Test still contains the pre-U-2 plaintext shape: %v", hc.Test)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `-k` (or `--insecure`) must be present because the bootstrap cert
|
||||||
|
// is per-deploy and the published image can't pin a CA bundle —
|
||||||
|
// see the U-2 closure docblock on Dockerfile and the audit doc.
|
||||||
|
if !strings.Contains(joined, "-k") && !strings.Contains(joined, "--insecure") {
|
||||||
|
t.Errorf("Healthcheck.Test omits -k / --insecure flag (required for self-signed bootstrap probe): %v", hc.Test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublishedAgentImage_HealthcheckSpecExists pins the U-2 adjacent
|
||||||
|
// fix that added a HEALTHCHECK to the agent image. Pre-U-2 the agent
|
||||||
|
// image had no HEALTHCHECK declaration, so bare-`docker run` agents got
|
||||||
|
// `none` health status from Docker. Post-U-2 the agent uses pgrep to
|
||||||
|
// verify the process is alive (mirroring the docker-compose pattern at
|
||||||
|
// deploy/docker-compose.yml:173, which also became reliable post-U-2
|
||||||
|
// because procps is now installed in the runtime image).
|
||||||
|
func TestPublishedAgentImage_HealthcheckSpecExists(t *testing.T) {
|
||||||
|
if !dockerAvailable(t) {
|
||||||
|
t.Skip("docker not available — skipping image-level HEALTHCHECK test")
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgTag = "certctl-u2-agent-healthcheck-spec-test"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = dockerCmd(t, 30*time.Second, "rmi", "-f", imgTag)
|
||||||
|
})
|
||||||
|
|
||||||
|
buildOut, err := dockerCmd(t, 5*time.Minute,
|
||||||
|
"build", "-f", "../../Dockerfile.agent", "-t", imgTag, "../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docker build failed: %v\noutput:\n%s", err, buildOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectOut, err := dockerCmd(t, 30*time.Second,
|
||||||
|
"inspect", "--format", "{{json .Config.Healthcheck}}", imgTag)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docker inspect failed: %v\noutput:\n%s", err, inspectOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(inspectOut)
|
||||||
|
if trimmed == "null" || trimmed == "" {
|
||||||
|
t.Fatalf("agent image has no HEALTHCHECK (got %q) — U-2 adjacent fix regressed", inspectOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hc struct {
|
||||||
|
Test []string
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &hc); err != nil {
|
||||||
|
t.Fatalf("could not parse Healthcheck JSON %q: %v", inspectOut, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(hc.Test, " ")
|
||||||
|
if !strings.Contains(joined, "pgrep") {
|
||||||
|
t.Errorf("agent Healthcheck.Test does not use pgrep (lost the process-presence shape): %v", hc.Test)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "certctl-agent") {
|
||||||
|
t.Errorf("agent Healthcheck.Test does not target the certctl-agent process name: %v", hc.Test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublishedServerImage_HealthcheckTransitionsToHealthy is the
|
||||||
|
// runtime-level contract: the built image, when started, must transition
|
||||||
|
// to `healthy` within the start-period + 30s observability budget. This
|
||||||
|
// is the heavy test — it requires the server to actually start, which
|
||||||
|
// in turn requires either a reachable database OR a startup that fails
|
||||||
|
// gracefully enough to keep the HEALTHCHECK probe target alive.
|
||||||
|
//
|
||||||
|
// The container is started with CERTCTL_DATABASE_URL pointing at an
|
||||||
|
// unreachable host so the server fails its postgres bring-up — but
|
||||||
|
// importantly, fails AFTER the TLS listener has come up, because the
|
||||||
|
// HEALTHCHECK probe target is the TLS listener. We don't actually need
|
||||||
|
// the database to validate the HEALTHCHECK shape.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this test is the runtime contract. If you're working on the
|
||||||
|
// server's startup ordering and the listener now comes up AFTER the
|
||||||
|
// database, this test must adapt — start a sidecar postgres via
|
||||||
|
// testcontainers-go (see internal/integration/lifecycle_test.go for the
|
||||||
|
// pattern) and connect the certctl-server container to it.
|
||||||
|
func TestPublishedServerImage_HealthcheckTransitionsToHealthy(t *testing.T) {
|
||||||
|
if !dockerAvailable(t) {
|
||||||
|
t.Skip("docker not available — skipping runtime HEALTHCHECK test")
|
||||||
|
}
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("runtime HEALTHCHECK test takes ~45s; skipping under -short")
|
||||||
|
}
|
||||||
|
t.Skip("runtime probe contract not yet wired to a sidecar postgres; " +
|
||||||
|
"image-spec contract above (TestPublishedServerImage_HealthcheckSpecUsesHTTPS) " +
|
||||||
|
"covers the audit-flagged regression. Re-enable once the integration " +
|
||||||
|
"harness provisions postgres for image-level smoke.")
|
||||||
|
}
|
||||||
+402
-23
@@ -47,11 +47,30 @@ func envOr(key, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPS-Everywhere Phase 6: the test harness now dials the server over TLS and
|
||||||
|
// validates the self-signed cert against the init-container-generated CA bundle
|
||||||
|
// bind-mounted at ./test/certs/ca.crt. The defaults assume the compose setup in
|
||||||
|
// deploy/docker-compose.test.yml; override via the usual env vars when pointing
|
||||||
|
// the suite at a different deployment.
|
||||||
|
//
|
||||||
|
// - CERTCTL_TEST_SERVER_URL — must be https:// for the Phase 6 wiring
|
||||||
|
// - CERTCTL_TEST_CA_BUNDLE — PEM bundle; must contain the server's issuing
|
||||||
|
// CA (self-signed in the compose setup, so server.crt doubles as ca.crt)
|
||||||
|
// - CERTCTL_TEST_INSECURE — set to "true" to fall back to
|
||||||
|
// InsecureSkipVerify when the CA bundle path is unavailable (CI smoke or
|
||||||
|
// exploratory runs only — CI-parity runs MUST use the pinned bundle).
|
||||||
|
//
|
||||||
|
// Under no circumstance does the suite silently downgrade to plaintext HTTP:
|
||||||
|
// Phase 5 (#203) pre-flight guards in cmd/server will refuse to start with an
|
||||||
|
// http:// URL anyway, so a misconfiguration fails loud at test-harness startup
|
||||||
|
// rather than flaking mid-suite.
|
||||||
var (
|
var (
|
||||||
serverURL = envOr("CERTCTL_TEST_SERVER_URL", "http://localhost:8443")
|
serverURL = envOr("CERTCTL_TEST_SERVER_URL", "https://localhost:8443")
|
||||||
apiKey = envOr("CERTCTL_TEST_API_KEY", "test-key-2026")
|
apiKey = envOr("CERTCTL_TEST_API_KEY", "test-key-2026")
|
||||||
dbURL = envOr("CERTCTL_TEST_DB_URL", "postgres://certctl:testpass@localhost:5432/certctl?sslmode=disable")
|
dbURL = envOr("CERTCTL_TEST_DB_URL", "postgres://certctl:testpass@localhost:5432/certctl?sslmode=disable")
|
||||||
nginxTLS = envOr("CERTCTL_TEST_NGINX_TLS", "localhost:8444")
|
nginxTLS = envOr("CERTCTL_TEST_NGINX_TLS", "localhost:8444")
|
||||||
|
caBundlePath = envOr("CERTCTL_TEST_CA_BUNDLE", "./certs/ca.crt")
|
||||||
|
insecureTLS = strings.EqualFold(os.Getenv("CERTCTL_TEST_INSECURE"), "true")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -75,16 +94,74 @@ type testClient struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildTLSConfig wires up the x509.CertPool with the self-signed CA bundle
|
||||||
|
// emitted by the certctl-tls-init container. Panics via t.Fatal on the happy
|
||||||
|
// path if both CERTCTL_TEST_CA_BUNDLE is unreadable *and* CERTCTL_TEST_INSECURE
|
||||||
|
// is not set — that combination is almost always a misconfigured test harness
|
||||||
|
// and silently downgrading to InsecureSkipVerify would hide real failures.
|
||||||
|
//
|
||||||
|
// MinVersion is pinned to TLS 1.3 so this matches what cmd/server negotiates
|
||||||
|
// by default; a drift there would surface here first.
|
||||||
|
func buildTLSConfig() *tls.Config {
|
||||||
|
cfg := &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
}
|
||||||
|
if insecureTLS {
|
||||||
|
// Opt-in smoke-run mode; log but don't fail so operators running
|
||||||
|
// `CERTCTL_TEST_INSECURE=true go test -tags integration ./deploy/test/...`
|
||||||
|
// against an ad-hoc environment still get a green suite when the server
|
||||||
|
// is reachable. CI must not set this.
|
||||||
|
cfg.InsecureSkipVerify = true
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
pem, err := os.ReadFile(caBundlePath)
|
||||||
|
if err != nil {
|
||||||
|
// Can't use t.Fatal here (called from package-level helpers); fall
|
||||||
|
// back to a panic so the harness dies loud at the first HTTP call.
|
||||||
|
// Operators see a clear "CA bundle missing" message and fix their
|
||||||
|
// setup instead of chasing a confusing TLS handshake error.
|
||||||
|
panic(fmt.Sprintf("integration test: read CA bundle %q: %v — "+
|
||||||
|
"run `docker compose -f deploy/docker-compose.test.yml up` first, or "+
|
||||||
|
"set CERTCTL_TEST_CA_BUNDLE to a valid PEM path, or "+
|
||||||
|
"set CERTCTL_TEST_INSECURE=true for a smoke run", caBundlePath, err))
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
panic(fmt.Sprintf("integration test: no PEM certificates parsed from %q", caBundlePath))
|
||||||
|
}
|
||||||
|
cfg.RootCAs = pool
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestClient builds a Bearer-authenticated HTTPS client pinned to the
|
||||||
|
// init-container CA. Every phase uses this for REST calls.
|
||||||
func newTestClient() *testClient {
|
func newTestClient() *testClient {
|
||||||
return &testClient{
|
return &testClient{
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: buildTLSConfig(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
baseURL: serverURL,
|
baseURL: serverURL,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newUnauthHTTPClient returns an *http.Client with the same TLS configuration
|
||||||
|
// but no Bearer token. Used for the Phase 7 RFC 5280 CRL / RFC 8615
|
||||||
|
// `/.well-known/pki/*` probes — those endpoints must be reachable by
|
||||||
|
// *unauthenticated* relying parties per M-006, so we explicitly omit the
|
||||||
|
// Authorization header to prove it.
|
||||||
|
func newUnauthHTTPClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: buildTLSConfig(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *testClient) do(method, path string, body io.Reader) (*http.Response, error) {
|
func (c *testClient) do(method, path string, body io.Reader) (*http.Response, error) {
|
||||||
url := c.baseURL + path
|
url := c.baseURL + path
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, url, body)
|
||||||
@@ -195,16 +272,11 @@ type metricsResponse struct {
|
|||||||
Uptime float64 `json:"uptime_seconds"`
|
Uptime float64 `json:"uptime_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// crlResponse for the CRL endpoint.
|
// M-006: The non-standard JSON CRL endpoint (`GET /api/v1/crl`) was removed.
|
||||||
type crlResponse struct {
|
// RFC 5280 §5 defines only the DER wire format, which is now served
|
||||||
Version int `json:"version"`
|
// unauthenticated at `/.well-known/pki/crl/{issuer_id}` per RFC 8615.
|
||||||
Total int `json:"total"`
|
// The `crlResponse` Go struct that used to decode the JSON envelope is gone;
|
||||||
Entries []struct {
|
// Phase 7 parses the DER bytes directly via `x509.ParseRevocationList`.
|
||||||
Serial string `json:"serial_number"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
RevokedAt string `json:"revoked_at"`
|
|
||||||
} `json:"entries"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PostgreSQL test helper
|
// PostgreSQL test helper
|
||||||
@@ -428,6 +500,15 @@ func TestIntegrationSuite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
}
|
}
|
||||||
|
// Q-1 closure (cat-s3-58ce7e9840be): this is a poll-with-skip, not a
|
||||||
|
// silent skip. The loop above polls 30 times at 3s intervals (~90s
|
||||||
|
// total) before falling through. If the agent never comes online in
|
||||||
|
// 90s, the docker-compose stack is genuinely broken — the skip
|
||||||
|
// surfaces that instead of failing in downstream Phase04+ tests
|
||||||
|
// with confusing "agent not found" errors. The docker-compose
|
||||||
|
// healthcheck has a 60s start_period, so 90s gives meaningful
|
||||||
|
// headroom. Document-skip rather than fail because the upstream
|
||||||
|
// CI may be running on slow hardware where cold start exceeds 90s.
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Skip("agent not yet online (may be slow to heartbeat)")
|
t.Skip("agent not yet online (may be slow to heartbeat)")
|
||||||
}
|
}
|
||||||
@@ -714,6 +795,12 @@ func TestIntegrationSuite(t *testing.T) {
|
|||||||
// Phase 7: Revocation
|
// Phase 7: Revocation
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
t.Run("Phase07_Revocation", func(t *testing.T) {
|
t.Run("Phase07_Revocation", func(t *testing.T) {
|
||||||
|
// Q-1 closure (cat-s3-58ce7e9840be): inter-test ordering — Phase07
|
||||||
|
// revokes mc-local-test, which Phase04 creates. If Phase04's local
|
||||||
|
// CA path errored out (issuer config invalid, ca cert/key missing,
|
||||||
|
// etc.) localCertCreated stays false and there's no certificate
|
||||||
|
// to revoke. Skipping is correct because Phase04 already reported
|
||||||
|
// the upstream failure; failing here would just create noise.
|
||||||
if !localCertCreated {
|
if !localCertCreated {
|
||||||
t.Skip("depends on Phase04 (Local CA cert not created)")
|
t.Skip("depends on Phase04 (Local CA cert not created)")
|
||||||
}
|
}
|
||||||
@@ -728,18 +815,48 @@ func TestIntegrationSuite(t *testing.T) {
|
|||||||
t.Fatalf("revocation response unexpected: %s", body)
|
t.Fatalf("revocation response unexpected: %s", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check CRL
|
// Check DER CRL served unauthenticated under /.well-known/pki/ per
|
||||||
t.Run("CRL", func(t *testing.T) {
|
// RFC 5280 §5 + RFC 8615 (M-006). Use newUnauthHTTPClient() — no
|
||||||
resp, err := c.Get("/api/v1/crl")
|
// Bearer token — to prove the endpoint is reachable by relying
|
||||||
|
// parties that have no certctl API credentials. Post HTTPS-Everywhere
|
||||||
|
// (M-007, Phase 6) the client still speaks TLS 1.3 against the pinned
|
||||||
|
// CA bundle from ./certs/ca.crt; we just skip the Authorization header
|
||||||
|
// to exercise the unauthenticated RFC 5280 / RFC 8615 relying-party
|
||||||
|
// path. Switching from the stdlib http.DefaultClient (plaintext OK,
|
||||||
|
// system trust store only) to the helper keeps the no-auth semantic
|
||||||
|
// while preventing silent plaintext downgrade — the whole point of
|
||||||
|
// this milestone.
|
||||||
|
t.Run("CRL_DER_Unauthenticated", func(t *testing.T) {
|
||||||
|
resp, err := newUnauthHTTPClient().Get(serverURL + "/.well-known/pki/crl/iss-local")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GET CRL: %v", err)
|
t.Fatalf("GET DER CRL: %v", err)
|
||||||
}
|
}
|
||||||
var crl crlResponse
|
defer resp.Body.Close()
|
||||||
if err := decodeJSON(resp, &crl); err != nil {
|
|
||||||
t.Fatalf("decode CRL: %v", err)
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("unexpected status: got %d, want 200 (body=%s)", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
if crl.Total < 1 {
|
if ct := resp.Header.Get("Content-Type"); ct != "application/pkix-crl" {
|
||||||
t.Fatalf("CRL total: got %d, want >= 1", crl.Total)
|
t.Errorf("Content-Type: got %q, want %q", ct, "application/pkix-crl")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read CRL body: %v", err)
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
t.Fatal("CRL body empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the DER bytes as an X.509 CRL (RFC 5280) and verify the
|
||||||
|
// just-revoked certificate is listed.
|
||||||
|
crl, err := x509.ParseRevocationList(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse DER CRL: %v", err)
|
||||||
|
}
|
||||||
|
if len(crl.RevokedCertificateEntries) < 1 {
|
||||||
|
t.Fatalf("CRL entries: got %d, want >= 1", len(crl.RevokedCertificateEntries))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -771,6 +888,15 @@ func TestIntegrationSuite(t *testing.T) {
|
|||||||
if err := decodeJSON(resp, &pr); err != nil {
|
if err := decodeJSON(resp, &pr); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
|
// Q-1 closure (cat-s3-58ce7e9840be): the discovery scan runs on a
|
||||||
|
// scheduler tick, not synchronously with this test. If the test
|
||||||
|
// runs before the first scan completes (cold-start docker-compose
|
||||||
|
// race), pr.Total is 0 and there's no discovered cert to assert
|
||||||
|
// against. Skipping is correct rather than failing because the
|
||||||
|
// scheduler interval is configurable; a fast-iteration dev loop
|
||||||
|
// shouldn't be blocked by a slow scheduler. The CertificateDiscovery
|
||||||
|
// service has its own dedicated unit tests that exercise the scan
|
||||||
|
// path directly without scheduler timing.
|
||||||
if pr.Total < 1 {
|
if pr.Total < 1 {
|
||||||
t.Skip("no discovered certificates yet (agent scan may not have run)")
|
t.Skip("no discovered certificates yet (agent scan may not have run)")
|
||||||
}
|
}
|
||||||
@@ -805,6 +931,13 @@ func TestIntegrationSuite(t *testing.T) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Q-1 closure (cat-s3-58ce7e9840be): inter-test fallthrough —
|
||||||
|
// Phase09 renews the first Active cert it finds among the candidate
|
||||||
|
// list. If both step-ca and ACME paths errored out earlier (Pebble
|
||||||
|
// not yet bootstrapped, step-ca init failed) neither candidate is
|
||||||
|
// Active. Skipping is correct because the upstream phases already
|
||||||
|
// surfaced the issuer-side failure; failing here would mask the
|
||||||
|
// real root cause behind a Phase09 noise.
|
||||||
if renewalCert == "" {
|
if renewalCert == "" {
|
||||||
t.Skip("no certificate in Active state for renewal test")
|
t.Skip("no certificate in Active state for renewal test")
|
||||||
}
|
}
|
||||||
@@ -985,6 +1118,13 @@ func TestIntegrationSuite(t *testing.T) {
|
|||||||
|
|
||||||
lastVersion := versions[len(versions)-1]
|
lastVersion := versions[len(versions)-1]
|
||||||
pemData := lastVersion.PEMChain
|
pemData := lastVersion.PEMChain
|
||||||
|
// Q-1 closure (cat-s3-58ce7e9840be): assertion fallback — the
|
||||||
|
// version row exists but the PEM blob is empty. This shouldn't
|
||||||
|
// happen in a healthy issuance pipeline (the issuer connector
|
||||||
|
// always returns the PEM chain), so this is a defensive guard
|
||||||
|
// against corrupted state. Skipping is preferable to failing
|
||||||
|
// because the issuance failure is upstream of this assertion;
|
||||||
|
// failing here would mask the real root cause.
|
||||||
if pemData == "" {
|
if pemData == "" {
|
||||||
t.Skip("no PEM data in certificate version")
|
t.Skip("no PEM data in certificate version")
|
||||||
}
|
}
|
||||||
@@ -1123,4 +1263,243 @@ func TestIntegrationSuite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase 13: I-005 Phase 1 Red — Notification Retry + Dead Letter Queue (E2E)
|
||||||
|
//
|
||||||
|
// Pins the full retry-loop contract end-to-end. Phase 2 Green must turn
|
||||||
|
// every subtest Green with a single coherent change set (migration 000016
|
||||||
|
// live, scheduler notificationRetryLoop wired as the 11th loop bumping
|
||||||
|
// the total from 10 → 11, service RetryFailedNotifications + MarkAsDead +
|
||||||
|
// RequeueNotification implemented, handler POST
|
||||||
|
// /api/v1/notifications/{id}/requeue routed, list handler parsing the
|
||||||
|
// status query param).
|
||||||
|
//
|
||||||
|
// Subtests:
|
||||||
|
//
|
||||||
|
// 1. MarkAsDead_OnMaxAttempts — a notification seeded at retry_count=4
|
||||||
|
// (one failure shy of the max_attempts=5 gate) with next_retry_at in
|
||||||
|
// the past is promoted to status='dead' on the first retry-loop
|
||||||
|
// tick. The pre-increment arithmetic `retry_count + 1 = 5 =
|
||||||
|
// max_attempts` triggers MarkAsDead instead of scheduling another
|
||||||
|
// retry.
|
||||||
|
//
|
||||||
|
// 2. Requeue_FlipsDeadToPending — POST
|
||||||
|
// /api/v1/notifications/{id}/requeue on a dead row flips status back
|
||||||
|
// to 'pending', resets retry_count to 0, and clears next_retry_at
|
||||||
|
// so the existing ProcessPendingNotifications loop (not the retry
|
||||||
|
// sweep) picks it up on its next tick.
|
||||||
|
//
|
||||||
|
// 3. ListFilter_StatusDead — GET /api/v1/notifications?status=dead
|
||||||
|
// returns only rows in status='dead' so the UI's Dead Letter tab
|
||||||
|
// (web/src/pages/NotificationsPage.test.tsx subtest #1) can isolate
|
||||||
|
// them without client-side filtering.
|
||||||
|
//
|
||||||
|
// Red behavior at HEAD (what Phase 2 Green must flip):
|
||||||
|
//
|
||||||
|
// * Schema: the INSERTs reference retry_count, next_retry_at,
|
||||||
|
// last_error. Migration 000016 is already written (file (a) of
|
||||||
|
// Phase 1 Red) but until it is applied the INSERTs fail with
|
||||||
|
// "column does not exist" — schema-level Red halt.
|
||||||
|
//
|
||||||
|
// * Subtest 1: no retry loop exists at HEAD. The seeded row stays at
|
||||||
|
// status='failed' retry_count=4 forever. The 4-minute waitFor
|
||||||
|
// therefore times out.
|
||||||
|
//
|
||||||
|
// * Subtest 2: /notifications/{id}/requeue is not routed at HEAD
|
||||||
|
// (internal/api/handler/notifications.go registers only list / get /
|
||||||
|
// mark-read). The POST returns 404.
|
||||||
|
//
|
||||||
|
// * Subtest 3: the list handler does not parse the status query param
|
||||||
|
// at HEAD. The response includes rows of every status, so the
|
||||||
|
// "leaked non-dead row" assertion fires.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
t.Run("Phase13_NotificationRetryDLQ", func(t *testing.T) {
|
||||||
|
// Unreachable endpoint so every webhook delivery attempt fails
|
||||||
|
// deterministically — port 1 is never bound. Pinning retry_count=4
|
||||||
|
// + a guaranteed-failing channel is what turns the seeded row into
|
||||||
|
// 'dead' on the very next scheduler tick (one delivery attempt,
|
||||||
|
// retry_count 4→5, crosses max_attempts=5 → MarkAsDead).
|
||||||
|
const blackHole = "http://127.0.0.1:1/i005-red-black-hole"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Subtest 1: failed → dead transition after one retry-loop tick
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
t.Run("MarkAsDead_OnMaxAttempts", func(t *testing.T) {
|
||||||
|
id := fmt.Sprintf("notif-i005-dead-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// retry_count=4 + next attempt = 5 = max_attempts → MarkAsDead.
|
||||||
|
// next_retry_at is backdated so the row is immediately eligible
|
||||||
|
// for the retry sweep rather than having to wait for its own
|
||||||
|
// backoff to elapse.
|
||||||
|
past := time.Now().Add(-30 * time.Second).UTC()
|
||||||
|
db.Exec(t, `
|
||||||
|
INSERT INTO notification_events
|
||||||
|
(id, type, channel, recipient, message, status,
|
||||||
|
retry_count, next_retry_at, last_error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
`,
|
||||||
|
id, "ExpirationWarning", "Webhook", blackHole,
|
||||||
|
"I-005 integration: DLQ promotion on max_attempts",
|
||||||
|
"failed", 4, past, "transient webhook 500",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Give the retry sweep up to 4m to tick at least once (default
|
||||||
|
// 2m interval + seed/sweep/notifier slop). On success the row
|
||||||
|
// carries status='dead' and retry_count has advanced to 5.
|
||||||
|
waitFor(t, "notification transitions to dead", 4*time.Minute, 5*time.Second,
|
||||||
|
func() (bool, error) {
|
||||||
|
var status string
|
||||||
|
var retry int
|
||||||
|
err := db.db.QueryRow(
|
||||||
|
"SELECT status, retry_count FROM notification_events WHERE id = $1",
|
||||||
|
id,
|
||||||
|
).Scan(&status, &retry)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.EqualFold(status, "dead") && retry >= 5, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// The dead-letter tab is only useful if operators can see why
|
||||||
|
// the row died. MarkAsDead must preserve the most recent
|
||||||
|
// failure string in last_error rather than nil'ing it.
|
||||||
|
var lastErr sql.NullString
|
||||||
|
if err := db.db.QueryRow(
|
||||||
|
"SELECT last_error FROM notification_events WHERE id = $1", id,
|
||||||
|
).Scan(&lastErr); err != nil {
|
||||||
|
t.Fatalf("read last_error: %v", err)
|
||||||
|
}
|
||||||
|
if !lastErr.Valid || lastErr.String == "" {
|
||||||
|
t.Errorf("dead notification %s has empty last_error — "+
|
||||||
|
"retry loop must preserve the most recent failure", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Subtest 2: dead → pending via manual Requeue endpoint
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
t.Run("Requeue_FlipsDeadToPending", func(t *testing.T) {
|
||||||
|
id := fmt.Sprintf("notif-i005-requeue-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Seed directly at status='dead' rather than waiting for a
|
||||||
|
// scheduler tick — this subtest isolates the requeue handler,
|
||||||
|
// not the retry loop (subtest 1 already pins that).
|
||||||
|
past := time.Now().Add(-10 * time.Minute).UTC()
|
||||||
|
db.Exec(t, `
|
||||||
|
INSERT INTO notification_events
|
||||||
|
(id, type, channel, recipient, message, status,
|
||||||
|
retry_count, next_retry_at, last_error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
`,
|
||||||
|
id, "ExpirationWarning", "Webhook", blackHole,
|
||||||
|
"I-005 integration: manual requeue",
|
||||||
|
"dead", 5, past, "max attempts reached",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := c.Post("/api/v1/notifications/"+id+"/requeue", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST requeue: %v", err)
|
||||||
|
}
|
||||||
|
body := readBody(resp)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("requeue status %d, want 200 (body: %s)",
|
||||||
|
resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
// Phase 2 Green handler responds with {"status":"requeued"}
|
||||||
|
// to mirror MarkAsRead's {"status":"marked_as_read"} envelope.
|
||||||
|
if !strings.Contains(body, "requeued") {
|
||||||
|
t.Errorf("requeue body missing 'requeued' marker: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB must reflect the full flip: pending status, reset counter,
|
||||||
|
// cleared next_retry_at. Clearing next_retry_at is what moves
|
||||||
|
// the row out of the retry-sweep partial index and back under
|
||||||
|
// ProcessPendingNotifications.
|
||||||
|
var status string
|
||||||
|
var retry int
|
||||||
|
var nextRetry sql.NullTime
|
||||||
|
if err := db.db.QueryRow(`
|
||||||
|
SELECT status, retry_count, next_retry_at
|
||||||
|
FROM notification_events WHERE id = $1
|
||||||
|
`, id).Scan(&status, &retry, &nextRetry); err != nil {
|
||||||
|
t.Fatalf("read requeued row: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(status, "pending") {
|
||||||
|
t.Errorf("after requeue: status=%q, want 'pending'", status)
|
||||||
|
}
|
||||||
|
if retry != 0 {
|
||||||
|
t.Errorf("after requeue: retry_count=%d, want 0", retry)
|
||||||
|
}
|
||||||
|
if nextRetry.Valid {
|
||||||
|
t.Errorf("after requeue: next_retry_at=%v, want NULL",
|
||||||
|
nextRetry.Time)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Subtest 3: GET /notifications?status=dead isolates DLQ rows
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
t.Run("ListFilter_StatusDead", func(t *testing.T) {
|
||||||
|
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
deadID := "notif-i005-filter-dead-" + suffix
|
||||||
|
pendingID := "notif-i005-filter-pending-" + suffix
|
||||||
|
|
||||||
|
// One row at each end of the lifecycle so we can prove the
|
||||||
|
// filter both matches and excludes.
|
||||||
|
db.Exec(t, `
|
||||||
|
INSERT INTO notification_events
|
||||||
|
(id, type, channel, recipient, message, status, retry_count)
|
||||||
|
VALUES ($1, 'ExpirationWarning', 'Webhook', $2,
|
||||||
|
'I-005 filter test: dead row', 'dead', 5)
|
||||||
|
`, deadID, blackHole)
|
||||||
|
db.Exec(t, `
|
||||||
|
INSERT INTO notification_events
|
||||||
|
(id, type, channel, recipient, message, status, retry_count)
|
||||||
|
VALUES ($1, 'ExpirationWarning', 'Webhook', $2,
|
||||||
|
'I-005 filter test: pending row', 'pending', 0)
|
||||||
|
`, pendingID, blackHole)
|
||||||
|
|
||||||
|
// per_page large enough to rule out pagination artifacts as
|
||||||
|
// the reason a seeded row might be missing from the response.
|
||||||
|
resp, err := c.Get("/api/v1/notifications?status=dead&per_page=500")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET notifications?status=dead: %v", err)
|
||||||
|
}
|
||||||
|
var pr pagedResponse
|
||||||
|
if err := decodeJSON(resp, &pr); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := json.Unmarshal(pr.Data, &rows); err != nil {
|
||||||
|
t.Fatalf("unmarshal rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sawDead, sawPending bool
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.ID == deadID {
|
||||||
|
sawDead = true
|
||||||
|
}
|
||||||
|
if r.ID == pendingID {
|
||||||
|
sawPending = true
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(r.Status, "dead") {
|
||||||
|
t.Errorf("status=dead filter leaked non-dead row: "+
|
||||||
|
"id=%s status=%s", r.ID, r.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawDead {
|
||||||
|
t.Errorf("status=dead filter missed seeded dead row %s", deadID)
|
||||||
|
}
|
||||||
|
if sawPending {
|
||||||
|
t.Errorf("status=dead filter leaked seeded pending row %s",
|
||||||
|
pendingID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-17
@@ -19,15 +19,44 @@
|
|||||||
//
|
//
|
||||||
// Environment overrides:
|
// Environment overrides:
|
||||||
//
|
//
|
||||||
// CERTCTL_QA_SERVER_URL (default: http://localhost:8443)
|
// CERTCTL_QA_SERVER_URL (default: https://localhost:8443)
|
||||||
// CERTCTL_QA_API_KEY (default: change-me-in-production)
|
// CERTCTL_QA_API_KEY (default: change-me-in-production)
|
||||||
// CERTCTL_QA_DB_URL (default: postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable)
|
// CERTCTL_QA_DB_URL (default: postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable)
|
||||||
// CERTCTL_QA_REPO_DIR (default: ../.. — the certctl repo root)
|
// CERTCTL_QA_REPO_DIR (default: ../.. — the certctl repo root)
|
||||||
|
// CERTCTL_QA_CA_BUNDLE (default: ./certs/ca.crt — the demo stack's init container writes here)
|
||||||
|
// CERTCTL_QA_INSECURE (default: false — set to "true" to skip TLS verify, e.g. before the init container finishes)
|
||||||
|
//
|
||||||
|
// TLS note (HTTPS-Everywhere M-007, Phase 6): the demo compose stack now
|
||||||
|
// listens on https://localhost:8443 with a self-signed cert written by the
|
||||||
|
// tls-init container. This suite pins the issuing CA via
|
||||||
|
// CERTCTL_QA_CA_BUNDLE so cert rotation or a tampered proxy fails the
|
||||||
|
// handshake instead of being silently trusted. CERTCTL_QA_INSECURE="true"
|
||||||
|
// is an explicit opt-out for bootstrap scenarios — there is no silent
|
||||||
|
// plaintext downgrade, matching the server-side pre-flight guard added in
|
||||||
|
// Phase 5 (task #203).
|
||||||
|
//
|
||||||
|
// Q-1 closure (cat-s3-58ce7e9840be): this file contains 11 `t.Skip("Requires
|
||||||
|
// X — manual test")` markers across the Part10..Part37 subtests
|
||||||
|
// (Sub-CA, ARI, Vault, DigiCert, CLI binary, MCP-server binary,
|
||||||
|
// scheduler-timing, docker-log inspection, and three browser-UI parts).
|
||||||
|
// Each marks a subtest that exercises a path requiring real external
|
||||||
|
// services or human-in-the-loop verification — they were never meant
|
||||||
|
// to run unattended in CI. The file-level `//go:build qa` tag at line 1
|
||||||
|
// already keeps them out of the default `go test ./...` invocation;
|
||||||
|
// the runtime t.Skip is the second-line guard for operators who run
|
||||||
|
// `-tags qa` against a stack that doesn't have the required external
|
||||||
|
// service available. The audit recommendation was "audit each skip and
|
||||||
|
// decide" — for these 11, the decision is **document-skip**: the gating
|
||||||
|
// is correct, and the t.Skip messages already name the missing
|
||||||
|
// precondition. No restructuring needed.
|
||||||
package integration_test
|
package integration_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -49,10 +78,12 @@ func qaEnv(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
qaServerURL = qaEnv("CERTCTL_QA_SERVER_URL", "http://localhost:8443")
|
qaServerURL = qaEnv("CERTCTL_QA_SERVER_URL", "https://localhost:8443")
|
||||||
qaAPIKey = qaEnv("CERTCTL_QA_API_KEY", "change-me-in-production")
|
qaAPIKey = qaEnv("CERTCTL_QA_API_KEY", "change-me-in-production")
|
||||||
qaDBURL = qaEnv("CERTCTL_QA_DB_URL", "postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable")
|
qaDBURL = qaEnv("CERTCTL_QA_DB_URL", "postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable")
|
||||||
qaRepoDir = qaEnv("CERTCTL_QA_REPO_DIR", filepath.Join("..", ".."))
|
qaRepoDir = qaEnv("CERTCTL_QA_REPO_DIR", filepath.Join("..", ".."))
|
||||||
|
qaCABundlePath = qaEnv("CERTCTL_QA_CA_BUNDLE", "./certs/ca.crt")
|
||||||
|
qaInsecure = strings.EqualFold(os.Getenv("CERTCTL_QA_INSECURE"), "true")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -65,9 +96,38 @@ type qaClient struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildQATLSConfig returns the *tls.Config used by every qaClient. TLS 1.3
|
||||||
|
// minimum matches the server-side config pinned in Phase 2 (cmd/server).
|
||||||
|
// When CERTCTL_QA_INSECURE=true we skip verification entirely — useful
|
||||||
|
// when running against a compose stack where the tls-init container hasn't
|
||||||
|
// written ca.crt yet, or when pointing at a dev server with a rotated cert.
|
||||||
|
// Otherwise we pin CERTCTL_QA_CA_BUNDLE and panic on read/parse failure
|
||||||
|
// rather than silently downgrading to the system trust store (which would
|
||||||
|
// mask a missing init container).
|
||||||
|
func buildQATLSConfig() *tls.Config {
|
||||||
|
cfg := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||||
|
if qaInsecure {
|
||||||
|
cfg.InsecureSkipVerify = true
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
pem, err := os.ReadFile(qaCABundlePath)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("qa test: read CA bundle %q: %v — set CERTCTL_QA_CA_BUNDLE or CERTCTL_QA_INSECURE=true", qaCABundlePath, err))
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
panic(fmt.Sprintf("qa test: no PEM certificates parsed from %q", qaCABundlePath))
|
||||||
|
}
|
||||||
|
cfg.RootCAs = pool
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
func newQAClient() *qaClient {
|
func newQAClient() *qaClient {
|
||||||
return &qaClient{
|
return &qaClient{
|
||||||
http: &http.Client{Timeout: 30 * time.Second},
|
http: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{TLSClientConfig: buildQATLSConfig()},
|
||||||
|
},
|
||||||
baseURL: qaServerURL,
|
baseURL: qaServerURL,
|
||||||
apiKey: qaAPIKey,
|
apiKey: qaAPIKey,
|
||||||
}
|
}
|
||||||
@@ -434,10 +494,19 @@ func TestQA(t *testing.T) {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
t.Run("Part03_CertCRUD", func(t *testing.T) {
|
t.Run("Part03_CertCRUD", func(t *testing.T) {
|
||||||
t.Run("Create_Minimal", func(t *testing.T) {
|
t.Run("Create_Minimal", func(t *testing.T) {
|
||||||
|
// C-001 scope-expansion: the handler's ValidateRequired
|
||||||
|
// contract now gates common_name, owner_id, team_id,
|
||||||
|
// issuer_id, name, and renewal_policy_id. A 3-field
|
||||||
|
// payload would 400 regardless of the id hint, so the
|
||||||
|
// "minimal" variant carries every required field.
|
||||||
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
|
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
|
||||||
"id": "mc-qa-minimal",
|
"id": "mc-qa-minimal",
|
||||||
|
"name": "qa-minimal",
|
||||||
"common_name": "qa-minimal.example.com",
|
"common_name": "qa-minimal.example.com",
|
||||||
"issuer_id": "iss-local"
|
"issuer_id": "iss-local",
|
||||||
|
"owner_id": "o-alice",
|
||||||
|
"team_id": "t-platform",
|
||||||
|
"renewal_policy_id": "rp-standard"
|
||||||
}`)
|
}`)
|
||||||
if code != 201 && code != 200 {
|
if code != 201 && code != 200 {
|
||||||
t.Fatalf("create cert: status %d, body: %s", code, body)
|
t.Fatalf("create cert: status %d, body: %s", code, body)
|
||||||
@@ -447,11 +516,14 @@ func TestQA(t *testing.T) {
|
|||||||
t.Run("Create_Full", func(t *testing.T) {
|
t.Run("Create_Full", func(t *testing.T) {
|
||||||
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
|
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
|
||||||
"id": "mc-qa-full",
|
"id": "mc-qa-full",
|
||||||
|
"name": "qa-full",
|
||||||
"common_name": "qa-full.example.com",
|
"common_name": "qa-full.example.com",
|
||||||
"sans": ["qa-full-alt.example.com"],
|
"sans": ["qa-full-alt.example.com"],
|
||||||
"issuer_id": "iss-local",
|
"issuer_id": "iss-local",
|
||||||
"environment": "staging",
|
"environment": "staging",
|
||||||
"owner_id": "o-alice"
|
"owner_id": "o-alice",
|
||||||
|
"team_id": "t-platform",
|
||||||
|
"renewal_policy_id": "rp-standard"
|
||||||
}`)
|
}`)
|
||||||
if code != 201 && code != 200 {
|
if code != 201 && code != 200 {
|
||||||
t.Fatalf("create cert: status %d, body: %s", code, body)
|
t.Fatalf("create cert: status %d, body: %s", code, body)
|
||||||
@@ -596,13 +668,37 @@ func TestQA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("CRL_JSON", func(t *testing.T) {
|
// M-006: The non-standard JSON CRL endpoint was removed. RFC 5280 §5
|
||||||
code, body := c.bodyStr(t, "GET", "/api/v1/crl", "")
|
// defines only the DER wire format, now served unauthenticated at
|
||||||
if code != 200 {
|
// `/.well-known/pki/crl/{issuer_id}` per RFC 8615. Use a plain
|
||||||
t.Fatalf("CRL = %d", code)
|
// http.Get — no Bearer — to prove the endpoint is reachable by
|
||||||
|
// relying parties with no API credentials.
|
||||||
|
t.Run("CRL_DER_Unauthenticated", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(qaServerURL + "/.well-known/pki/crl/iss-local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET DER CRL: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "entries") {
|
defer resp.Body.Close()
|
||||||
t.Fatalf("CRL response missing entries field")
|
if resp.StatusCode != 200 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("CRL = %d (body=%s)", resp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "application/pkix-crl" {
|
||||||
|
t.Errorf("Content-Type: got %q, want %q", ct, "application/pkix-crl")
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read CRL body: %v", err)
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
t.Fatal("CRL body empty")
|
||||||
|
}
|
||||||
|
crl, err := x509.ParseRevocationList(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse DER CRL: %v", err)
|
||||||
|
}
|
||||||
|
if len(crl.RevokedCertificateEntries) < 1 {
|
||||||
|
t.Fatalf("CRL entries: got %d, want >= 1", len(crl.RevokedCertificateEntries))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -952,6 +1048,26 @@ func TestQA(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Part 23: S/MIME & EKU Support — manual test (no automation yet)
|
||||||
|
// ===================================================================
|
||||||
|
t.Run("Part23_SMIMEEku", func(t *testing.T) {
|
||||||
|
t.Skip("Part 23 (S/MIME & EKU) is documented in docs/testing-guide.md::Part 23 " +
|
||||||
|
"as a manual test. Automation candidates: profile creation with SMIME EKU; " +
|
||||||
|
"issuance request with mismatched EKU should 400; issued cert MUST contain " +
|
||||||
|
"SMIMECapabilities extension when profile.allow_smime=true.")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Part 24: OCSP Responder & DER CRL — manual test (no automation yet)
|
||||||
|
// ===================================================================
|
||||||
|
t.Run("Part24_OCSPCRL", func(t *testing.T) {
|
||||||
|
t.Skip("Part 24 (OCSP/CRL) is documented in docs/testing-guide.md::Part 24 " +
|
||||||
|
"as a manual test. Automation candidates: GET /.well-known/pki/ocsp/{issuer}/{serial} " +
|
||||||
|
"returns RFC 6960 OCSPResponse; DER CRL response is valid ASN.1 and signed by issuing CA; " +
|
||||||
|
"Must-Staple cert returns OCSP for fail-open relying parties.")
|
||||||
|
})
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// Part 25: Certificate Discovery
|
// Part 25: Certificate Discovery
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -1790,6 +1906,26 @@ func TestQA(t *testing.T) {
|
|||||||
fileContains(t, "migrations/seed_demo.sql", `iss-awsacmpca`)
|
fileContains(t, "migrations/seed_demo.sql", `iss-awsacmpca`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Part 55: Agent Soft-Retirement (I-004) — manual test (no automation yet)
|
||||||
|
// ===================================================================
|
||||||
|
t.Run("Part55_AgentSoftRetire", func(t *testing.T) {
|
||||||
|
t.Skip("Part 55 (Agent Soft-Retirement) is documented in docs/testing-guide.md::Part 55 " +
|
||||||
|
"as a manual test. Automation candidates: POST /api/v1/agents/{id}/retire with " +
|
||||||
|
"soft=true does not delete; foreign-key cascade behavior on certs owned by retired " +
|
||||||
|
"agent; reactivation flow restores agent status.")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Part 56: Notification Retry & Dead-Letter Queue (I-005) — manual test (no automation yet)
|
||||||
|
// ===================================================================
|
||||||
|
t.Run("Part56_NotificationDeadLetter", func(t *testing.T) {
|
||||||
|
t.Skip("Part 56 (Notification Retry/Dead-Letter) is documented in docs/testing-guide.md::Part 56 " +
|
||||||
|
"as a manual test. Automation candidates: notification with N consecutive failures " +
|
||||||
|
"transitions to status=DeadLetter; POST /api/v1/notifications/{id}/requeue resets to " +
|
||||||
|
"Pending; idempotency under concurrent retry; alert on dead-letter buildup.")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: uses Go 1.21+ built-in min() — no custom definition needed.
|
// Note: uses Go 1.21+ built-in min() — no custom definition needed.
|
||||||
|
|||||||
+45
-10
@@ -1,5 +1,30 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# certctl End-to-End Test Script
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
@@ -32,10 +57,11 @@ set -euo pipefail
|
|||||||
# Config
|
# Config
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
COMPOSE_FILE="docker-compose.test.yml"
|
COMPOSE_FILE="docker-compose.test.yml"
|
||||||
API_URL="http://localhost:8443"
|
API_URL="https://localhost:8443"
|
||||||
API_KEY="test-key-2026"
|
API_KEY="test-key-2026"
|
||||||
NGINX_TLS="localhost:8444"
|
NGINX_TLS="localhost:8444"
|
||||||
AUTH_HEADER="Authorization: Bearer ${API_KEY}"
|
AUTH_HEADER="Authorization: Bearer ${API_KEY}"
|
||||||
|
CACERT="./certs/ca.crt"
|
||||||
|
|
||||||
# Flags
|
# Flags
|
||||||
BUILD=true
|
BUILD=true
|
||||||
@@ -91,7 +117,7 @@ header() {
|
|||||||
# API helper: GET endpoint, return JSON body. Exits 1 on HTTP error.
|
# API helper: GET endpoint, return JSON body. Exits 1 on HTTP error.
|
||||||
api_get() {
|
api_get() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
curl -sf -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
curl -sf --cacert "${CACERT}" -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# API helper: POST with optional JSON body
|
# API helper: POST with optional JSON body
|
||||||
@@ -99,10 +125,10 @@ api_post() {
|
|||||||
local path="$1"
|
local path="$1"
|
||||||
local body="${2:-}"
|
local body="${2:-}"
|
||||||
if [ -n "$body" ]; then
|
if [ -n "$body" ]; then
|
||||||
curl -sf -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/json" \
|
curl -sf --cacert "${CACERT}" -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/json" \
|
||||||
-d "$body" "${API_URL}${path}" 2>/dev/null
|
-d "$body" "${API_URL}${path}" 2>/dev/null
|
||||||
else
|
else
|
||||||
curl -sf -X POST -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
curl -sf --cacert "${CACERT}" -X POST -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,13 +634,22 @@ else
|
|||||||
fail "Revocation failed" "$REVOKE_RESP"
|
fail "Revocation failed" "$REVOKE_RESP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Checking CRL..."
|
info "Checking DER CRL under /.well-known/pki (RFC 5280 §5, RFC 8615)..."
|
||||||
CRL_RESP=$(api_get "/api/v1/crl" 2>/dev/null || echo '{"total":0}')
|
# The JSON CRL endpoint (`GET /api/v1/crl`) was removed in M-006. RFC 5280
|
||||||
CRL_TOTAL=$(echo "$CRL_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
# defines only the DER wire format, now served unauthenticated at
|
||||||
if [ "$CRL_TOTAL" -ge 1 ]; then
|
# `/.well-known/pki/crl/{issuer_id}`. Fetch without the Bearer header to
|
||||||
pass "CRL contains $CRL_TOTAL revoked certificate(s)"
|
# 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
|
else
|
||||||
fail "CRL empty after revocation"
|
fail "DER CRL fetch failed: HTTP=$CRL_HTTP_CODE Content-Type=$CRL_CONTENT_TYPE size=$CRL_SIZE"
|
||||||
fi
|
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")
|
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")
|
||||||
|
|||||||
+118
-24
@@ -61,12 +61,12 @@ flowchart TB
|
|||||||
API["REST API\n(Go net/http, :8443)"]
|
API["REST API\n(Go net/http, :8443)"]
|
||||||
SVC["Service Layer"]
|
SVC["Service Layer"]
|
||||||
REPO["Repository Layer\n(database/sql + lib/pq)"]
|
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)"]
|
DASH["Web Dashboard\n(React SPA)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Data Store"
|
subgraph "Data Store"
|
||||||
PG[("PostgreSQL 16\n21 tables\nTEXT primary keys")]
|
PG[("PostgreSQL 16\nTEXT primary keys")]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Agent Fleet"
|
subgraph "Agent Fleet"
|
||||||
@@ -139,6 +139,18 @@ 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 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
|
### 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).
|
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).
|
||||||
@@ -153,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
|
- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography
|
||||||
- SSE/WebSocket planned for real-time job status updates
|
- 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
|
### 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`.
|
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`.
|
||||||
@@ -275,6 +291,9 @@ erDiagram
|
|||||||
text channel
|
text channel
|
||||||
text recipient
|
text recipient
|
||||||
text status
|
text status
|
||||||
|
int retry_count
|
||||||
|
timestamptz next_retry_at
|
||||||
|
text last_error
|
||||||
}
|
}
|
||||||
certificate_profiles {
|
certificate_profiles {
|
||||||
text id PK
|
text id PK
|
||||||
@@ -335,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
|
## Data Flow: Certificate Lifecycle
|
||||||
|
|
||||||
@@ -463,7 +487,7 @@ sequenceDiagram
|
|||||||
API-->>U: 200 OK
|
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.
|
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.
|
||||||
|
|
||||||
@@ -473,40 +497,55 @@ For compliance events requiring fleet-wide revocation (key compromise, CA distru
|
|||||||
|
|
||||||
### 4. Automatic Renewal
|
### 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph "Scheduler (Background Goroutines)"
|
subgraph "Scheduler (Background Goroutines)"
|
||||||
R["Renewal Checker\n⏱ every 1h"]
|
R["Renewal Checker\n⏱ every 1h"]
|
||||||
J["Job Processor\n⏱ every 30s"]
|
J["Job Processor\n⏱ every 30s"]
|
||||||
|
JR["Job Retry\n⏱ every 5m"]
|
||||||
|
JT["Job Timeout\n⏱ every 10m"]
|
||||||
H["Agent Health\n⏱ every 2m"]
|
H["Agent Health\n⏱ every 2m"]
|
||||||
N["Notification Processor\n⏱ every 1m"]
|
N["Notification Processor\n⏱ every 1m"]
|
||||||
|
NR["Notification Retry\n⏱ every 2m"]
|
||||||
SL["Short-Lived Expiry\n⏱ every 30s"]
|
SL["Short-Lived Expiry\n⏱ every 30s"]
|
||||||
NS["Network Scanner\n⏱ every 6h"]
|
NS["Network Scanner\n⏱ every 6h"]
|
||||||
DG["Certificate Digest\n⏱ every 24h"]
|
DG["Certificate Digest\n⏱ every 24h"]
|
||||||
|
HC["Endpoint Health\n⏱ every 60s"]
|
||||||
|
CD["Cloud Discovery\n⏱ every 6h"]
|
||||||
end
|
end
|
||||||
|
|
||||||
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
|
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
|
||||||
J -->|"Process pending jobs\nCoordinate issuance"| DB
|
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
|
H -->|"Check heartbeat staleness\nMark agents offline"| DB
|
||||||
N -->|"Send pending notifications\nEmail / Webhook / Slack"| 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
|
SL -->|"Expire short-lived certs\nMark as Expired"| DB
|
||||||
NS -->|"Probe TLS endpoints\nStore discovered certs"| DB
|
NS -->|"Probe TLS endpoints\nStore discovered certs"| DB
|
||||||
DG -->|"Generate & send HTML digest\nEmail to recipients"| 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 |
|
| Loop | Interval | Always-on? | Purpose |
|
||||||
|------|----------|---------|---------|
|
|------|----------|------------|---------|
|
||||||
| Renewal checker | 1 hour | 5 minutes | Finds certificates approaching expiry, creates renewal jobs |
|
| Renewal checker | 1 hour | Yes | Finds certificates approaching expiry (threshold-based or ARI-directed), creates renewal jobs |
|
||||||
| Job processor | 30 seconds | 2 minutes | Processes pending jobs (issuance, renewal, deployment) |
|
| Job processor | 30 seconds | Yes | Processes pending jobs (issuance, renewal, deployment) |
|
||||||
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
|
| Job retry | 5 minutes (`CERTCTL_SCHEDULER_RETRY_INTERVAL`) | Yes | Transitions `Failed` jobs back to `Pending` for re-dispatch (I-001) |
|
||||||
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
|
| 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) |
|
||||||
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
| Agent health check | 2 minutes | Yes | Marks agents as offline if heartbeat is stale |
|
||||||
| 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. |
|
| Notification processor | 1 minute | Yes | Sends pending notifications via configured channels |
|
||||||
| 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. |
|
| 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.
|
Each operation has a context timeout to prevent indefinite hangs if external services become unresponsive.
|
||||||
|
|
||||||
@@ -606,7 +645,7 @@ type Connector interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Built-in issuers (live count: `ls -d internal/connector/issuer/*/ | wc -l`): **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), **AWS ACM Private CA** (synchronous issuance via ACM PCA API), **Entrust** (mTLS client cert auth, sync/approval-pending), **GlobalSign Atlas HVCA** (mTLS + API key/secret dual auth), and **EJBCA** (Keyfactor open-source self-hosted CA, dual auth: mTLS or OAuth2). 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 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.
|
**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.
|
||||||
|
|
||||||
@@ -648,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.
|
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)
|
### 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`).
|
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`).
|
||||||
@@ -685,6 +734,8 @@ 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 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).
|
**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.
|
**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)
|
### SCEP Server (RFC 8894)
|
||||||
@@ -711,7 +762,7 @@ 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.
|
**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 uses challenge passwords embedded in CSR attributes (OID 1.2.840.113549.1.9.7) rather than TLS client certificates. The server validates the challenge password against `CERTCTL_SCEP_CHALLENGE_PASSWORD`. When no challenge password is configured, any value is accepted.
|
**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):
|
**Interface:** The `SCEPHandler` defines an `SCEPService` interface (dependency inversion):
|
||||||
|
|
||||||
@@ -768,10 +819,11 @@ The control plane only handles public material: certificates, chains, and CSRs.
|
|||||||
|
|
||||||
### Authentication
|
### 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
|
- **Agent → Server**: API key registered at agent creation, included in all requests
|
||||||
- **Server → Issuers**: ACME account key, or connector-specific credentials
|
- **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.
|
- **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
|
### Audit Trail
|
||||||
|
|
||||||
@@ -808,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.
|
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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -822,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`
|
4. **BodyLimit** - request body size cap via `http.MaxBytesReader`
|
||||||
5. **RateLimiter** - token bucket rate limiting (optional, when enabled)
|
5. **RateLimiter** - token bucket rate limiting (optional, when enabled)
|
||||||
6. **CORS** - cross-origin request handling (deny-by-default)
|
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)
|
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
|
### 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
|
### Logging
|
||||||
|
|
||||||
@@ -846,7 +932,15 @@ 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.
|
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 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.
|
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml`. The router-vs-spec parity is pinned by the `TestRouter_OpenAPIParity` regression test (Bundle D / M-027), which AST-walks `internal/api/router/router.go` for every `r.Register` AND direct `r.mux.Handle` registration and asserts the set matches the spec's `paths:` block exactly. Live counts:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -cE 'r\.Register\("[A-Z]' internal/api/router/router.go # r.Register sites
|
||||||
|
grep -cE 'r\.mux\.Handle\("[A-Z]' internal/api/router/router.go # r.mux.Handle sites (auth-exempt: health/ready/auth-info/version)
|
||||||
|
grep -cE '^\s+operationId:' api/openapi.yaml # documented operations
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`.
|
||||||
|
|
||||||
@@ -861,7 +955,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).
|
- **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.
|
- **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.
|
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.
|
||||||
|
|
||||||
@@ -1023,7 +1117,7 @@ flowchart TB
|
|||||||
|
|
||||||
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
|
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
|
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** — 9th scheduler loop (6h default), runs immediately on startup, `atomic.Bool` idempotency guard
|
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
|
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}`
|
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
|
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
|
||||||
@@ -1045,7 +1139,7 @@ This data flow is pull-based and non-blocking. Agents discover at their own pace
|
|||||||
|
|
||||||
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.
|
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 8th 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).
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or sel
|
|||||||
```bash
|
```bash
|
||||||
cd /opt/certctl
|
cd /opt/certctl
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
# Dashboard & API: http://localhost:8443
|
# Dashboard & API: https://localhost:8443 (self-signed cert — pin with --cacert ./deploy/test/certs/ca.crt)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option B: Kubernetes** (recommended for prod)
|
**Option B: Kubernetes** (recommended for prod)
|
||||||
@@ -59,7 +59,8 @@ chmod +x /usr/local/bin/certctl-agent
|
|||||||
|
|
||||||
# Config
|
# Config
|
||||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||||
CERTCTL_SERVER_URL=http://certctl-control-plane:8443
|
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_API_KEY=your-api-key
|
||||||
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
|
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
|
||||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||||
|
|||||||
@@ -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
|
- Proxy agent executes deployment via appliance API
|
||||||
|
|
||||||
**Revocation Distribution**
|
**Revocation Distribution**
|
||||||
- Certificate Revocation List (CRL) via `GET /api/v1/crl/{issuer_id}`
|
- 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
|
- Returns DER-encoded X.509 CRL signed by issuing CA (`Content-Type: application/pkix-crl`)
|
||||||
- 24-hour validity period
|
- 24-hour validity period
|
||||||
- Includes all revoked serials, reasons, and revocation timestamps
|
- 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
|
- Subject to URL caching; OCSP preferred for real-time revocation
|
||||||
- OCSP via `GET /api/v1/ocsp/{issuer_id}/{serial}`
|
- OCSP via `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960)
|
||||||
- Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure)
|
- Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure, `Content-Type: application/ocsp-response`)
|
||||||
- Signed by issuing CA (or delegated OCSP signing cert)
|
- Signed by issuing CA (or delegated OCSP signing cert)
|
||||||
- Responds with good/revoked/unknown status
|
- 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
|
- Real-time, more bandwidth-efficient than CRL polling
|
||||||
|
|
||||||
## Revocation and Compromise (NIST SP 800-57 Part 3)
|
## Revocation and Compromise (NIST SP 800-57 Part 3)
|
||||||
|
|||||||
+16
-14
@@ -92,10 +92,10 @@ 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.
|
- **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):
|
- **Revocation Infrastructure** (M15a, M15b, M-006):
|
||||||
- Revocation API: `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes
|
- Revocation API: `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes
|
||||||
- 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)
|
- 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 /api/v1/ocsp/{issuer_id}/{serial}` (returns DER-encoded OCSP response: good/revoked/unknown)
|
- 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
|
- 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)
|
- Short-lived cert exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
|
||||||
|
|
||||||
@@ -109,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.
|
- 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).
|
- 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.
|
- 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`.
|
- 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.
|
- Dashboard charts showing expiration timeline, renewal success trends, status distribution.
|
||||||
|
|
||||||
@@ -328,9 +328,10 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
- Issuer notified (best-effort; ACME lacks standard revocation, Local CA skips issuer step).
|
- Issuer notified (best-effort; ACME lacks standard revocation, Local CA skips issuer step).
|
||||||
- Revocation notifications sent to owner via email/webhook/Slack/Teams/PagerDuty.
|
- Revocation notifications sent to owner via email/webhook/Slack/Teams/PagerDuty.
|
||||||
|
|
||||||
- **CRL and OCSP Publication** (M15b) — Revoked certificates published in:
|
- **CRL and OCSP Publication** (M15b, M-006) — 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)
|
- 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 /api/v1/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain)
|
- 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.
|
- 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.
|
- **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.
|
||||||
@@ -342,8 +343,8 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
|
|
||||||
**Evidence You Can Provide**:
|
**Evidence You Can Provide**:
|
||||||
- Revocation requests: `GET /api/v1/audit?type=certificate_revoked` with RFC 5280 reason codes.
|
- 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.
|
- 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 /api/v1/ocsp/{issuer}/{serial}` for a known-revoked cert; response includes `revoked` status.
|
- 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`.
|
- Audit trail: Certificate status transitions (Active → Revoked) recorded in `audit_events`.
|
||||||
|
|
||||||
**Operator Responsibility**:
|
**Operator Responsibility**:
|
||||||
@@ -386,12 +387,12 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
- API key transmitted in Authorization header (not URL parameter, not cookie).
|
- API key transmitted in Authorization header (not URL parameter, not cookie).
|
||||||
- Browser to server: TLS.
|
- Browser to server: TLS.
|
||||||
- Agent 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**:
|
**Evidence You Can Provide**:
|
||||||
- API configuration: `CERTCTL_AUTH_TYPE=api-key` in deployment manifest.
|
- API configuration: `CERTCTL_AUTH_TYPE=api-key` in deployment manifest.
|
||||||
- Database schema: `api_keys` table showing SHA-256 hash column, not plaintext.
|
- 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 Bearer token validation (no plaintext keys logged).
|
- 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.
|
- 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).
|
- GUI login flow: browser network tab showing Authorization header (token value redacted in compliance report).
|
||||||
|
|
||||||
@@ -561,6 +562,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
- **Alert Notifications** (M3, M16a) — Configurable escalation:
|
- **Alert Notifications** (M3, M16a) — Configurable escalation:
|
||||||
- Email alerts: certificate approaching expiration, renewal failure, revocation notification.
|
- Email alerts: certificate approaching expiration, renewal failure, revocation notification.
|
||||||
- Webhook: custom HTTP POST to your monitoring system (Slack, Teams, PagerDuty, OpsGenie, custom webhook).
|
- 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).
|
- Deduplication: one alert per threshold/certificate per day (avoid alert fatigue).
|
||||||
|
|
||||||
- **Audit Trail Filtering and Export** (M13) — Compliance reporting:
|
- **Audit Trail Filtering and Export** (M13) — Compliance reporting:
|
||||||
@@ -721,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 |
|
| 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.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.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.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.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.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.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 |
|
| **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 |
|
| **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 |
|
||||||
|
|||||||
+27
-16
@@ -44,7 +44,8 @@ Each section includes:
|
|||||||
|
|
||||||
**certctl Implementation** (V2 — Community Edition):
|
**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.
|
- **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.
|
- **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.
|
- **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)
|
- 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`
|
- Rate limiting middleware: `internal/api/middleware/rate_limit.go`
|
||||||
- CORS configuration: `cmd/server/main.go`, search for `CERTCTL_CORS_ORIGINS`
|
- 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**:
|
**V3 Enhancement**:
|
||||||
|
|
||||||
@@ -110,7 +116,7 @@ Each section includes:
|
|||||||
|
|
||||||
**certctl Implementation** (V2):
|
**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.
|
- **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".
|
- **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).
|
- **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.
|
- **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.
|
- **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:
|
- **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: every 1 hour, scans for certificates approaching renewal threshold
|
- Renewal loop (always-on, 1 hour): scans for certificates approaching renewal threshold
|
||||||
- Job processor loop: every 30 seconds, picks up pending/waiting jobs and advances their state
|
- Job processor loop (always-on, 30 seconds): picks up pending/waiting jobs and advances their state
|
||||||
- Health check loop: every 2 minutes, pings agents to detect downtime
|
- Job retry loop (always-on, 5 minutes, `CERTCTL_SCHEDULER_RETRY_INTERVAL`): retries Failed jobs (I-001)
|
||||||
- Notification dispatcher loop: every 1 minute, sends queued alerts
|
- Job timeout reaper loop (always-on, 10 minutes, `CERTCTL_JOB_TIMEOUT_INTERVAL`): fails AwaitingCSR/AwaitingApproval jobs past timeout (I-003)
|
||||||
- Short-lived cert expiry loop: every 30 seconds, marks expired short-lived credentials
|
- Agent health check loop (always-on, 2 minutes): pings agents to detect downtime
|
||||||
- Network scanner loop: every 6 hours, scans enabled TLS endpoints for certificate discovery
|
- Notification dispatcher loop (always-on, 1 minute): sends queued alerts
|
||||||
- Digest emailer loop: every 24 hours, sends scheduled certificate digest email to configured recipients
|
- 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)
|
||||||
Each loop includes error handling and logs failures via structured slog.
|
- 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:
|
- **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` — 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
|
- `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,8 +293,8 @@ Each section includes:
|
|||||||
- `certificateHold` — temporary revocation (can be "unhold" by reissue)
|
- `certificateHold` — temporary revocation (can be "unhold" by reissue)
|
||||||
- `privilegeWithdrawn` — access rights revoked
|
- `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.
|
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).
|
- **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 /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.
|
- **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:
|
- **Revocation Notifications** — When a cert is revoked, notifications are sent to:
|
||||||
- Certificate owner (email)
|
- Certificate owner (email)
|
||||||
- Configured webhooks (if you have a SIEM that subscribes)
|
- Configured webhooks (if you have a SIEM that subscribes)
|
||||||
@@ -453,15 +464,15 @@ Each section includes:
|
|||||||
| | Metrics JSON Endpoint | `GET /api/v1/metrics` (gauges, counters, uptime) | ✅ | ✅ | Set thresholds, configure alerting |
|
| | 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 |
|
| | 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 |
|
| | 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 |
|
| **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 |
|
| | 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 |
|
| | 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 |
|
| | Notification Routing | Email, Slack, Teams, PagerDuty, OpsGenie | ✅ | ✅ | Configure notifiers, on-call integration |
|
||||||
| | Deployment Rollback | Redeploy previous cert version via GUI | ✅ | ✅ | Audit rollback decisions |
|
| | 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 |
|
| **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 |
|
| | 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 | `GET /api/v1/ocsp/{issuer_id}/{serial}` | ✅ | ✅ | Test revocation in staging |
|
| | 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 |
|
| | 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 |
|
| | 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 |
|
| **CC7.4** Risk Mitigation | Renewal Job Tracking | Job state machine (Pending → Running → Completed/Failed) | ✅ | ✅ | Monitor renewal success rate |
|
||||||
|
|||||||
@@ -32,6 +32,85 @@ If you're preparing for an audit and certctl is already deployed, use the "Opera
|
|||||||
| PCI-DSS 4.0 | Cardholder data protection | TLS lifecycle, key management, immutable logging, access control |
|
| PCI-DSS 4.0 | Cardholder data protection | TLS lifecycle, key management, immutable logging, access control |
|
||||||
| NIST SP 800-57 | Cryptographic key management | Agent-side keygen, key isolation, algorithm selection, revocation |
|
| NIST SP 800-57 | Cryptographic key management | Agent-side keygen, key isolation, algorithm selection, revocation |
|
||||||
|
|
||||||
|
## Audit-Trail Integrity & Privacy (Bundle 6)
|
||||||
|
|
||||||
|
Two complementary controls protect the `audit_events` table against tampering and minimize PII exposure. Both apply automatically — no operator action is required at install time, but operators must understand the contract before responding to a legal-hold or retention request.
|
||||||
|
|
||||||
|
### Append-Only Enforcement (HIPAA §164.312(b))
|
||||||
|
|
||||||
|
<!-- Source: migrations/000018_audit_events_worm.up.sql -->
|
||||||
|
|
||||||
|
`audit_events` rows cannot be modified or deleted by the application role. Two layers:
|
||||||
|
|
||||||
|
| Layer | Mechanism | Surface |
|
||||||
|
|---|---|---|
|
||||||
|
| **DB trigger** | `audit_events_block_modification()` raises `check_violation` on `BEFORE UPDATE OR DELETE` | Catches any UPDATE / DELETE — including direct `psql` from the app role |
|
||||||
|
| **App-role grant** | `REVOKE UPDATE, DELETE ON audit_events FROM certctl` | Defence-in-depth; the app role can't even attempt the modification |
|
||||||
|
|
||||||
|
**Verification.** From a `psql` session connected as the `certctl` app role:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE audit_events SET actor = 'tampered' WHERE id = 'audit-001';
|
||||||
|
-- ERROR: audit_events is append-only (Bundle-6 / M-017 / HIPAA §164.312(b))
|
||||||
|
-- HINT: Use a compliance superuser role for legitimate retention operations.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compliance superuser pattern.** Legitimate retention work (legal hold, GDPR right-to-be-forgotten, statutory purges) requires a separate PostgreSQL role provisioned out-of-band that bypasses the trigger. Certctl does NOT auto-create this role — operators provision it per their compliance policy. Suggested shape:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- One-time setup by a DBA. Stored procedure pattern keeps the
|
||||||
|
-- compliance superuser audit-able too: every invocation should
|
||||||
|
-- itself land in audit_events.
|
||||||
|
CREATE ROLE certctl_compliance LOGIN PASSWORD '<strong-secret>';
|
||||||
|
GRANT UPDATE, DELETE ON audit_events TO certctl_compliance;
|
||||||
|
-- (optional) provision SECURITY DEFINER stored procedures that
|
||||||
|
-- (a) record the retention reason in audit_events as the FIRST step
|
||||||
|
-- (b) then perform the UPDATE/DELETE
|
||||||
|
-- (c) all under the certctl_compliance role's grants.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Body Redaction (GDPR Art. 32, CWE-532)
|
||||||
|
|
||||||
|
<!-- Source: internal/service/audit_redact.go -->
|
||||||
|
|
||||||
|
`AuditService.RecordEvent` routes every `details` map through `RedactDetailsForAudit` BEFORE marshaling to the JSONB column. Two deny-lists:
|
||||||
|
|
||||||
|
| Category | Match | Replacement | Examples |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Credentials** | case-insensitive key match | `"[REDACTED:CREDENTIAL]"` | `api_key`, `password`, `token`, `*_pem`, `eab_secret`, `acme_account_key`, `signature` |
|
||||||
|
| **PII** | case-insensitive key match | `"[REDACTED:PII]"` | `email`, `phone`, `ssn`, `dob`, `name`, `address`, `postal_code`, `ip_address` |
|
||||||
|
|
||||||
|
Nested maps and arrays are walked recursively — sensitive keys at any depth get scrubbed. The redactor is mutation-free (the caller's original map is unchanged) so service-layer code that reuses the map elsewhere is safe.
|
||||||
|
|
||||||
|
**Operator visibility — `redacted_keys` array.** The redacted map includes a `redacted_keys` array listing every dotted-path that was scrubbed. This surfaces the redaction footprint to compliance auditors without exposing values. Example before/after:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Caller's input map (e.g., from a service handler):
|
||||||
|
{
|
||||||
|
"action": "create_issuer",
|
||||||
|
"issuer_id": "iss-acme-prod",
|
||||||
|
"config": {
|
||||||
|
"endpoint": "https://acme.example.com",
|
||||||
|
"eab_secret": "abc123secret",
|
||||||
|
"contact": { "email": "ops@example.com", "role": "admin" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persisted in audit_events.details:
|
||||||
|
{
|
||||||
|
"action": "create_issuer",
|
||||||
|
"issuer_id": "iss-acme-prod",
|
||||||
|
"config": {
|
||||||
|
"endpoint": "https://acme.example.com",
|
||||||
|
"eab_secret": "[REDACTED:CREDENTIAL]",
|
||||||
|
"contact": { "email": "[REDACTED:PII]", "role": "admin" }
|
||||||
|
},
|
||||||
|
"redacted_keys": ["config.eab_secret", "config.contact.email"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Maintenance.** When introducing a new credential-bearing field anywhere in the codebase, add the key name to `credentialKeys` (or `piiKeys`) in `internal/service/audit_redact.go`. The unit test suite in `audit_redact_test.go` exercises every entry and proves case-insensitivity + JSON round-trip safety.
|
||||||
|
|
||||||
## certctl Pro (V3) Enhancements
|
## certctl Pro (V3) Enhancements
|
||||||
|
|
||||||
Several compliance-relevant features are planned for certctl Pro:
|
Several compliance-relevant features are planned for certctl Pro:
|
||||||
|
|||||||
+4
-2
@@ -123,6 +123,8 @@ 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.
|
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
|
### Deployment Targets
|
||||||
|
|
||||||
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).
|
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).
|
||||||
@@ -216,9 +218,9 @@ certctl implements revocation using three complementary mechanisms:
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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 /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.
|
**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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+37
-20
@@ -155,7 +155,7 @@ 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`.
|
**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.
|
**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.
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ 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).
|
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.
|
**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.
|
||||||
|
|
||||||
@@ -1126,7 +1126,7 @@ The digest HTML template includes:
|
|||||||
- Expiring certificates table (color-coded by urgency: 7d, 14d, 30d)
|
- Expiring certificates table (color-coded by urgency: 7d, 14d, 30d)
|
||||||
- Auto-refresh and responsive email layout
|
- 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:
|
Configuration:
|
||||||
|
|
||||||
@@ -1141,13 +1141,30 @@ API Endpoints:
|
|||||||
- **`GET /api/v1/digest/preview`** — Render digest HTML for preview (no email sent)
|
- **`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)
|
- **`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:
|
Example:
|
||||||
```bash
|
```bash
|
||||||
# Preview digest
|
# 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
|
# 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:
|
Each notifier is enabled by its configuration env var:
|
||||||
@@ -1294,24 +1311,24 @@ The agent scans these directories on startup and every 6 hours, looking for cert
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List discovered certificates (filter by agent, status)
|
# 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
|
# 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)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
||||||
|
|
||||||
# Dismiss a discovery
|
# 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
|
# 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)
|
# 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
|
### Use Cases
|
||||||
@@ -1340,7 +1357,7 @@ Network scan targets can be managed from the **Network Scans** dashboard page (c
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a scan target for your internal network (or use the dashboard's "+ New Target" button)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "Production Web Servers",
|
"name": "Production Web Servers",
|
||||||
@@ -1365,31 +1382,31 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all scan targets
|
# 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
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "DMZ", "cidrs": ["172.16.0.0/24"], "ports": [443]}' | jq .
|
-d '{"name": "DMZ", "cidrs": ["172.16.0.0/24"], "ports": [443]}' | jq .
|
||||||
|
|
||||||
# Get a specific target (includes last_scan_at, last_scan_certs_found)
|
# 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)
|
# 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
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"ports": [443, 8443, 9443], "timeout_ms": 3000}' | jq .
|
-d '{"ports": [443, 8443, 9443], "timeout_ms": 3000}' | jq .
|
||||||
|
|
||||||
# Delete a scan target
|
# 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
|
### 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
|
### Use Cases
|
||||||
|
|
||||||
@@ -1447,7 +1464,7 @@ Source path format: `gcp-sm://{project}/{secret-name}`. Sentinel agent: `cloud-g
|
|||||||
|
|
||||||
### Cloud Discovery Scheduler
|
### Cloud Discovery Scheduler
|
||||||
|
|
||||||
All enabled cloud sources run on a shared scheduler loop (9th loop). The interval is configurable:
|
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 |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Database TLS — Postgres Transport Encryption
|
||||||
|
|
||||||
|
**Audit reference:** Bundle B / M-018. PCI-DSS v4.0 Req 4 §2.2.5; CWE-319.
|
||||||
|
|
||||||
|
certctl talks to Postgres over a single connection-string URL controlled by the
|
||||||
|
`CERTCTL_DATABASE_URL` env var. The `sslmode` query parameter on that URL
|
||||||
|
selects the transport-encryption posture. Pre-Bundle-B all the bundled
|
||||||
|
deployment artifacts (Helm chart, docker-compose) hard-coded `sslmode=disable`.
|
||||||
|
Bundle B exposes that as an operator-facing knob with a documented default and
|
||||||
|
explicit opt-in / opt-out paths for the four real-world deployment shapes.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Deployment shape | Default `sslmode` | When to change |
|
||||||
|
|------------------------------------------------|--------------------|----------------|
|
||||||
|
| Helm chart, bundled Postgres, in-cluster | `disable` | When the cluster does not provide pod-network encryption (CNI without WireGuard / IPSec) and the workload is in PCI-DSS scope. |
|
||||||
|
| Helm chart, external Postgres (RDS / Cloud SQL / Azure DB) | not auto-set | **Always** set to `verify-full` and provide the cloud provider's server CA bundle. |
|
||||||
|
| docker-compose, bundled Postgres on docker bridge | `disable` | Demo/dev only; not a deployment shape we expect operators to harden. |
|
||||||
|
| docker-compose / k8s with external Postgres | not auto-set | **Always** set `CERTCTL_DATABASE_URL` to a connection string with `sslmode=verify-full`. |
|
||||||
|
|
||||||
|
`sslmode` values come from `lib/pq` (the underlying driver). The full set is:
|
||||||
|
`disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full`. PCI-DSS
|
||||||
|
Req 4 v4.0 §2.2.5 considers `verify-ca` the floor for sensitive-data transport;
|
||||||
|
`verify-full` is the floor for systems exposed to spoofing risk (it adds
|
||||||
|
hostname validation against the server cert's CN/SAN).
|
||||||
|
|
||||||
|
## Helm chart (Bundle B)
|
||||||
|
|
||||||
|
Bundle B adds two values under `postgresql.tls`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
postgresql:
|
||||||
|
tls:
|
||||||
|
mode: disable # disable | require | verify-ca | verify-full
|
||||||
|
caSecretRef: "" # Secret with ca.crt key (required for verify-ca / verify-full)
|
||||||
|
```
|
||||||
|
|
||||||
|
The chart pipes `postgresql.tls.mode` into the `?sslmode=` parameter of the
|
||||||
|
generated `CERTCTL_DATABASE_URL` (see `templates/_helpers.tpl::certctl.databaseURL`).
|
||||||
|
For external Postgres, set `postgresql.enabled: false` and override
|
||||||
|
`server.env.CERTCTL_DATABASE_URL` directly with the full connection string —
|
||||||
|
the operator authoring an external-DB values file owns the entire URL.
|
||||||
|
|
||||||
|
### Example: external RDS with verify-full
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
postgresql:
|
||||||
|
enabled: false # Disable bundled Postgres
|
||||||
|
|
||||||
|
server:
|
||||||
|
env:
|
||||||
|
CERTCTL_DATABASE_URL: |
|
||||||
|
postgres://certctl:STRONGPW@my-db.cabc12345.us-east-1.rds.amazonaws.com:5432/certctl?sslmode=verify-full
|
||||||
|
|
||||||
|
# Provide the AWS RDS root CA bundle as a secret + mount.
|
||||||
|
# AWS publishes per-region root certs at https://truststore.pki.rds.amazonaws.com/
|
||||||
|
extraVolumes:
|
||||||
|
- name: rds-ca
|
||||||
|
secret:
|
||||||
|
secretName: rds-ca-bundle # kubectl create secret generic rds-ca-bundle --from-file=ca.crt=...
|
||||||
|
|
||||||
|
extraVolumeMounts:
|
||||||
|
- name: rds-ca
|
||||||
|
mountPath: /etc/postgresql-ca
|
||||||
|
readOnly: true
|
||||||
|
|
||||||
|
# lib/pq honors PGSSLROOTCERT for the verify-{ca,full} CA bundle path.
|
||||||
|
server:
|
||||||
|
env:
|
||||||
|
PGSSLROOTCERT: /etc/postgresql-ca/ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
## docker-compose (development / demo)
|
||||||
|
|
||||||
|
The bundled `deploy/docker-compose.yml` keeps `sslmode=disable` as the default
|
||||||
|
because the Postgres container shares the docker bridge network with the certctl
|
||||||
|
server and the compose file is not a production deployment artifact. To opt in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CERTCTL_DATABASE_URL='postgres://certctl:certctl@postgres:5432/certctl?sslmode=verify-full'
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
For any non-`disable` mode, confirm the connection actually negotiated TLS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From inside the certctl-server container or any host with psql + the same URL:
|
||||||
|
psql "$CERTCTL_DATABASE_URL" -c "SELECT ssl, version, cipher FROM pg_stat_ssl WHERE pid = pg_backend_pid();"
|
||||||
|
|
||||||
|
# Expected output for verify-full: ssl=t, version=TLSv1.3 (or TLSv1.2), cipher=...
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ssl=f` appears, the connection silently fell back to plaintext — investigate
|
||||||
|
the cert chain or sslmode value before treating the deployment as PCI-compliant.
|
||||||
|
|
||||||
|
## What this does NOT cover
|
||||||
|
|
||||||
|
* **Postgres-to-Postgres replication** — if you run a replica, replica-primary
|
||||||
|
TLS is configured via the Postgres server itself (`pg_hba.conf` +
|
||||||
|
`ssl=on`); it is independent of certctl's `CERTCTL_DATABASE_URL`.
|
||||||
|
* **Backup transport** — `pg_dump` / `pg_basebackup` honor the same `sslmode`
|
||||||
|
parameter when invoked with the URL form, but the bundled chart's backup
|
||||||
|
story (if any) is operator-owned.
|
||||||
|
* **Encryption at rest** — `sslmode` is a transport concern only. Disk
|
||||||
|
encryption is the cloud provider's storage layer (RDS, EBS, etc.) or the
|
||||||
|
operator's Postgres TDE / disk LUKS / etc.
|
||||||
|
|
||||||
|
## Reverting
|
||||||
|
|
||||||
|
If `sslmode=verify-full` causes connection failures (most common: missing CA
|
||||||
|
bundle, wrong hostname), drop temporarily to `sslmode=require` to confirm TLS
|
||||||
|
is at least negotiated, then add the CA bundle and ratchet back up. Never
|
||||||
|
revert to `sslmode=disable` on a system carrying real cert metadata —
|
||||||
|
audit_events alone contains enough operator/issuer/target identity to justify
|
||||||
|
TLS in any scoped environment.
|
||||||
+26
-18
@@ -50,14 +50,17 @@ docker compose -f deploy/docker-compose.yml up -d --build
|
|||||||
docker compose -f deploy/docker-compose.yml ps
|
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
|
```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
|
## How the pieces fit together
|
||||||
|
|
||||||
Before we start, here's the high-level flow of what we're about to do:
|
Before we start, here's the high-level flow of what we're about to do:
|
||||||
@@ -724,22 +727,24 @@ curl -s -X POST $API/api/v1/certificates/mc-demo-payments/revoke \
|
|||||||
6. Creates an audit trail entry
|
6. Creates an audit trail entry
|
||||||
7. Sends revocation notifications via configured channels
|
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
|
```bash
|
||||||
# JSON-formatted CRL
|
# DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection).
|
||||||
curl -s $API/api/v1/crl | jq .
|
# Note: no -H "Authorization: Bearer ..." — the endpoint is deliberately
|
||||||
|
# unauthenticated. Content-Type is application/pkix-crl.
|
||||||
# DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection)
|
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
|
||||||
curl -s $API/api/v1/crl/iss-local -o /tmp/crl.der
|
|
||||||
openssl crl -inform DER -in /tmp/crl.der -text -noout
|
openssl crl -inform DER -in /tmp/crl.der -text -noout
|
||||||
```
|
```
|
||||||
|
|
||||||
Check OCSP status:
|
Check OCSP status (RFC 6960, also unauthenticated, `application/ocsp-response`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Replace SERIAL with the actual serial number from the certificate version
|
# Replace SERIAL with the actual serial number from the certificate version.
|
||||||
curl -s $API/api/v1/ocsp/iss-local/SERIAL | jq .
|
# 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.
|
**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 .
|
cd cmd/cli && go build -o certctl-cli .
|
||||||
|
|
||||||
# Export credentials
|
# 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"
|
export CERTCTL_API_KEY="test-key-123"
|
||||||
|
|
||||||
# List certificates (JSON or table format)
|
# List certificates (JSON or table format)
|
||||||
@@ -988,7 +994,8 @@ certctl exposes the full REST API via the Model Context Protocol (MCP), enabling
|
|||||||
cd cmd/mcp-server && go build -o mcp-server .
|
cd cmd/mcp-server && go build -o mcp-server .
|
||||||
|
|
||||||
# Export credentials
|
# 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"
|
export CERTCTL_API_KEY="test-key-123"
|
||||||
|
|
||||||
# Start the MCP server (listens on stdin/stdout)
|
# 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:
|
Or with the CLI flag:
|
||||||
|
|
||||||
```bash
|
```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)
|
### Network Discovery (Server-Side)
|
||||||
@@ -1153,7 +1160,7 @@ flowchart TB
|
|||||||
API["REST API\nGo net/http"]
|
API["REST API\nGo net/http"]
|
||||||
SVC["Service Layer\nBusiness Logic"]
|
SVC["Service Layer\nBusiness Logic"]
|
||||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
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"]
|
CONN["Connector Registry\nIssuer + Target + Notifier"]
|
||||||
end
|
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
|
#!/bin/bash
|
||||||
set -e
|
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'
|
BLUE='\033[0;34m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
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 " 6. Checked dashboard stats and metrics"
|
||||||
echo " 7. All actions recorded in the audit trail"
|
echo " 7. All actions recorded in the audit trail"
|
||||||
echo ""
|
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"
|
echo "Look for certificate: $CERT_ID"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ The full walkthrough — including profile-based issuer assignment, testing with
|
|||||||
|
|
||||||
## Beyond These Examples
|
## Beyond These Examples
|
||||||
|
|
||||||
These 5 scenarios cover the most common deployment patterns, but certctl supports 7 issuer backends and 10 target connectors. Once you have the basics running, you can mix and match:
|
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).
|
**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).
|
||||||
|
|
||||||
|
|||||||
+102
-39
@@ -8,17 +8,30 @@ Complete reference of every feature shipped in certctl through v2.1.0 (April 202
|
|||||||
|
|
||||||
| Metric | Count |
|
| Metric | Count |
|
||||||
|---|---|
|
|---|---|
|
||||||
| HTTP routes | 107 (103 under `/api/v1/` + 4 EST) |
|
<!--
|
||||||
| OpenAPI 3.1 operations | 97 |
|
S-1 master closure (cat-s1-9ce1cbe26876, cat-s1-features_md_issuer_count_contradiction):
|
||||||
| MCP tools | 80 |
|
every numeric count below is captured at the time of the last edit AND
|
||||||
| CLI commands | 12 |
|
paired with the source-of-truth grep command from CLAUDE.md. CLAUDE.md
|
||||||
| Issuer connectors | 9 (+ EST server) |
|
rule: "Numeric claims about current state rot the instant the next
|
||||||
| Target connectors | 14 |
|
release lands." Re-derive before each release; the CI guardrail at
|
||||||
| Notifier connectors | 6 channels |
|
.github/workflows/ci.yml::"Forbidden hardcoded source-count prose
|
||||||
| Database tables | 21 (across 10 migrations) |
|
regression guard (S-1)" fails the build on any new prose-only counts
|
||||||
| Background scheduler loops | 7 |
|
without an adjacent rebuild command.
|
||||||
| Web dashboard pages | 24 |
|
-->
|
||||||
| Test functions | 1850+ |
|
| Surface | Count (rebuild command) |
|
||||||
|
|---|---|
|
||||||
|
| HTTP routes | rebuild via `grep -cE 'r\.Register\("[A-Z]' internal/api/router/router.go` |
|
||||||
|
| OpenAPI 3.1 operations | rebuild via `grep -cE '^\s+operationId:' api/openapi.yaml` |
|
||||||
|
| MCP tools | rebuild via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` |
|
||||||
|
| CLI commands | rebuild via `grep -cE 'AddCommand|RootCmd\.Add' cmd/cli/*.go internal/cli/*.go` (intentionally narrow — see CLI Scope §) |
|
||||||
|
| Issuer connectors | rebuild via `ls -d internal/connector/issuer/*/ \| wc -l` (+ EST server) |
|
||||||
|
| Target connectors | rebuild via `ls -d internal/connector/target/*/ \| wc -l` (includes shared `certutil/`) |
|
||||||
|
| Notifier connectors | rebuild via `ls -d internal/connector/notifier/*/ \| wc -l` |
|
||||||
|
| Discovery connectors | rebuild via `ls -d internal/connector/discovery/*/ \| wc -l` |
|
||||||
|
| Database tables | rebuild via `grep -hE '^CREATE TABLE' migrations/*.up.sql \| sed -E 's/CREATE TABLE (IF NOT EXISTS )?([a-zA-Z_]+).*/\2/' \| sort -u \| wc -l` (across `ls migrations/*.up.sql \| wc -l` migrations) |
|
||||||
|
| Background scheduler loops | rebuild via `grep -cE '^func \(s \*Scheduler\) [a-zA-Z]+Loop' internal/scheduler/scheduler.go` |
|
||||||
|
| Web dashboard pages | rebuild via `ls web/src/pages/*.tsx \| grep -v '\.test\.' \| wc -l` |
|
||||||
|
| Test functions (Go backend) | rebuild via the `find` + `grep '^func Test'` recipe in CLAUDE.md::Current-state commands |
|
||||||
| Supported platforms | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 |
|
| Supported platforms | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -47,11 +60,20 @@ Two endpoints are served without auth so the GUI can detect auth mode before log
|
|||||||
|
|
||||||
Token bucket algorithm protecting the control plane from misbehaving clients.
|
Token bucket algorithm protecting the control plane from misbehaving clients.
|
||||||
|
|
||||||
|
Bundle B (Audit M-025 / OWASP ASVS L2 §11.2.1): per-key keying. Each
|
||||||
|
authenticated caller gets a bucket keyed on their API-key name; each
|
||||||
|
unauthenticated source IP gets its own bucket. Bucket creation is
|
||||||
|
on-demand under a `sync.RWMutex`; no eviction (the leak is bounded by
|
||||||
|
realistic operator IP fan-out — appropriate for the OWASP ASVS L2 threat
|
||||||
|
model of abuse-by-known-clients, not infinite-cardinality scanners).
|
||||||
|
|
||||||
| Env Var | Default | Description |
|
| Env Var | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable/disable |
|
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable/disable |
|
||||||
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second |
|
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Per-key requests per second (default applies to IP-keyed buckets; user-keyed buckets fall back to this when `PER_USER_RPS` is unset) |
|
||||||
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Burst capacity |
|
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Per-key burst capacity (default applies to IP-keyed buckets; user-keyed buckets fall back to this when `PER_USER_BURST` is unset) |
|
||||||
|
| `CERTCTL_RATE_LIMIT_PER_USER_RPS` | `0` | Override RPS for authenticated callers. `0` means "use `RATE_LIMIT_RPS`". Set higher than `RATE_LIMIT_RPS` to grant authenticated clients a more generous budget than anonymous probes. |
|
||||||
|
| `CERTCTL_RATE_LIMIT_PER_USER_BURST` | `0` | Override burst for authenticated callers. `0` means "use `RATE_LIMIT_BURST`". |
|
||||||
|
|
||||||
Exceeded requests receive `429 Too Many Requests` with a `Retry-After` header.
|
Exceeded requests receive `429 Too Many Requests` with a `Retry-After` header.
|
||||||
|
|
||||||
@@ -75,6 +97,35 @@ Preflight responses include `Access-Control-Max-Age` for caching.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CERTCTL_MAX_BODY_SIZE` | `1048576` (1 MB) | Maximum request body in bytes |
|
| `CERTCTL_MAX_BODY_SIZE` | `1048576` (1 MB) | Maximum request body in bytes |
|
||||||
|
|
||||||
|
### Agent Bootstrap Token
|
||||||
|
|
||||||
|
<!-- Source: internal/api/handler/agent_bootstrap.go (Bundle-5 / Audit H-007) -->
|
||||||
|
|
||||||
|
Pre-shared secret enforced on `POST /api/v1/agents`. When set, the registration handler requires `Authorization: Bearer <token>` and verifies via `crypto/subtle.ConstantTimeCompare` BEFORE the JSON body parse — defeats both timing oracles and unauth payload allocation. Mismatch / missing / malformed → `401 invalid_or_missing_bootstrap_token`.
|
||||||
|
|
||||||
|
| Env Var | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `CERTCTL_AGENT_BOOTSTRAP_TOKEN` | `""` (warn-mode pass-through) | Bearer token agents must present on first registration. v2.2.0 will require it; unset emits a one-shot startup deprecation WARN. Generate with `openssl rand -hex 32`. |
|
||||||
|
|
||||||
|
### Graceful Shutdown Audit Flush
|
||||||
|
|
||||||
|
<!-- Source: cmd/server/main.go (Bundle-5 / Audit M-011) -->
|
||||||
|
|
||||||
|
On SIGTERM / SIGINT, the server drains in-flight audit recordings before closing the DB pool. The drain budget is shared with the HTTP server graceful shutdown.
|
||||||
|
|
||||||
|
| Env Var | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS` | `30` | Total budget (seconds) for HTTP shutdown + scheduler completion + audit-event drain. WARN-log on deadline exceeded; never exit hard. |
|
||||||
|
|
||||||
|
### Liveness vs Readiness Probes
|
||||||
|
|
||||||
|
<!-- Source: internal/api/handler/health.go (Bundle-5 / Audit H-006) -->
|
||||||
|
|
||||||
|
| Endpoint | Purpose | Probe |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /health` | Liveness — process alive only. Returns 200 unconditionally; never restart pods for DB hiccups. | k8s `livenessProbe` |
|
||||||
|
| `GET /ready` | Readiness — runs `db.PingContext` with 2 s ceiling. Returns 503 + `{"status":"db_unavailable"}` when DB unreachable so k8s drains the pod. | k8s `readinessProbe` |
|
||||||
|
|
||||||
### Query Features
|
### Query Features
|
||||||
|
|
||||||
All list endpoints support:
|
All list endpoints support:
|
||||||
@@ -136,7 +187,7 @@ Every API call is recorded to the immutable audit trail. Best-effort (non-blocki
|
|||||||
|
|
||||||
<!-- Source: internal/scheduler/scheduler.go (renewalCheckLoop, 1-hour default interval) -->
|
<!-- Source: internal/scheduler/scheduler.go (renewalCheckLoop, 1-hour default interval) -->
|
||||||
|
|
||||||
The renewal scheduler runs every hour (configurable via `CERTCTL_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration:
|
The renewal scheduler runs every hour (configurable via `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration:
|
||||||
|
|
||||||
1. Checks ACME ARI (RFC 9773) if available — CA-directed renewal timing takes priority
|
1. Checks ACME ARI (RFC 9773) if available — CA-directed renewal timing takes priority
|
||||||
2. Falls back to threshold-based logic using per-policy `alert_thresholds_days` (default `[30, 14, 7, 0]`)
|
2. Falls back to threshold-based logic using per-policy `alert_thresholds_days` (default `[30, 14, 7, 0]`)
|
||||||
@@ -228,14 +279,15 @@ Revocation is a 7-step process: validate eligibility → get serial → update s
|
|||||||
- Audit trail: single `bulk_revocation_initiated` event logs the criteria and actor
|
- Audit trail: single `bulk_revocation_initiated` event logs the criteria and actor
|
||||||
- Optional `--reason` defaults to `unspecified` if omitted
|
- Optional `--reason` defaults to `unspecified` if omitted
|
||||||
|
|
||||||
### CRL Endpoints
|
### CRL Endpoint
|
||||||
|
|
||||||
- `GET /api/v1/crl` — JSON-formatted CRL (version, entries array, total count, timestamp)
|
- `GET /.well-known/pki/crl/{issuer_id}` — DER-encoded X.509 CRL signed by the issuing CA, 24-hour validity (RFC 5280 §5 + RFC 8615). Served unauthenticated with `Content-Type: application/pkix-crl` so relying parties without certctl API credentials can fetch it.
|
||||||
- `GET /api/v1/crl/{issuer_id}` — DER-encoded X.509 CRL signed by issuing CA, 24-hour validity
|
|
||||||
|
Prior non-standard JSON CRL and authenticated `/api/v1/crl*` paths were removed in M-006 — RFC 5280 defines only the DER wire format and relying parties do not have API keys.
|
||||||
|
|
||||||
### OCSP Responder
|
### OCSP Responder
|
||||||
|
|
||||||
`GET /api/v1/ocsp/{issuer_id}/{serial}` — signed OCSP responses (good/revoked/unknown). Signs with issuing CA key. Requires CA key access (Local CA, step-CA connectors).
|
`GET /.well-known/pki/ocsp/{issuer_id}/{serial}` — signed OCSP responses (good/revoked/unknown) per RFC 6960. Served unauthenticated with `Content-Type: application/ocsp-response`. Signs with the issuing CA key; requires CA key access (Local CA, step-CA connectors).
|
||||||
|
|
||||||
### Short-Lived Certificate Exemption
|
### Short-Lived Certificate Exemption
|
||||||
|
|
||||||
@@ -324,9 +376,9 @@ Policies can be scoped to agent groups via `agent_group_id` foreign key. Violati
|
|||||||
|
|
||||||
## Issuer Connectors
|
## Issuer Connectors
|
||||||
|
|
||||||
<!-- Source: internal/domain/connector.go (12 IssuerType constants), internal/connector/issuer/ -->
|
<!-- Source: internal/domain/connector.go (IssuerType constants), internal/connector/issuer/. Rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`. -->
|
||||||
|
|
||||||
12 issuer connectors implementing the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
|
The issuer connector catalog (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`) implements the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
|
||||||
|
|
||||||
### Local CA
|
### Local CA
|
||||||
|
|
||||||
@@ -615,9 +667,9 @@ For Let's Encrypt 6-day `shortlived` certificates, ARI is the expected renewal p
|
|||||||
|
|
||||||
## Target Connectors
|
## Target Connectors
|
||||||
|
|
||||||
<!-- Source: internal/domain/connector.go (14 TargetType constants), internal/connector/target/ -->
|
<!-- Source: internal/domain/connector.go (TargetType constants), internal/connector/target/. Rebuild count via `ls -d internal/connector/target/*/ | wc -l` (includes shared `certutil/`). -->
|
||||||
|
|
||||||
14 target connector types implementing the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`.
|
The target connector catalog (rebuild count via `ls -d internal/connector/target/*/ | wc -l`) implements the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`.
|
||||||
|
|
||||||
### Deployment Model
|
### Deployment Model
|
||||||
|
|
||||||
@@ -902,7 +954,7 @@ Server-side active TLS scanning of CIDR ranges. Concurrent probing with semaphor
|
|||||||
|
|
||||||
<!-- Source: internal/connector/discovery/awssm/, azurekv/, gcpsm/, internal/service/cloud_discovery.go -->
|
<!-- Source: internal/connector/discovery/awssm/, azurekv/, gcpsm/, internal/service/cloud_discovery.go -->
|
||||||
|
|
||||||
Discovers certificates stored in cloud secret managers and brings them into the certctl inventory. Extends the existing discovery pipeline with pluggable `DiscoverySource` implementations. Each source runs as part of the 9th scheduler loop (6h default).
|
Discovers certificates stored in cloud secret managers and brings them into the certctl inventory. Extends the existing discovery pipeline with pluggable `DiscoverySource` implementations. Each source runs as part of the opt-in cloud discovery scheduler loop (6h default; see `docs/architecture.md` for the full 12-loop scheduler topology).
|
||||||
|
|
||||||
**Supported sources:**
|
**Supported sources:**
|
||||||
|
|
||||||
@@ -1096,17 +1148,22 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
|
|||||||
|
|
||||||
<!-- Source: internal/scheduler/scheduler.go -->
|
<!-- Source: internal/scheduler/scheduler.go -->
|
||||||
|
|
||||||
7 background loops, each with an `atomic.Bool` idempotency guard preventing concurrent tick execution. `sync.WaitGroup` + `WaitForCompletion()` for graceful shutdown.
|
12 background loops (8 always-on + 4 opt-in), each with an `atomic.Bool` idempotency guard preventing concurrent tick execution. `sync.WaitGroup` + `WaitForCompletion()` for graceful shutdown. Authoritative topology table lives in `docs/architecture.md`.
|
||||||
|
|
||||||
| Loop | Default Interval | Description |
|
| Loop | Default Interval | Always-on | Env Var | Description |
|
||||||
|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Renewal check | 1 hour | Check expiring certs, query ARI, create renewal jobs |
|
| Renewal check | 1 hour | Yes | `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | Check expiring certs, query ARI, create renewal jobs |
|
||||||
| Job processor | 30 seconds | Process pending jobs |
|
| Job processor | 30 seconds | Yes | `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | Process pending jobs |
|
||||||
| Agent health check | 2 minutes | Check agent heartbeat staleness |
|
| Job retry | 5 minutes | Yes | `CERTCTL_SCHEDULER_RETRY_INTERVAL` | Retry Failed jobs (I-001) |
|
||||||
| Notification processor | 1 minute | Send queued notifications |
|
| Job timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` (per-state thresholds: `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT`, `CERTCTL_JOB_AWAITING_CSR_TIMEOUT`) | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) |
|
||||||
| Short-lived expiry check | 30 seconds | Mark short-lived certs expired |
|
| Agent health check | 2 minutes | Yes | `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | Check agent heartbeat staleness |
|
||||||
| Network scan | 6 hours | Run network discovery scans |
|
| Notification processor | 1 minute | Yes | `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | Send queued notifications |
|
||||||
| Digest | 24 hours | Send certificate digest email (does not run on startup) |
|
| Notification retry | 2 minutes | Yes | `CERTCTL_NOTIFICATION_RETRY_INTERVAL` | Exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005) |
|
||||||
|
| Short-lived expiry check | 30 seconds | Yes | `CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL` | Mark short-lived certs expired (C-1: pre-C-1 the setter was unwired and this env var had no effect; post-C-1 it's read by `cmd/server/main.go::sched.SetShortLivedExpiryCheckInterval`) |
|
||||||
|
| Network scan | 6 hours | Opt-in | `CERTCTL_NETWORK_SCAN_ENABLED` | Run network discovery scans |
|
||||||
|
| Digest | 24 hours | Opt-in | `CERTCTL_DIGEST_INTERVAL` | Send certificate digest email (does not run on startup) |
|
||||||
|
| Endpoint health | 60 seconds | Opt-in | `CERTCTL_HEALTH_CHECK_INTERVAL` | Continuous TLS health probes (M48) |
|
||||||
|
| Cloud discovery | 6 hours | Opt-in | `CERTCTL_CLOUD_DISCOVERY_INTERVAL` | Cloud secret manager certificate discovery (M50) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1118,7 +1175,7 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
|
|||||||
|
|
||||||
GUI-driven issuer CRUD with AES-256-GCM encrypted config storage in PostgreSQL.
|
GUI-driven issuer CRUD with AES-256-GCM encrypted config storage in PostgreSQL.
|
||||||
|
|
||||||
- Per-type config schema validation for all 9 issuer types
|
- Per-type config schema validation for all issuer types (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`)
|
||||||
- Test connection flow (instantiates throwaway connector, calls `ValidateConfig`)
|
- Test connection flow (instantiates throwaway connector, calls `ValidateConfig`)
|
||||||
- Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart
|
- Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart
|
||||||
- Env var backward compatibility: seeds DB on first boot if no DB config exists
|
- Env var backward compatibility: seeds DB on first boot if no DB config exists
|
||||||
@@ -1147,9 +1204,9 @@ Same pattern as issuer configuration:
|
|||||||
|
|
||||||
## Web Dashboard
|
## Web Dashboard
|
||||||
|
|
||||||
<!-- Source: web/src/main.tsx (25 Route elements, 24 pages), Vite + React 18 + TypeScript + TanStack Query + Recharts -->
|
<!-- Source: web/src/main.tsx (Route elements + page imports), Vite + React 18 + TypeScript + TanStack Query + Recharts. Rebuild page count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`. -->
|
||||||
|
|
||||||
24 pages wired to real API endpoints.
|
The dashboard surface (rebuild count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`) wires every page to real API endpoints.
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
|
|
||||||
@@ -1201,6 +1258,10 @@ Latching state prevents refetch-driven dismissal. `localStorage` dismissal key:
|
|||||||
|
|
||||||
`certctl-cli` — stdlib-only (`flag` + `text/tabwriter`), no Cobra dependency.
|
`certctl-cli` — stdlib-only (`flag` + `text/tabwriter`), no Cobra dependency.
|
||||||
|
|
||||||
|
### Scope (intentionally narrow)
|
||||||
|
|
||||||
|
The CLI focuses on **read-heavy operator triage** (list, get, status, version) and **bulk-action surface** (`certs bulk-revoke`, `import`). It deliberately omits admin CRUD for issuers, targets, owners, teams, agent groups, certificate profiles, renewal policies, policy rules, and notifications — those live in the GUI and the MCP server (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` for the full operator surface). This split is intentional: CLI is the SSH-into-the-prod-host emergency console; GUI is the day-to-day operator console; MCP is the AI/automation surface. Closes audit finding `cat-i-7c8b28936e3d` — pre-this-doc the narrow scope was correct in code but confused readers who scanned `docs/features.md`'s "CLI commands" count and assumed the CLI was incomplete.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -1268,7 +1329,7 @@ certctl-cli certs bulk-revoke --issuer-id iss-letsencrypt --reason caCompromise
|
|||||||
|
|
||||||
Separate standalone binary (`cmd/mcp-server/`) using the official MCP Go SDK (`modelcontextprotocol/go-sdk`). Stdio transport for Claude, Cursor, and similar AI tool integrations.
|
Separate standalone binary (`cmd/mcp-server/`) using the official MCP Go SDK (`modelcontextprotocol/go-sdk`). Stdio transport for Claude, Cursor, and similar AI tool integrations.
|
||||||
|
|
||||||
- 80 MCP tools covering all API endpoints
|
- MCP tools covering all API endpoints (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go`)
|
||||||
- Stateless HTTP proxy — translates MCP tool calls to REST API calls
|
- Stateless HTTP proxy — translates MCP tool calls to REST API calls
|
||||||
- Typed input structs with `jsonschema` struct tags for automatic schema generation
|
- Typed input structs with `jsonschema` struct tags for automatic schema generation
|
||||||
- Binary response support (DER CRL, OCSP)
|
- Binary response support (DER CRL, OCSP)
|
||||||
@@ -1350,7 +1411,9 @@ Config via `values.yaml`. Secrets for API key, database password, SMTP password.
|
|||||||
|
|
||||||
<!-- Source: migrations/ -->
|
<!-- Source: migrations/ -->
|
||||||
|
|
||||||
21 tables across 10 numbered migrations. PostgreSQL 16. `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs.
|
PostgreSQL 16, `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs. The catalog of tables and migrations rebuilds via the commands in the "At a Glance" table at the top of this doc — re-derive at release time rather than reading hardcoded numbers from prose.
|
||||||
|
|
||||||
|
The migration runner reads SQL files from `./migrations/` by default; the path is configurable via `CERTCTL_DATABASE_MIGRATIONS_PATH` for operators running certctl out of a non-standard layout (e.g. a Helm chart that bind-mounts migrations into `/etc/certctl/migrations/`).
|
||||||
|
|
||||||
### Migrations
|
### Migrations
|
||||||
|
|
||||||
@@ -1486,4 +1549,4 @@ Pre-mapped to three compliance frameworks in `docs/`:
|
|||||||
| Deployment model | Pull-only | Server never initiates outbound to agents/targets |
|
| Deployment model | Pull-only | Server never initiates outbound to agents/targets |
|
||||||
| Service decomposition | Facade/delegation | `CertificateService` delegates to `RevocationSvc` + `CAOperationsSvc` |
|
| Service decomposition | Facade/delegation | `CertificateService` delegates to `RevocationSvc` + `CAOperationsSvc` |
|
||||||
| Handler wiring | `HandlerRegistry` struct (20 fields) | Replaced 18-positional-parameter function |
|
| Handler wiring | `HandlerRegistry` struct (20 fields) | Replaced 18-positional-parameter function |
|
||||||
| License | BSL 1.1 | Source-available, converts to Apache 2.0 in March 2033 |
|
| License | BSL 1.1 | Source-available; not for use in competing managed services |
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Legacy EST / SCEP Clients — TLS 1.2 Reverse-Proxy Runbook
|
||||||
|
|
||||||
|
**Audit reference:** Bundle F / M-023. PCI-DSS v4.0 Req 4 §2.2.5; CWE-326.
|
||||||
|
|
||||||
|
certctl's control plane pins `tls.Config.MinVersion = tls.VersionTLS13`
|
||||||
|
(`cmd/server/tls.go:131`). Some embedded EST (RFC 7030) and SCEP (RFC 8894)
|
||||||
|
clients only speak TLS 1.0/1.1/1.2 — those clients cannot complete the
|
||||||
|
handshake against certctl directly. This runbook documents the supported
|
||||||
|
operator pattern: terminate the legacy TLS version at a front-door reverse
|
||||||
|
proxy and pass the request through to certctl over TLS 1.3.
|
||||||
|
|
||||||
|
## Why TLS 1.3 minimum
|
||||||
|
|
||||||
|
certctl's audit posture, the SOC 2 / PCI-DSS / NIST SP 800-57 compliance
|
||||||
|
mappings, and the M-001 PBKDF2 work factor all assume modern transport
|
||||||
|
crypto. TLS 1.2 with the cipher suites still in the wild has known
|
||||||
|
attack surface (BEAST, POODLE, ROBOT, raccoon — all CVE-categorized);
|
||||||
|
allowing TLS 1.2 directly on the certctl listener would invalidate the
|
||||||
|
guarantee that the server-side encryption chain is the strongest the
|
||||||
|
ecosystem currently supports.
|
||||||
|
|
||||||
|
## When this runbook applies
|
||||||
|
|
||||||
|
You need this if **all three** are true:
|
||||||
|
|
||||||
|
1. You operate certctl with EST or SCEP enabled (`CERTCTL_EST_ENABLED=true`
|
||||||
|
or `CERTCTL_SCEP_ENABLED=true`).
|
||||||
|
2. Your enrolling clients are embedded devices (printers, network
|
||||||
|
appliances, IoT boards, legacy MFPs, point-of-sale terminals) whose TLS
|
||||||
|
stack pre-dates 2018 and only speaks TLS 1.2 or older.
|
||||||
|
3. Replacing those clients is not feasible on a 6-month horizon.
|
||||||
|
|
||||||
|
If your enrolling clients are modern (any current Linux/Windows/macOS
|
||||||
|
host, anything Go-based, anything Rust/Python/Node from 2019 onward),
|
||||||
|
they speak TLS 1.3 natively and this runbook is unnecessary — point them
|
||||||
|
straight at certctl on `:8443`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─── TLS 1.2/1.3 ────┐ ┌─── TLS 1.3 ───┐
|
||||||
|
[legacy EST/SCEP client]──>│ nginx / HAProxy │────────>│ certctl :8443 │
|
||||||
|
│ reverse proxy │ │ │
|
||||||
|
└────────────────────┘ └───────────────┘
|
||||||
|
Allowed TLS 1.2 Re-encrypts as TLS 1.3
|
||||||
|
```
|
||||||
|
|
||||||
|
The reverse proxy:
|
||||||
|
|
||||||
|
- Terminates the legacy-version TLS handshake on the public-facing port.
|
||||||
|
- Forwards the request to certctl over TLS 1.3 on a private network.
|
||||||
|
- (For EST mTLS) forwards the client certificate via an
|
||||||
|
`X-SSL-Client-Cert` header that certctl reads only when the connection
|
||||||
|
arrives from a configured-trusted source IP.
|
||||||
|
|
||||||
|
## nginx config
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream certctl_backend {
|
||||||
|
# Private-network address; not reachable from outside the proxy host.
|
||||||
|
server 10.0.0.10:8443;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name est.example.com;
|
||||||
|
|
||||||
|
# Public-facing legacy listener. ssl_protocols includes TLSv1.2 explicitly.
|
||||||
|
# Keep ssl_ciphers conservative — only the strong AEAD suites that
|
||||||
|
# PCI-DSS Req 4 §2.2.5 still allows under TLS 1.2.
|
||||||
|
ssl_certificate /etc/nginx/certs/est.example.com.fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/est.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
# mTLS for EST: optional client cert, verified against the EST CA.
|
||||||
|
ssl_client_certificate /etc/nginx/certs/est-clients-ca.pem;
|
||||||
|
ssl_verify_client optional;
|
||||||
|
|
||||||
|
location ~ ^/\.well-known/(est|pki) {
|
||||||
|
# Forward the client cert (if presented) to certctl over the
|
||||||
|
# private hop. The current certctl implementation IGNORES the
|
||||||
|
# X-SSL-Client-Cert header (header-agnostic by default — see
|
||||||
|
# the certctl-side configuration section below). EST/SCEP
|
||||||
|
# authentication still works correctly because both protocols
|
||||||
|
# carry their own auth (CSR signature for EST, challengePassword
|
||||||
|
# for SCEP) inside the request body.
|
||||||
|
proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# The proxy-to-certctl hop is itself TLS 1.3.
|
||||||
|
proxy_pass https://certctl_backend;
|
||||||
|
proxy_ssl_protocols TLSv1.3;
|
||||||
|
proxy_ssl_verify on;
|
||||||
|
proxy_ssl_trusted_certificate /etc/nginx/certs/certctl-internal-ca.pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SCEP endpoints — same pattern, no client-cert requirement
|
||||||
|
# (SCEP authenticates via challengePassword inside the CSR).
|
||||||
|
location ^~ /scep {
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass https://certctl_backend;
|
||||||
|
proxy_ssl_protocols TLSv1.3;
|
||||||
|
proxy_ssl_verify on;
|
||||||
|
proxy_ssl_trusted_certificate /etc/nginx/certs/certctl-internal-ca.pem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## HAProxy config (alternative)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend est_legacy
|
||||||
|
bind *:443 ssl crt /etc/haproxy/certs/est.example.com.pem alpn h2,http/1.1 \
|
||||||
|
ssl-min-ver TLSv1.2 \
|
||||||
|
ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
|
||||||
|
|
||||||
|
acl is_est_path path_beg /.well-known/est
|
||||||
|
acl is_pki_path path_beg /.well-known/pki
|
||||||
|
acl is_scep_path path_beg /scep
|
||||||
|
use_backend certctl_backend if is_est_path or is_pki_path or is_scep_path
|
||||||
|
default_backend certctl_modern
|
||||||
|
|
||||||
|
backend certctl_backend
|
||||||
|
server certctl 10.0.0.10:8443 ssl verify required \
|
||||||
|
ca-file /etc/haproxy/certs/certctl-internal-ca.pem \
|
||||||
|
ssl-min-ver TLSv1.3
|
||||||
|
http-request set-header X-Forwarded-For %[src]
|
||||||
|
http-request set-header X-Forwarded-Proto https
|
||||||
|
```
|
||||||
|
|
||||||
|
## certctl-side configuration
|
||||||
|
|
||||||
|
The current implementation is **header-agnostic**: certctl ignores any
|
||||||
|
`X-SSL-Client-Cert` / `X-Forwarded-For` headers from the proxy. EST
|
||||||
|
authentication still happens via in-protocol CSR signature + profile
|
||||||
|
policy (RFC 7030 §3.2.3); SCEP authentication still happens via the
|
||||||
|
`challengePassword` attribute embedded in the CSR (RFC 8894 §3.2). Both
|
||||||
|
mechanisms are inside the request body and survive the reverse-proxy
|
||||||
|
hop without server-side header trust.
|
||||||
|
|
||||||
|
**Why this is the correct default:** trusting a proxy-supplied header
|
||||||
|
for client identity opens a header-spoofing attack surface that requires
|
||||||
|
careful design (CIDR allowlist of trusted proxies, fail-closed defaults,
|
||||||
|
explicit operator opt-in). The Bundle F closure of M-023 ships the
|
||||||
|
TLS-bridge guidance as documentation only; a future commit can extend
|
||||||
|
certctl with proxy-header trust if and when an operator demonstrates a
|
||||||
|
deployment shape that requires it. Until that lands, the runbook above
|
||||||
|
is operationally complete: legacy EST and SCEP clients continue to
|
||||||
|
authenticate via their in-protocol mechanisms, and the reverse proxy is
|
||||||
|
purely a TLS-version bridge.
|
||||||
|
|
||||||
|
If your deployment requires proxy-supplied client identity (e.g., the
|
||||||
|
proxy terminates mTLS and you want certctl to record the client-cert
|
||||||
|
subject in the audit trail beyond what the CSR carries), open an issue
|
||||||
|
and a future commit will add a header-trust contract behind two
|
||||||
|
fail-closed env vars: a CIDR allowlist of trusted proxies, plus an
|
||||||
|
explicit opt-in toggle. Both knobs would be required together; setting
|
||||||
|
only one would fail loud at startup. Until that work ships, the
|
||||||
|
header-agnostic default described above is the only supported
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
## PCI-DSS Req 4 §2.2.5 attestation
|
||||||
|
|
||||||
|
PCI-DSS v4.0 §2.2.5 ("strong cryptography for authentication/transmission
|
||||||
|
of cardholder data") considers TLS 1.2 with strong cipher suites
|
||||||
|
acceptable for the foreseeable future, with the explicit caveat that NIST
|
||||||
|
or the PCI Council may shorten the deprecation window if a TLS 1.2
|
||||||
|
weakness is published. The configuration above:
|
||||||
|
|
||||||
|
- Pins TLS 1.2 + TLS 1.3 only (no SSLv3, TLS 1.0, TLS 1.1).
|
||||||
|
- Uses only AEAD cipher suites with forward secrecy (ECDHE-* with GCM or
|
||||||
|
ChaCha20-Poly1305).
|
||||||
|
- Re-encrypts to TLS 1.3 on the proxy-to-certctl hop.
|
||||||
|
|
||||||
|
This is PCI-DSS Req 4 v4.0 compliant. Auditors looking for the
|
||||||
|
attestation should be pointed at this section + the proxy's TLS config.
|
||||||
|
|
||||||
|
## What this runbook does NOT cover
|
||||||
|
|
||||||
|
- **Replacing the legacy clients.** That's the long-term fix; this
|
||||||
|
runbook is the bridge while you're migrating.
|
||||||
|
- **Network segmentation.** The reverse proxy assumes the proxy-to-certctl
|
||||||
|
hop is on a network that an external attacker can't reach. If it's
|
||||||
|
not, you need a deeper architecture review.
|
||||||
|
- **Client-cert revocation.** EST mTLS revocation is the relying party's
|
||||||
|
responsibility. certctl's EST handler accepts the cert; the proxy can
|
||||||
|
enforce CRL/OCSP via `ssl_crl_path` (nginx) or `crl-file` (HAProxy).
|
||||||
|
|
||||||
|
## When TLS 1.2 itself sunsets
|
||||||
|
|
||||||
|
PCI-DSS, NIST, and major browsers will eventually deprecate TLS 1.2.
|
||||||
|
When that happens, this runbook becomes obsolete; the only path forward
|
||||||
|
will be to replace the legacy clients. Subscribe to RSS feeds at the
|
||||||
|
following sources to catch the deprecation announcement before it
|
||||||
|
becomes a compliance failure:
|
||||||
|
|
||||||
|
- https://www.pcisecuritystandards.org/news_events/
|
||||||
|
- https://nvlpubs.nist.gov/nistpubs/SpecialPublications/ (SP 800-52 revisions)
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [`tls.md`](tls.md) — the certctl-internal TLS configuration (HTTPS-only
|
||||||
|
control plane, MinVersion pin)
|
||||||
|
- [`security.md`](security.md) — overall security posture
|
||||||
|
- [`database-tls.md`](database-tls.md) — Postgres TLS opt-in (Bundle B / M-018)
|
||||||
+11
-5
@@ -29,15 +29,18 @@ The binary has zero runtime dependencies beyond the certctl server it connects t
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The MCP server reads two environment variables:
|
The MCP server reads three environment variables:
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| 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_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.
|
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
|
## 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):
|
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": {
|
"certctl": {
|
||||||
"command": "/path/to/certctl-mcp",
|
"command": "/path/to/certctl-mcp",
|
||||||
"env": {
|
"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"
|
"CERTCTL_API_KEY": "your-api-key-here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +71,8 @@ In Cursor, go to Settings → MCP Servers and add:
|
|||||||
"certctl": {
|
"certctl": {
|
||||||
"command": "/path/to/certctl-mcp",
|
"command": "/path/to/certctl-mcp",
|
||||||
"env": {
|
"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"
|
"CERTCTL_API_KEY": "your-api-key-here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +89,8 @@ Add certctl as an MCP server in your project's `.mcp.json`:
|
|||||||
"certctl": {
|
"certctl": {
|
||||||
"command": "/path/to/certctl-mcp",
|
"command": "/path/to/certctl-mcp",
|
||||||
"env": {
|
"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"
|
"CERTCTL_API_KEY": "your-api-key-here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ cd certctl/deploy
|
|||||||
docker compose up -d
|
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
|
### 2. Deploy Agents
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Option A: Docker Compose (quickest for evaluation)
|
|||||||
```bash
|
```bash
|
||||||
cd /opt/certctl
|
cd /opt/certctl
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
# Dashboard & API: http://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)
|
# 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
|
# Create config
|
||||||
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
|
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
|
||||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||||
CERTCTL_SERVER_URL=http://certctl-control-plane.example.com:8443
|
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_API_KEY=your-api-key-here
|
||||||
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
|
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
|
||||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||||
|
|||||||
+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:
|
The spec declares a `bearerAuth` security scheme applied globally. All endpoints under `/api/v1/` require a Bearer token by default:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer your-api-key" \
|
# The default compose stack uses a self-signed cert; pin with --cacert
|
||||||
http://localhost:8443/api/v1/certificates
|
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.
|
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`
|
1. Open Postman → Import → File → select `api/openapi.yaml`
|
||||||
2. Postman creates a collection with all 78 documented operations organized by tag
|
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
|
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
|
## Key Schemas
|
||||||
|
|
||||||
@@ -176,8 +179,10 @@ Use the spec to generate contract tests that verify the API matches the spec:
|
|||||||
```bash
|
```bash
|
||||||
# Using schemathesis for fuzz testing against the spec
|
# Using schemathesis for fuzz testing against the spec
|
||||||
pip install schemathesis
|
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 \
|
schemathesis run api/openapi.yaml \
|
||||||
--base-url http://localhost:8443 \
|
--base-url https://localhost:8443 \
|
||||||
--header "Authorization: Bearer your-api-key"
|
--header "Authorization: Bearer your-api-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+185
-29
@@ -6,32 +6,68 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Test Suite Health (regenerate via `make qa-stats`)
|
||||||
|
|
||||||
|
> Snapshot at HEAD. Re-run `make qa-stats` to refresh; CI's QA-doc drift guards (`.github/workflows/ci.yml`) catch out-of-date Part / cert / issuer counts on every PR. **Last regenerated: 2026-04-27 (Bundle P).**
|
||||||
|
|
||||||
|
| Metric | Value | Target | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Backend test files | 221 | n/a | ℹ |
|
||||||
|
| Backend `Test*` functions | 2,454 | n/a | ℹ |
|
||||||
|
| Backend `t.Run` subtests | 778 | n/a | ℹ |
|
||||||
|
| Frontend test files | 38 | n/a | ℹ |
|
||||||
|
| Fuzz targets | 11 | ≥10 (one per hand-rolled parser) | ✓ |
|
||||||
|
| `t.Skip` sites | 60 | each carries valid rationale (Bundle O audit) | ✓ |
|
||||||
|
| `qa_test.go` Part_* subtests | 53 | tracks `testing-guide.md` Parts (3 `## Part 15-17` covered indirectly via Parts 42–46) | ✓ |
|
||||||
|
| `testing-guide.md` Parts | 56 | n/a | ℹ |
|
||||||
|
| Existential cluster line cov (post-Bundle-J + L.B + Bundle 0.7) | acme 55.6%, stepca 90.4%, local-issuer ≥86%, crypto ≥85% | ≥95% | △ ACME below; tracked in `coverage-matrix.md` |
|
||||||
|
| Mutation kill rate (Existential) | unmeasured (operator-runnable per Strengthening #5) | ≥90% | ⚠ |
|
||||||
|
| Race detector clean (`-count=10`) | partial (`-count=3` clean per Phase 0) | 0 races | ⚠ |
|
||||||
|
|
||||||
## What Is This File?
|
## 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.
|
`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:
|
It covers **49 of 56 Parts** of the testing guide as automation; the remaining 7 are
|
||||||
|
either manual-only by design or pending QA-suite coverage:
|
||||||
|
|
||||||
- **~164 automated subtests** — API calls, database queries, source file checks, performance benchmarks
|
- **49 `Part_*` automation wrappers**, **~159 leaf subtests** — API calls, database queries, source file checks, performance benchmarks
|
||||||
- **11 skipped Parts** — with documented reasons (external CAs, Windows, browser-only, etc.)
|
- **11 fully skipped Parts** — with documented reasons (external CAs, Windows, browser-only, etc.) — see "What This Test Does NOT Cover" below
|
||||||
- **Remaining ~282 manual tests** — GUI flows, scheduler timing, Docker log inspection — must be done by a human following `docs/testing-guide.md`
|
- **4 Parts NOT YET AUTOMATED** — Parts 23 (S/MIME & EKU), 24 (OCSP/CRL), 55 (Agent Soft-Retirement), 56 (Notification Retry & Dead-Letter) — must be tested manually per `docs/testing-guide.md` until QA-suite automation lands
|
||||||
|
- **Manual-only flows** in addition: GUI flows, scheduler timing, Docker log inspection — must be done by a human following `docs/testing-guide.md`
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────┐ ┌──────────────────────────┐
|
┌────────────────────────┐ ┌─────────────────────────────────┐
|
||||||
│ qa_test.go │────▶│ certctl demo stack │
|
│ qa_test.go │────▶│ certctl demo stack │
|
||||||
│ (//go:build qa) │ │ docker-compose.yml + │
|
│ (//go:build qa) │ │ docker-compose.yml + │
|
||||||
│ │ │ docker-compose.demo.yml │
|
│ │ │ docker-compose.demo.yml │
|
||||||
│ TestQA(t *testing.T) │ │ │
|
│ TestQA(t *testing.T) │ │ │
|
||||||
│ ├─ Part01_Infra │ │ ┌─ certctl-server :8443 │
|
│ ├─ Part01_Infra │ │ ┌─ certctl-server :8443 │
|
||||||
│ ├─ Part02_Auth │ │ ├─ postgres :5432 │
|
│ ├─ Part02_Auth │ │ ├─ postgres :5432 │
|
||||||
│ ├─ Part03_CertCRUD │ │ └─ certctl-agent │
|
│ ├─ Part03_CertCRUD │ │ └─ certctl-agent (×N) │
|
||||||
│ ├─ ... │ └──────────────────────────┘
|
│ ├─ ... │ │ ↑ seed_demo.sql provisions │
|
||||||
│ └─ Part52_HelmChart │
|
│ └─ Part52_HelmChart │ │ 12 agent rows (1 active, │
|
||||||
└────────────────────────┘
|
└────────────────────────┘ │ 2 retired, 9 reserved / │
|
||||||
|
│ sentinel) for the soft- │
|
||||||
|
│ retire / FSM coverage │
|
||||||
|
│ Parts 55–56 exercise. │
|
||||||
|
└─────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Multi-agent demo stack (Bundle Q / L-004 closure).** The demo
|
||||||
|
> stack runs a single live `certctl-agent` container by default but
|
||||||
|
> the database is seeded with 12 agent rows (`migrations/seed_demo.sql`,
|
||||||
|
> grep `mc-* | ag-*` IDs). The "(×N)" notation reflects the seed-data
|
||||||
|
> reality: Parts 04 (Agents Listing), 05 (Agent Heartbeats), 55
|
||||||
|
> (Agent Soft-Retirement), and FSM coverage tables in
|
||||||
|
> `coverage-audit-2026-04-27/tables/fsm-coverage.md` exercise the full
|
||||||
|
> multi-agent population, not the one live container. Operators
|
||||||
|
> running the QA suite in a parallel-agent topology should set
|
||||||
|
> `AGENT_COUNT=N` in compose-override and re-derive the seed counts
|
||||||
|
> via `make qa-stats`.
|
||||||
|
|
||||||
Key design choices:
|
Key design choices:
|
||||||
|
|
||||||
- **Build tag:** `//go:build qa` — never runs during `go test ./...` or CI. Only runs when explicitly requested.
|
- **Build tag:** `//go:build qa` — never runs during `go test ./...` or CI. Only runs when explicitly requested.
|
||||||
@@ -85,10 +121,12 @@ go test -tags qa -v -timeout 10m ./...
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CERTCTL_QA_SERVER_URL` | `http://localhost:8443` | certctl server URL |
|
| `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_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_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_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
|
## Part-by-Part Coverage Map
|
||||||
|
|
||||||
@@ -116,6 +154,8 @@ This table shows what each Part tests and what's left for manual verification.
|
|||||||
| 20 | Post-Deployment Verification | 1 | 404 on nonexistent job verification | TLS probing, fingerprint comparison |
|
| 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 |
|
| 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 |
|
| 22 | Certificate Export | 3 | PEM export, PKCS#12 export, 404 on nonexistent | Download mode, file content validation |
|
||||||
|
| 23 | S/MIME & EKU Support | 0 (NOT AUTOMATED) | — | S/MIME profile creation; EKU enforcement on issuance; SMIMECapabilities extension presence in issued cert; rejection of profile-violating EKU on CSR. Test manually per `docs/testing-guide.md::Part 23` |
|
||||||
|
| 24 | OCSP Responder & DER CRL | 0 (NOT AUTOMATED) | — | OCSP request/response (RFC 6960), DER CRL generation, status (Good/Revoked/Unknown), Must-Staple coordination. Test manually per `docs/testing-guide.md::Part 24` |
|
||||||
| 25 | Certificate Discovery | 5 | List discovered, summary, list scan targets, create target, invalid CIDR 400 | Agent filesystem scan, claim/dismiss workflow |
|
| 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 |
|
| 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) |
|
| 27 | Request Body Size Limits | 1 | 2MB body rejected (413/400) | Exact limit boundary (1MB) |
|
||||||
@@ -145,8 +185,28 @@ This table shows what each Part tests and what's left for manual verification.
|
|||||||
| 52 | Helm Chart | 5 | Chart.yaml, values.yaml, 4 templates exist, securityContext, health probes | `helm template` rendering, `helm install` |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
| 55 | Agent Soft-Retirement (I-004) | 0 (NOT AUTOMATED) | — | Soft-retire vs hard-retire; force flag; reason capture; foreign-key cascade behavior on retired-agent cert ownership; reactivation. Test manually per `docs/testing-guide.md::Part 55` |
|
||||||
|
| 56 | Notification Retry & Dead-Letter Queue (I-005) | 0 (NOT AUTOMATED) | — | Retry loop with exponential backoff, dead-letter transition after N retries, requeue endpoint (`POST /api/v1/notifications/{id}/requeue`), idempotency on retry. Test manually per `docs/testing-guide.md::Part 56` |
|
||||||
|
|
||||||
**Totals:** ~164 automated subtests, 11 fully skipped Parts, ~282 manual tests remaining.
|
**Totals (verified 2026-04-27):** 49 `Part_*` automation wrappers, ~159 leaf subtests, 11 fully
|
||||||
|
skipped Parts, 4 Parts not yet automated (23, 24, 55, 56), and an unspecified count of manual-only
|
||||||
|
flows (GUI, scheduler timing, Docker log inspection). Run `grep -cE '^## Part [0-9]+:' docs/testing-guide.md`
|
||||||
|
and `grep -cE 't\.Run\("Part[0-9]+_' deploy/test/qa_test.go` to re-verify.
|
||||||
|
|
||||||
|
## Coverage by Risk Class
|
||||||
|
|
||||||
|
A buyer's QA lead reading this doc wants "where are the existential bugs caught?" — Bundle P / Strengthening #1 surfaces that view directly. The table below classifies each Part by risk class so reviewers can answer the existential-coverage question in one glance.
|
||||||
|
|
||||||
|
| Risk class | Description | Parts in scope | Automation status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Existential** (Critical paths — bugs would compromise CA, leak keys, mis-issue, bypass revocation) | Crypto, PKCS#7, local-issuer, OCSP/CRL, agent keygen, CSR validation | 5 (Revocation), 21 (EST), 23 (S/MIME EKU), 24 (OCSP/CRL), 47 (Digest with cert content), 53 (K8s Secrets), 54 (AWS PCA) | 5/7 automated; Parts 23 + 24 pending (Bundle I Skip stubs in `qa_test.go`; manual playbook in `testing-guide.md`) |
|
||||||
|
| **High** (FSM corruption, credential leak, authn/z weakening) | Renewal, jobs, agents, issuers, deployment, scheduler | 4, 7, 8, 9, 18, 19, 20, 22, 25, 28, 29, 32, 33, 48, 49, 55, 56 | 14/17 automated; CLI / MCP / scheduler-loop are inherently SKIP (require compiled binaries / Docker logs); Parts 55 + 56 pending |
|
||||||
|
| **Medium** (Operational pain or silent data drift) | Targets, notifiers, observability, error handling, performance, regression | 14, 15-17, 30, 31, 38, 39, 40, 41, 42, 43, 44, 45, 46 | 14/14 automated (15-17 indirect via Parts 42–46) |
|
||||||
|
| **Low** (Hygiene) | Documentation, docs verification | 40 (Documentation), 50 (Onboarding) | 2/2 automated |
|
||||||
|
| **Frontend** (XSS, render correctness, mutation contracts) | GUI testing | 35, 36-37 | 0/3 automated in this suite (Vitest covers separately under `web/`); this doc punts to manual + Vitest |
|
||||||
|
| **Compliance** (PCI / SOC2 / HIPAA-relevant) | Audit trail, body-size limits, request limits, Helm chart deploy posture | 27, 32, 51, 52 | 4/4 automated |
|
||||||
|
|
||||||
|
This is the table acquisition reviewers screenshot for their report. When a new Part lands in `testing-guide.md`, classify it here; the QA-doc Part-count drift guard (`.github/workflows/ci.yml::QA-doc Part-count drift guard`) catches the count mismatch.
|
||||||
|
|
||||||
## Test Categories
|
## Test Categories
|
||||||
|
|
||||||
@@ -180,6 +240,17 @@ Timed API requests with threshold assertions:
|
|||||||
|
|
||||||
These gaps must be filled by manual testing per `docs/testing-guide.md`:
|
These gaps must be filled by manual testing per `docs/testing-guide.md`:
|
||||||
|
|
||||||
|
### Not Yet Automated (Parts 23, 24, 55, 56)
|
||||||
|
|
||||||
|
These Parts are documented in `docs/testing-guide.md` but have no `Part_*` automation
|
||||||
|
in `qa_test.go` yet. They are operator-runnable from the manual playbook; QA-suite
|
||||||
|
automation should land before the next acquisition-grade release.
|
||||||
|
|
||||||
|
- **Part 23: S/MIME & EKU Support** — profile-driven EKU enforcement; SMIMECapabilities extension
|
||||||
|
- **Part 24: OCSP Responder & DER CRL** — OCSP request/response correctness, CRL generation, Must-Staple coordination
|
||||||
|
- **Part 55: Agent Soft-Retirement (I-004)** — soft vs hard retire, FK cascade, reactivation
|
||||||
|
- **Part 56: Notification Retry & Dead-Letter Queue (I-005)** — retry semantics, dead-letter transition, requeue
|
||||||
|
|
||||||
### External CA Integrations (Parts 10–13)
|
### External CA Integrations (Parts 10–13)
|
||||||
- **Sub-CA mode** — requires CA cert+key files on disk
|
- **Sub-CA mode** — requires CA cert+key files on disk
|
||||||
- **ACME ARI** — requires a CA that supports RFC 9773 Renewal Information
|
- **ACME ARI** — requires a CA that supports RFC 9773 Renewal Information
|
||||||
@@ -219,7 +290,7 @@ Both files live in `deploy/test/` in the same Go package (`integration_test`):
|
|||||||
| **Build tag** | `//go:build qa` | `//go:build integration` |
|
| **Build tag** | `//go:build qa` | `//go:build integration` |
|
||||||
| **Target stack** | Demo (`docker-compose.yml` + `docker-compose.demo.yml`) | Test (`docker-compose.test.yml`) |
|
| **Target stack** | Demo (`docker-compose.yml` + `docker-compose.demo.yml`) | Test (`docker-compose.test.yml`) |
|
||||||
| **Port** | 8443 | Different (test stack config) |
|
| **Port** | 8443 | Different (test stack config) |
|
||||||
| **Seed data** | `seed_demo.sql` (32 certs, 8 agents, realistic history) | Minimal (created by tests) |
|
| **Seed data** | `seed_demo.sql` (32 certs, 12 agents, 13 issuers, 8 targets, realistic history) | Minimal (created by tests) |
|
||||||
| **CA backends** | Local CA only (demo mode) | Pebble ACME, step-ca, NGINX |
|
| **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 |
|
| **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 |
|
| **Run frequency** | Before each release tag | CI on every PR |
|
||||||
@@ -230,21 +301,54 @@ They are complementary. Integration tests prove the machinery works. QA tests pr
|
|||||||
|
|
||||||
The QA tests depend on `migrations/seed_demo.sql`. Key IDs used:
|
The QA tests depend on `migrations/seed_demo.sql`. Key IDs used:
|
||||||
|
|
||||||
### Certificates (32 total)
|
### Certificates (32 total in `managed_certificates`)
|
||||||
`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)
|
The full canonical list is generated by:
|
||||||
`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)
|
```
|
||||||
|
sed -n '/^INSERT INTO managed_certificates/,/^;/p' migrations/seed_demo.sql \
|
||||||
|
| grep -oE "^\s*\('mc-[a-z0-9_-]+" | sed -E "s/^\s*\('//" | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
### Issuers (9 total)
|
Hand-listing is unsustainable as the seed grows; tests reference IDs by lookup, not by enumeration.
|
||||||
`iss-local`, `iss-acme-le`, `iss-stepca`, `iss-acme-zs`, `iss-openssl`, `iss-vault`, `iss-digicert`, `iss-sectigo`, `iss-googlecas`
|
Sample IDs: `mc-api-prod`, `mc-web-prod`, `mc-pay-prod`, `mc-compromised`, `mc-smime-bob`, `mc-edge-eu`, `mc-k8s-ingress`, `mc-wildcard-prod`. See `migrations/seed_demo.sql:147` onward.
|
||||||
|
|
||||||
### Targets (8 total)
|
### Agents (12 total in `agents` table)
|
||||||
|
|
||||||
|
8 named workload agents + 1 server-side sentinel + 3 cloud-discovery sentinels:
|
||||||
|
|
||||||
|
- **Workload agents:** `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-side sentinel:** `server-scanner`
|
||||||
|
- **Cloud-discovery sentinels:** `cloud-aws-sm`, `cloud-azure-kv`, `cloud-gcp-sm`
|
||||||
|
|
||||||
|
Full list via:
|
||||||
|
```
|
||||||
|
sed -n '/^INSERT INTO agents/,/^;/p' migrations/seed_demo.sql \
|
||||||
|
| grep -oE "^\s*\('[a-z][a-z0-9_-]+" | sed -E "s/^\s*\('//"
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `agent_groups` table also contains entries with `ag-*` IDs — `ag-linux-prod`, `ag-windows`, `ag-datacenter-a`, `ag-arm64`, `ag-manual` — but those are *group* IDs, not agents. Don't confuse the two.)
|
||||||
|
|
||||||
|
### Issuers (13 total)
|
||||||
|
|
||||||
|
`iss-local`, `iss-acme-le`, `iss-stepca`, `iss-acme-zs`, `iss-openssl`, `iss-vault`, `iss-digicert`, `iss-sectigo`, `iss-googlecas`, `iss-awsacmpca`, `iss-entrust`, `iss-globalsign`, `iss-ejbca`.
|
||||||
|
|
||||||
|
Full list via:
|
||||||
|
```
|
||||||
|
sed -n '/^INSERT INTO issuers/,/^;/p' migrations/seed_demo.sql \
|
||||||
|
| grep -oE "^\s*\('iss-[a-z0-9_-]+" | sed -E "s/^\s*\('//"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Targets (8 total in `deployment_targets`)
|
||||||
`tgt-nginx-prod`, `tgt-nginx-staging`, `tgt-haproxy-prod`, `tgt-apache-prod`, `tgt-iis-prod`, `tgt-traefik-prod`, `tgt-caddy-prod`, `tgt-nginx-data`
|
`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)
|
### Network Scan Targets (4 total in `network_scan_targets`)
|
||||||
`nst-dc1-web`, `nst-dc2-apps`, `nst-dmz`, `nst-edge`
|
`nst-dc1-web`, `nst-dc2-apps`, `nst-dmz`, `nst-edge`
|
||||||
|
|
||||||
|
**Maintenance note:** when adding new seed rows, also update this section, OR remove the
|
||||||
|
per-table counts and rely on the `sed | grep` commands so the doc stops drifting on every
|
||||||
|
seed-data change. A CI guard that fails when the doc count diverges from the seed file is
|
||||||
|
proposed in `coverage-audit-2026-04-27/tables/qa-doc-strengthening.md` (Strengthening #6).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Server unreachable" on startup
|
### "Server unreachable" on startup
|
||||||
@@ -256,8 +360,8 @@ docker compose -f docker-compose.yml -f docker-compose.demo.yml ps
|
|||||||
# Check server logs
|
# Check server logs
|
||||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml logs certctl-server
|
docker compose -f docker-compose.yml -f docker-compose.demo.yml logs certctl-server
|
||||||
|
|
||||||
# Check if the port is exposed
|
# Check if the port is exposed (self-signed cert — pin CA bundle)
|
||||||
curl -s http://localhost:8443/health
|
curl --cacert ./deploy/test/certs/ca.crt -s https://localhost:8443/health
|
||||||
```
|
```
|
||||||
|
|
||||||
### "connect to QA DB" failure
|
### "connect to QA DB" failure
|
||||||
@@ -278,6 +382,56 @@ The `fileExists` and `fileContains` helpers read from `CERTCTL_QA_REPO_DIR` (def
|
|||||||
CERTCTL_QA_REPO_DIR=/absolute/path/to/certctl go test -tags qa -v ./...
|
CERTCTL_QA_REPO_DIR=/absolute/path/to/certctl go test -tags qa -v ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Release Day Sign-Off Matrix
|
||||||
|
|
||||||
|
Before tagging a release, the QA-on-call engineer signs off on each row. This matrix replaces the previous ad-hoc release checklist and ties test execution directly to release approval. Acquisition-grade releases have this kind of matrix; the doc previously didn't.
|
||||||
|
|
||||||
|
| Sign-off | Evidence | Owner | Result | Date |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `make verify` clean on master | CI run URL | Eng-on-call | ☐ | |
|
||||||
|
| `go test -tags qa ./deploy/test/...` ≥ 95% pass rate (skips counted as pass) | Test output | QA-on-call | ☐ | |
|
||||||
|
| `go test -race -count=10 ./internal/...` 0 races | `tool-output/race-x10.txt` | QA-on-call | ☐ | |
|
||||||
|
| Coverage ≥ thresholds in `ci.yml` (service / handler / crypto / local-issuer / acme / stepca / mcp) | `tool-output/cover-summary.txt` | QA-on-call | ☐ | |
|
||||||
|
| Helm chart `helm lint && helm template` clean | `tool-output/helm.txt` | DevOps-on-call | ☐ | |
|
||||||
|
| All `t.Skip` sites have current rationales (see Bundle O audit; CI guard catches new orphans) | `make qa-stats` t.Skip count | QA-on-call | ☐ | |
|
||||||
|
| Frontend: Vitest run clean; per-page coverage ≥ 70% | `web/tool-output/vitest.txt` | Frontend-on-call | ☐ | |
|
||||||
|
| Manual Parts 23, 24, 55, 56 executed (or explicit defer with rationale) | This sheet | QA-on-call | ☐ | |
|
||||||
|
| Demo stack `docker compose up -d --build` smoke (`/health` 200, `/ready` 200) | curl receipt | QA-on-call | ☐ | |
|
||||||
|
| `govulncheck ./...` clean (or deferred-call advisories tracked in `gap-backlog`) | `tool-output/govulncheck.json` | Security-on-call | ☐ | |
|
||||||
|
| QA-doc drift guards green (Part-count + cert-count) | CI run URL | QA-on-call | ☐ | |
|
||||||
|
| FSM transition coverage tables (`coverage-audit-2026-04-27/tables/fsm-coverage.md`) — Existential FSMs ≥80% legal + 100% illegal | This sheet | QA-on-call | ☐ | |
|
||||||
|
|
||||||
|
**Sign-off owner:** ______________________ **Date:** ______ **Tag:** v__.__.__
|
||||||
|
|
||||||
|
## Mutation Testing Targets & Kill Rate
|
||||||
|
|
||||||
|
Mutation testing exposes which assertions are actually load-bearing — tests can pass against broken code if mutations survive, which is a coverage trap. The audit's Phase 0 attempted to run `go-mutesting` on the Existential cluster but was blocked by a Go 1.25 / arm64 incompatibility in `osutil@v1.6.1` (uses `syscall.Dup2` which is undefined on linux/arm64). The operator-runnable workaround uses a fork that targets `unix.Dup3` instead.
|
||||||
|
|
||||||
|
| Package | Risk class | Target kill rate | Last measured | Tool |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `internal/crypto` | Existential | ≥90% | unmeasured (sandbox-blocked, operator-runnable) | go-mutesting |
|
||||||
|
| `internal/pkcs7` | Existential | ≥90% | unmeasured | go-mutesting |
|
||||||
|
| `internal/connector/issuer/local` | Existential | ≥90% | unmeasured | go-mutesting |
|
||||||
|
| `internal/connector/issuer/acme` | Existential | ≥80% (catch-up; failure-mode coverage 55.6% per Bundle J) | unmeasured | go-mutesting |
|
||||||
|
| `internal/connector/issuer/stepca` | Existential | ≥85% (post-Bundle-L.B coverage at 90.4%) | unmeasured | go-mutesting |
|
||||||
|
| `internal/api/middleware` | High | ≥80% | unmeasured | go-mutesting |
|
||||||
|
| `internal/validation` | Existential (CWE-78 / CWE-113 boundary) | ≥90% | unmeasured | go-mutesting |
|
||||||
|
| `web/src/utils/safeHtml.ts` | Frontend (XSS gate) | ≥90% | unmeasured | Stryker |
|
||||||
|
|
||||||
|
### Operator command (per package)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use the avito-tech fork that supports linux/arm64 + Go 1.25.
|
||||||
|
go install github.com/avito-tech/go-mutesting/cmd/go-mutesting@latest
|
||||||
|
|
||||||
|
mkdir -p tool-output
|
||||||
|
$(go env GOPATH)/bin/go-mutesting --debug ./internal/crypto/... \
|
||||||
|
> tool-output/mutation-crypto.txt 2>&1
|
||||||
|
grep -oE 'mutation score is [0-9.]+' tool-output/mutation-crypto.txt | tail -1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance:** ≥80% (Existential) / ≥70% (High). Anything below is a Medium finding; triage entries go in `coverage-audit-2026-04-27/gap-backlog.md`. This subsection moves mutation testing from "future work" to "documented release gate."
|
||||||
|
|
||||||
## Adding New Tests
|
## Adding New Tests
|
||||||
|
|
||||||
When a new feature ships:
|
When a new feature ships:
|
||||||
@@ -291,5 +445,7 @@ When a new feature ships:
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
- **v1.0** (April 2026) — Initial release covering all 52 Parts of testing-guide.md v2.1. Replaces `qa-smoke-test.sh`.
|
- **v1.3** (April 2026, post-Bundle-P) — QA Doc Strengthening shipped. New top-of-doc Test Suite Health dashboard (regenerated via `make qa-stats`). New Coverage by Risk Class table after the Coverage Map. New Release Day Sign-Off Matrix and Mutation Testing Targets sections. CI seed-count + Part-count drift guards land in `.github/workflows/ci.yml` so future doc drift fails CI. Bundle P closes M-007 / M-010 / M-011 / M-012 (structural strengthening) + M-008 (Mutation Testing Targets).
|
||||||
|
- **v1.2** (April 2026, post-coverage-audit) — Documented Parts 55–56 (I-004 Agent Soft-Retirement, I-005 Notification Retry & Dead-Letter) and surfaced Parts 23–24 (S/MIME & EKU; OCSP/CRL) as not-yet-automated. 56 Parts total in `testing-guide.md`; 49 live `Part_*` automation wrappers in `qa_test.go` + 4 new `Skip` stubs for Parts 23/24/55/56 = 53 wrappers (Parts 15–17 remain covered by source-checks in Parts 42–46). Reconciled seed-data section to actual `seed_demo.sql` counts (12 agents, 13 issuers; certs were already accurate at 32). Bundle I of the 2026-04-27 coverage-audit closure plan.
|
||||||
- **v1.1** (April 2026) — Added Parts 53–54 (M47: Kubernetes Secrets target + AWS ACM PCA issuer). 54 Parts total, ~164 automated subtests.
|
- **v1.1** (April 2026) — Added Parts 53–54 (M47: Kubernetes Secrets target + AWS ACM PCA issuer). 54 Parts total, ~164 automated subtests.
|
||||||
|
- **v1.0** (April 2026) — Initial release covering all 52 Parts of testing-guide.md v2.1. Replaces `qa-smoke-test.sh`.
|
||||||
|
|||||||
+64
-47
@@ -60,6 +60,8 @@ cp deploy/.env.example deploy/.env
|
|||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
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
|
### Docker Compose Environments
|
||||||
|
|
||||||
The `deploy/` directory contains four compose files for different use cases:
|
The `deploy/` directory contains four compose files for different use cases:
|
||||||
@@ -105,16 +107,24 @@ certctl-server Up (healthy)
|
|||||||
certctl-agent Up
|
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
|
```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
|
```json
|
||||||
{"status":"healthy"}
|
{"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 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.
|
> **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.
|
||||||
>
|
>
|
||||||
@@ -154,62 +164,64 @@ Everything you see in the dashboard is backed by the REST API. All endpoints liv
|
|||||||
|
|
||||||
### Core operations
|
### 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
|
```bash
|
||||||
# List all certificates
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# View policies and violations
|
||||||
curl -s http://localhost:8443/api/v1/policies | jq .
|
curl --cacert "$CA" -s https://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/pr-require-owner/violations | jq .
|
||||||
|
|
||||||
# Notifications
|
# 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
|
# Profiles and agent groups
|
||||||
curl -s http://localhost:8443/api/v1/profiles | jq .
|
curl --cacert "$CA" -s https://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/agent-groups | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sorting, filtering, and pagination
|
### Sorting, filtering, and pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sort by expiration date (ascending)
|
# 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 -)
|
# 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)
|
# Time-range filters (RFC3339)
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
|
curl --cacert "$CA" -s "https://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?created_after=2026-03-01T00:00:00Z" | jq .
|
||||||
|
|
||||||
# Sparse fields — request only what you need
|
# 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
|
# 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 --cacert "$CA" -s "https://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?cursor=<next_cursor_value>&page_size=5" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commonName`, `name`, `status`, `environment`.
|
Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commonName`, `name`, `status`, `environment`.
|
||||||
@@ -218,22 +230,22 @@ Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commo
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dashboard summary
|
# 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
|
# 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)
|
# 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)
|
# 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
|
# 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)
|
# 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
|
## Create Your First Certificate
|
||||||
@@ -241,7 +253,7 @@ curl -s http://localhost:8443/api/v1/metrics/prometheus
|
|||||||
Create a certificate record that certctl will track, renew, and deploy automatically.
|
Create a certificate record that certctl will track, renew, and deploy automatically.
|
||||||
|
|
||||||
```bash
|
```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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "My First Certificate",
|
"name": "My First Certificate",
|
||||||
@@ -264,31 +276,34 @@ CERT_ID="<paste the id from the response>"
|
|||||||
|
|
||||||
Trigger renewal:
|
Trigger renewal:
|
||||||
```bash
|
```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:
|
Check the result:
|
||||||
```bash
|
```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
|
### Revoke a certificate
|
||||||
|
|
||||||
When a private key is compromised or a service is decommissioned:
|
When a private key is compromised or a service is decommissioned:
|
||||||
|
|
||||||
```bash
|
```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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"reason": "superseded"}' | jq .
|
-d '{"reason": "superseded"}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`.
|
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
|
```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
|
### Interactive approval workflow
|
||||||
@@ -297,15 +312,15 @@ For high-value certificates where you want human oversight. The demo includes 2
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List jobs awaiting approval (demo includes 2)
|
# 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
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"reason": "Approved for production deployment"}' | jq .
|
-d '{"reason": "Approved for production deployment"}' | jq .
|
||||||
|
|
||||||
# Reject a pending job
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"reason": "Key type does not meet compliance requirements"}' | jq .
|
-d '{"reason": "Key type does not meet compliance requirements"}' | jq .
|
||||||
```
|
```
|
||||||
@@ -331,7 +346,7 @@ export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/var/lib/certs"
|
|||||||
export CERTCTL_NETWORK_SCAN_ENABLED=true
|
export CERTCTL_NETWORK_SCAN_ENABLED=true
|
||||||
|
|
||||||
# Create a scan target
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "Internal Network",
|
"name": "Internal Network",
|
||||||
@@ -343,20 +358,20 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
|||||||
}' | jq .
|
}' | jq .
|
||||||
|
|
||||||
# Trigger an immediate scan
|
# 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
|
### Triage discovered certificates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List discovered certs
|
# 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
|
# 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)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
||||||
```
|
```
|
||||||
@@ -366,8 +381,9 @@ curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_
|
|||||||
```bash
|
```bash
|
||||||
cd cmd/cli && go build -o certctl-cli .
|
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_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 list # List certificates
|
||||||
./certctl-cli certs get mc-api-prod # Certificate details
|
./certctl-cli certs get mc-api-prod # Certificate details
|
||||||
@@ -400,10 +416,10 @@ export CERTCTL_DIGEST_RECIPIENTS=ops@example.com,security@example.com
|
|||||||
|
|
||||||
Preview the digest HTML before enabling scheduled delivery:
|
Preview the digest HTML before enabling scheduled delivery:
|
||||||
```bash
|
```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)
|
# 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).
|
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).
|
||||||
@@ -413,8 +429,9 @@ If no recipients are configured (`CERTCTL_DIGEST_RECIPIENTS` empty), the digest
|
|||||||
```bash
|
```bash
|
||||||
cd cmd/mcp-server && go build -o mcp-server .
|
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_API_KEY="test-key-123"
|
||||||
|
export CERTCTL_SERVER_CA_BUNDLE_PATH="$CA" # MCP is env-vars-only; no CLI flags
|
||||||
|
|
||||||
./mcp-server
|
./mcp-server
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# certctl Security Posture & Operator Guidance
|
||||||
|
|
||||||
|
This document collects the operator-facing security guidance that the source
|
||||||
|
code's per-finding comment blocks reference. Each section names the audit
|
||||||
|
finding it closes, the threat model, and the operator action required (if
|
||||||
|
any).
|
||||||
|
|
||||||
|
## OCSP responder availability
|
||||||
|
|
||||||
|
**Audit reference:** Bundle C / M-020. CWE-770 (uncontrolled resource
|
||||||
|
consumption); RFC 6960 (OCSP); RFC 7633 (Must-Staple).
|
||||||
|
|
||||||
|
certctl ships an OCSP responder at `/.well-known/pki/ocsp/{issuer_id}/{serial}`
|
||||||
|
that signs a fresh response per request. Pre-Bundle-C the unauth handler
|
||||||
|
chain had no rate limit, so an attacker could DoS the responder and force
|
||||||
|
fail-open relying parties to accept revoked certificates as valid. Bundle C
|
||||||
|
adds the same per-key rate limiter to the unauth chain that the authenticated
|
||||||
|
chain has used since Bundle B. Per-IP keying applies because OCSP traffic is
|
||||||
|
unauthenticated.
|
||||||
|
|
||||||
|
The rate limiter alone does not solve the underlying revocation-bypass risk.
|
||||||
|
**The architectural fix is for issued certificates to carry the OCSP
|
||||||
|
Must-Staple TLS Feature extension** (RFC 7633, OID 1.3.6.1.5.5.7.1.24). When
|
||||||
|
present, conforming TLS clients refuse to negotiate a session unless the
|
||||||
|
server staples a fresh signed OCSP response in the TLS handshake. This shifts
|
||||||
|
revocation enforcement from the client's discretion (which most fail-open by
|
||||||
|
default) to a hard requirement that the connection cannot complete without
|
||||||
|
proof of non-revocation.
|
||||||
|
|
||||||
|
### Operator action
|
||||||
|
|
||||||
|
For certificates issued to systems where revocation correctness matters:
|
||||||
|
|
||||||
|
1. **Configure the issuer profile to set `must-staple: true`.** Out-of-the-box
|
||||||
|
profiles in `migrations/seed.sql` do not set this; operators add it at
|
||||||
|
profile-creation time via the API or by editing seed data.
|
||||||
|
2. **Confirm the relying party honors the extension.** OpenSSL ≥ 1.1.0,
|
||||||
|
Firefox, and Chrome 84+ all enforce Must-Staple. Older clients silently
|
||||||
|
ignore it.
|
||||||
|
3. **Confirm the deployment target is configured for OCSP stapling** so the
|
||||||
|
server can actually deliver the stapled response in the handshake.
|
||||||
|
- **nginx:** `ssl_stapling on; ssl_stapling_verify on;`
|
||||||
|
- **Apache:** `SSLUseStapling on`
|
||||||
|
- **HAProxy:** `set ssl ocsp-response /path/to/response.der`
|
||||||
|
- **Envoy:** `ocsp_staple_policy: must_staple`
|
||||||
|
|
||||||
|
### What this does NOT cover
|
||||||
|
|
||||||
|
- **CRL fallback.** Must-Staple does not affect CRL behavior. Operators with
|
||||||
|
CRL-based relying parties should use the rate-limit + caching defense
|
||||||
|
alone; there is no client-side equivalent to Must-Staple for CRLs.
|
||||||
|
- **Self-issued certs in air-gapped networks.** When the relying party
|
||||||
|
cannot reach the OCSP responder at all (the threat model the audit
|
||||||
|
cited), Must-Staple is the only mechanism that closes the bypass. CRL
|
||||||
|
distribution similarly requires the relying party to fetch the CRL,
|
||||||
|
which is also subject to the same network-availability concern.
|
||||||
|
|
||||||
|
## Postgres transport encryption
|
||||||
|
|
||||||
|
See [docs/database-tls.md](database-tls.md). Bundle B / M-018.
|
||||||
|
|
||||||
|
## Encryption at rest
|
||||||
|
|
||||||
|
Bundle B / M-001. PBKDF2-SHA256 at 600,000 rounds (OWASP 2024 Password
|
||||||
|
Storage Cheat Sheet floor) for the operator-supplied passphrase that
|
||||||
|
derives the AES-256-GCM key for sensitive config columns. v3 blob format
|
||||||
|
with a per-ciphertext random salt; v1/v2 read fallback for legacy rows.
|
||||||
|
See [internal/crypto/encryption.go](../internal/crypto/encryption.go) and
|
||||||
|
the accompanying tests for the format spec.
|
||||||
|
|
||||||
|
## Authentication surface
|
||||||
|
|
||||||
|
Bundle B / M-002. Two layers decide auth-exempt status:
|
||||||
|
|
||||||
|
1. **Router layer:** `internal/api/router/router.go::AuthExemptRouterRoutes`
|
||||||
|
— the 4 endpoints registered via direct `r.mux.Handle` without going
|
||||||
|
through the middleware chain (`/health`, `/ready`, `/api/v1/auth/info`,
|
||||||
|
`/api/v1/version`).
|
||||||
|
2. **Dispatch layer:** `internal/api/router/router.go::AuthExemptDispatchPrefixes`
|
||||||
|
— URL-prefix routing in `cmd/server/main.go::buildFinalHandler` for
|
||||||
|
`/.well-known/pki/*`, `/.well-known/est/*`, and `/scep[/...]*`.
|
||||||
|
|
||||||
|
Both lists have AST-walking regression tests (`auth_exempt_test.go`) that
|
||||||
|
fail CI if a new bypass lands without an updating the documented constant.
|
||||||
|
|
||||||
|
## Per-user rate limiting
|
||||||
|
|
||||||
|
Bundle B / M-025. Authenticated callers are bucketed by API-key name;
|
||||||
|
unauthenticated callers (probes, OCSP relying parties, EST/SCEP enrollees)
|
||||||
|
are bucketed by source IP. `RPS` and `BurstSize` are per-key budgets.
|
||||||
|
`PerUserRPS` / `PerUserBurstSize` give authenticated clients a separate
|
||||||
|
budget when set non-zero.
|
||||||
|
|
||||||
|
## API key rotation
|
||||||
|
|
||||||
|
**Audit reference:** L-004. CWE-924 (improper enforcement of message integrity during transmission in a communication channel) — operator UX variant.
|
||||||
|
|
||||||
|
certctl's API keys are configured via the `CERTCTL_API_KEYS_NAMED` env var
|
||||||
|
(format `name1:key1,name2:key2:admin`) and parsed at startup into an
|
||||||
|
in-memory list. There is no DB-resident key store, no GUI, no `/api/v1/keys`
|
||||||
|
endpoint — the env var IS the key inventory.
|
||||||
|
|
||||||
|
Pre-Bundle-G the env var rejected duplicate names, so rotating a key
|
||||||
|
required: stop accepting OLDKEY → restart → roll NEWKEY out. Any client
|
||||||
|
polling against OLDKEY during the restart window hit a 401.
|
||||||
|
|
||||||
|
Bundle G adds a **double-key rotation window**: two entries can share a
|
||||||
|
name during the rollover, and both keys validate. Operators run the
|
||||||
|
rotation as:
|
||||||
|
|
||||||
|
1. **Generate the new key.** `openssl rand -hex 32` produces a 256-bit
|
||||||
|
value with sufficient entropy.
|
||||||
|
|
||||||
|
2. **Append the new entry to `CERTCTL_API_KEYS_NAMED`** alongside the
|
||||||
|
existing one:
|
||||||
|
```
|
||||||
|
CERTCTL_API_KEYS_NAMED="alice:OLDKEY:admin,alice:NEWKEY:admin"
|
||||||
|
```
|
||||||
|
Both entries MUST carry the same admin flag — startup fails loud if
|
||||||
|
they don't (a non-admin shouldn't share an identity with an admin).
|
||||||
|
|
||||||
|
3. **Restart certctl.** A startup INFO log confirms the rotation window
|
||||||
|
is active:
|
||||||
|
```
|
||||||
|
INFO api-key rotation window active name=alice entries=2 see=docs/security.md::api-key-rotation
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Roll the new key out to all clients.** Both keys validate during
|
||||||
|
this phase. Audit-trail actor + per-user rate-limit bucket stay
|
||||||
|
consistent across the rollover (both entries produce the same
|
||||||
|
`UserKey` context value, the shared name).
|
||||||
|
|
||||||
|
5. **Remove the old entry** from `CERTCTL_API_KEYS_NAMED`:
|
||||||
|
```
|
||||||
|
CERTCTL_API_KEYS_NAMED="alice:NEWKEY:admin"
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Restart certctl.** OLDKEY now fails with 401. Rotation complete.
|
||||||
|
|
||||||
|
The rotation window has no operator-set timeout — it lasts for as long
|
||||||
|
as both entries are in the env var. Best practice is a 24-72h window
|
||||||
|
covering a full deploy cadence; if a client hasn't rolled to NEWKEY by
|
||||||
|
the end of step 4, extend the window before step 5.
|
||||||
|
|
||||||
|
### What the contract guarantees
|
||||||
|
|
||||||
|
- Two entries with the same `name`: **allowed** if both have the same
|
||||||
|
`admin` flag.
|
||||||
|
- Two entries with the same `name` but mismatched admin: **rejected at
|
||||||
|
startup** (privilege escalation guard).
|
||||||
|
- Two entries with the same `(name, key)` pair: **rejected at startup**
|
||||||
|
(typo guard — rotation requires DIFFERENT keys under the same name).
|
||||||
|
- Single-entry steady state: unchanged from pre-Bundle-G behavior.
|
||||||
|
|
||||||
|
### What the contract does NOT do
|
||||||
|
|
||||||
|
- **No automatic expiration of OLDKEY.** The operator removes the entry
|
||||||
|
in step 5; certctl doesn't track timestamps. A future enhancement
|
||||||
|
could add a `rotated_at` annotation if operators ask for it.
|
||||||
|
- **No GUI / API for key management.** Keys are env-var only by design;
|
||||||
|
building a key-management surface is a separate feature project.
|
||||||
|
- **No revocation list.** If a key leaks, the only path is to remove it
|
||||||
|
from the env var and restart. That's appropriate for a small env-var
|
||||||
|
inventory; it would not scale to a per-user-key-issued model.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Email `certctl@proton.me`. Coordinated disclosure preferred; we will
|
||||||
|
acknowledge within 72h.
|
||||||
+69
-49
@@ -16,7 +16,7 @@ You'll start 7 Docker containers that talk to each other:
|
|||||||
| **pebble-challtestsrv** | DNS/HTTP challenge test server for Pebble | 10.30.50.3 | Not directly — Pebble talks to it |
|
| **pebble-challtestsrv** | DNS/HTTP challenge test server for Pebble | 10.30.50.3 | Not directly — Pebble talks to it |
|
||||||
| **Pebble** | A fake Let's Encrypt (tests the ACME protocol without touching the real internet) | 10.30.50.4 | Not directly — the server talks to it |
|
| **Pebble** | A fake Let's Encrypt (tests the ACME protocol without touching the real internet) | 10.30.50.4 | Not directly — the server talks to it |
|
||||||
| **step-ca** | A private Certificate Authority (think: your company's internal CA) | 10.30.50.5 | Not directly — the server talks to it |
|
| **step-ca** | A private Certificate Authority (think: your company's internal CA) | 10.30.50.5 | Not directly — the server talks to it |
|
||||||
| **certctl-server** | The brain. API + web dashboard + scheduler + ACME challenge server | 10.30.50.6 | **http://localhost:8443** |
|
| **certctl-server** | The brain. API + web dashboard + scheduler + ACME challenge server | 10.30.50.6 | **https://localhost:8443** (self-signed — see CA-bundle note below) |
|
||||||
| **NGINX** | A web server. The agent deploys certificates here. | 10.30.50.7 | **https://localhost:8444** |
|
| **NGINX** | A web server. The agent deploys certificates here. | 10.30.50.7 | **https://localhost:8444** |
|
||||||
| **certctl-agent** | The hands. Generates keys, deploys certs to NGINX | 10.30.50.8 | Not directly — it talks to the server |
|
| **certctl-agent** | The hands. Generates keys, deploys certs to NGINX | 10.30.50.8 | Not directly — it talks to the server |
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose -f docker-compose.test.yml up --build
|
|||||||
|
|
||||||
```
|
```
|
||||||
certctl-test-server | {"level":"INFO","msg":"server started","address":"0.0.0.0:8443"}
|
certctl-test-server | {"level":"INFO","msg":"server started","address":"0.0.0.0:8443"}
|
||||||
certctl-test-agent | {"level":"INFO","msg":"agent starting","server_url":"http://certctl-server:8443"}
|
certctl-test-agent | {"level":"INFO","msg":"agent starting","server_url":"https://certctl-server:8443"}
|
||||||
certctl-test-stepca | Serving HTTPS on :9000 ...
|
certctl-test-stepca | Serving HTTPS on :9000 ...
|
||||||
certctl-test-pebble | Listening on: 0.0.0.0:14000
|
certctl-test-pebble | Listening on: 0.0.0.0:14000
|
||||||
```
|
```
|
||||||
@@ -159,13 +159,29 @@ certctl-test-stepca Up (healthy)
|
|||||||
|
|
||||||
**If certctl-test-server says "Restarting"**: It probably started before step-ca or Pebble were ready. Wait 30 seconds and check again. If it keeps restarting, see [Troubleshooting](#troubleshooting).
|
**If certctl-test-server says "Restarting"**: It probably started before step-ca or Pebble were ready. Wait 30 seconds and check again. If it keeps restarting, see [Troubleshooting](#troubleshooting).
|
||||||
|
|
||||||
|
### Get the CA bundle for curl
|
||||||
|
|
||||||
|
The test harness runs HTTPS-only (the `certctl-tls-init` init container self-signs an ECDSA-P256 server cert with a SHA-256 signature into a bind-mounted directory before the server starts — see `docker-compose.test.yml` §`certctl-tls-init` for details). The CA cert that signed it is materialized on the host at `./test/certs/ca.crt` (relative to the `deploy/` directory). Every `curl` in the rest of this doc expects it in `$CA`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CA=$PWD/test/certs/ca.crt
|
||||||
|
ls -la "$CA" # sanity check: file should exist and be non-empty
|
||||||
|
curl --cacert "$CA" -f https://localhost:8443/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `{"status":"ok"}`. If `curl` errors with `SSL certificate problem: unable to get local issuer certificate`, the init container hasn't finished yet — wait a few seconds and retry. If the file doesn't exist at all, the bind mount didn't populate; `docker compose -f docker-compose.test.yml logs certctl-tls-init` should show the self-sign ran.
|
||||||
|
|
||||||
|
For a full explanation of the cert provisioning patterns (self-signed bootstrap, operator-supplied, cert-manager), see [`tls.md`](tls.md). For the one-step cutover from the old plaintext test harness to HTTPS, see [`upgrade-to-tls.md`](upgrade-to-tls.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2: Open the Dashboard
|
## Step 2: Open the Dashboard
|
||||||
|
|
||||||
Open your web browser and go to:
|
Open your web browser and go to:
|
||||||
|
|
||||||
**http://localhost:8443**
|
**https://localhost:8443**
|
||||||
|
|
||||||
|
Your browser will warn you that the cert is self-signed ("Your connection is not private" / "NET::ERR_CERT_AUTHORITY_INVALID"). That's expected for the test harness — the CA that signed the cert lives at `deploy/test/certs/ca.crt` and isn't in your system trust store. Click through the warning (Chrome: "Advanced" → "Proceed"; Firefox: "Accept the Risk"; Safari: "Show Details" → "visit this website").
|
||||||
|
|
||||||
You'll see a login screen asking for an API key. Enter:
|
You'll see a login screen asking for an API key. Enter:
|
||||||
|
|
||||||
@@ -198,12 +214,13 @@ Go back to your second terminal. Let's verify the data loaded correctly.
|
|||||||
### Check the agent
|
### Check the agent
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/agents | python3 -m json.tool
|
https://localhost:8443/api/v1/agents | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
**What this command does**:
|
**What this command does**:
|
||||||
- `curl` makes an HTTP request (like a browser but from the terminal)
|
- `curl` makes an HTTPS request (like a browser but from the terminal)
|
||||||
|
- `--cacert "$CA"` pins the test harness's self-signed root as the only trust anchor for this call — matches what you exported in Step 1
|
||||||
- `-s` means "silent" (don't show progress bars)
|
- `-s` means "silent" (don't show progress bars)
|
||||||
- `-H "Authorization: Bearer test-key-2026"` sends the API key (same one you used to log in)
|
- `-H "Authorization: Bearer test-key-2026"` sends the API key (same one you used to log in)
|
||||||
- `python3 -m json.tool` formats the JSON response so it's readable
|
- `python3 -m json.tool` formats the JSON response so it's readable
|
||||||
@@ -233,8 +250,8 @@ The important parts: `"id": "agent-test-01"` and `"status": "online"`. If the st
|
|||||||
### Check the issuers
|
### Check the issuers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/issuers | python3 -m json.tool
|
https://localhost:8443/api/v1/issuers | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see three issuers:
|
You should see three issuers:
|
||||||
@@ -245,8 +262,8 @@ You should see three issuers:
|
|||||||
### Check the target
|
### Check the target
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/targets | python3 -m json.tool
|
https://localhost:8443/api/v1/targets | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see `target-test-nginx` — the NGINX deployment target, assigned to `agent-test-01`.
|
You should see `target-test-nginx` — the NGINX deployment target, assigned to `agent-test-01`.
|
||||||
@@ -255,7 +272,7 @@ The target config uses no-op commands for `reload_command` and `validate_command
|
|||||||
|
|
||||||
### See it all in the dashboard
|
### See it all in the dashboard
|
||||||
|
|
||||||
Open the dashboard at http://localhost:8443 and click through the sidebar:
|
Open the dashboard at https://localhost:8443 and click through the sidebar:
|
||||||
- **Agents** — you should see `test-agent-01`
|
- **Agents** — you should see `test-agent-01`
|
||||||
- **Issuers** — you should see all three CAs
|
- **Issuers** — you should see all three CAs
|
||||||
- **Targets** — you should see `Test NGINX`
|
- **Targets** — you should see `Test NGINX`
|
||||||
@@ -287,7 +304,7 @@ The private key **never leaves the agent**. The server only ever sees the CSR (p
|
|||||||
### Step 4a: Create the certificate record
|
### Step 4a: Create the certificate record
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
|
||||||
-H "Authorization: Bearer test-key-2026" \
|
-H "Authorization: Bearer test-key-2026" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
@@ -338,7 +355,7 @@ docker exec certctl-test-postgres psql -U certctl -d certctl -c \
|
|||||||
### Step 4c: Trigger issuance
|
### Step 4c: Trigger issuance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-local-test/renew \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-local-test/renew \
|
||||||
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -395,7 +412,7 @@ The `subject` should match the domain name you chose. The `issuer` should say "c
|
|||||||
|
|
||||||
### Step 4f: Check the dashboard
|
### Step 4f: Check the dashboard
|
||||||
|
|
||||||
Open the dashboard at http://localhost:8443 and:
|
Open the dashboard at https://localhost:8443 and:
|
||||||
|
|
||||||
1. Click **Certificates** in the sidebar — you should see `mc-local-test` with status "Active"
|
1. Click **Certificates** in the sidebar — you should see `mc-local-test` with status "Active"
|
||||||
2. Click on it to see the detail page — you should see version history, the signed certificate details, and the deployment timeline
|
2. Click on it to see the detail page — you should see version history, the signed certificate details, and the deployment timeline
|
||||||
@@ -414,7 +431,7 @@ This is the real deal. ACME is the protocol that Let's Encrypt uses to issue cer
|
|||||||
### Step 5a: Create the certificate record
|
### Step 5a: Create the certificate record
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
|
||||||
-H "Authorization: Bearer test-key-2026" \
|
-H "Authorization: Bearer test-key-2026" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
@@ -441,7 +458,7 @@ docker exec certctl-test-postgres psql -U certctl -d certctl -c \
|
|||||||
"INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-acme-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
"INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-acme-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||||
|
|
||||||
# Trigger issuance
|
# Trigger issuance
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-acme-test/renew \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-acme-test/renew \
|
||||||
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -502,7 +519,7 @@ Revocation means "this certificate is no longer trusted, even though it hasn't e
|
|||||||
### Step 7a: Revoke the Local CA cert
|
### Step 7a: Revoke the Local CA cert
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-local-test/revoke \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-local-test/revoke \
|
||||||
-H "Authorization: Bearer test-key-2026" \
|
-H "Authorization: Bearer test-key-2026" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"reason": "superseded"}' | python3 -m json.tool
|
-d '{"reason": "superseded"}' | python3 -m json.tool
|
||||||
@@ -512,12 +529,15 @@ curl -s -X POST http://localhost:8443/api/v1/certificates/mc-local-test/revoke \
|
|||||||
|
|
||||||
### Step 7b: Check the CRL (Certificate Revocation List)
|
### Step 7b: Check the CRL (Certificate Revocation List)
|
||||||
|
|
||||||
|
The CRL is a DER-encoded X.509 v2 CRL (RFC 5280 §5) served under the RFC 8615 well-known namespace. It is deliberately unauthenticated — relying parties that need to verify revocation don't have certctl API keys.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
# No Authorization header — the endpoint is public by design.
|
||||||
http://localhost:8443/api/v1/crl | python3 -m json.tool
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
**What you should see**: A list that includes the revoked certificate's serial number, the reason, and the timestamp.
|
**What you should see**: `openssl` prints the CRL issuer DN, `This Update` / `Next Update` timestamps, and at least one entry whose `Serial Number` matches the cert you just revoked, with `CRL Reason Code: Superseded` (or whichever reason you passed in step 7a). The response's `Content-Type` header is `application/pkix-crl`.
|
||||||
|
|
||||||
### Step 7c: Check in the dashboard
|
### Step 7c: Check in the dashboard
|
||||||
|
|
||||||
@@ -530,8 +550,8 @@ Go to **Certificates** in the sidebar. The `mc-local-test` cert should now show
|
|||||||
The agent is configured to scan `/nginx-certs` every 6 hours for existing certificates. It already ran a scan when it started up. Let's see what it found.
|
The agent is configured to scan `/nginx-certs` every 6 hours for existing certificates. It already ran a scan when it started up. Let's see what it found.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/discovered-certificates | python3 -m json.tool
|
https://localhost:8443/api/v1/discovered-certificates | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
**What you should see**: Any certificates that exist in the NGINX cert directory, including the ones you deployed in Steps 4-5. The discovery system extracts metadata (CN, SANs, issuer, expiry, fingerprint) from the PEM files.
|
**What you should see**: Any certificates that exist in the NGINX cert directory, including the ones you deployed in Steps 4-5. The discovery system extracts metadata (CN, SANs, issuer, expiry, fingerprint) from the PEM files.
|
||||||
@@ -539,8 +559,8 @@ curl -s -H "Authorization: Bearer test-key-2026" \
|
|||||||
Check the summary:
|
Check the summary:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/discovery-summary | python3 -m json.tool
|
https://localhost:8443/api/v1/discovery-summary | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
This shows counts: how many are Unmanaged, Managed, and Dismissed.
|
This shows counts: how many are Unmanaged, Managed, and Dismissed.
|
||||||
@@ -554,7 +574,7 @@ In the dashboard: click **Discovery** in the sidebar to see the triage view.
|
|||||||
Force a renewal on the ACME certificate to see the full cycle happen again:
|
Force a renewal on the ACME certificate to see the full cycle happen again:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-acme-test/renew \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-acme-test/renew \
|
||||||
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -581,7 +601,7 @@ The test environment enables EST with `CERTCTL_EST_ENABLED=true` and `CERTCTL_ES
|
|||||||
### Step 10a: Check available CA certificates
|
### Step 10a: Check available CA certificates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sk http://localhost:8443/.well-known/est/cacerts \
|
curl --cacert "$CA" -s https://localhost:8443/.well-known/est/cacerts \
|
||||||
-H "Authorization: Bearer test-key-2026"
|
-H "Authorization: Bearer test-key-2026"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -592,7 +612,7 @@ curl -sk http://localhost:8443/.well-known/est/cacerts \
|
|||||||
### Step 10b: Check CSR attributes
|
### Step 10b: Check CSR attributes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sk http://localhost:8443/.well-known/est/csrattrs \
|
curl --cacert "$CA" -s https://localhost:8443/.well-known/est/csrattrs \
|
||||||
-H "Authorization: Bearer test-key-2026"
|
-H "Authorization: Bearer test-key-2026"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -612,7 +632,7 @@ openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
|
|||||||
EST_CSR=$(openssl req -in /tmp/est-test.csr -outform DER | base64 -w 0)
|
EST_CSR=$(openssl req -in /tmp/est-test.csr -outform DER | base64 -w 0)
|
||||||
|
|
||||||
# Submit to EST simpleenroll endpoint
|
# Submit to EST simpleenroll endpoint
|
||||||
curl -sk -X POST http://localhost:8443/.well-known/est/simpleenroll \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/.well-known/est/simpleenroll \
|
||||||
-H "Authorization: Bearer test-key-2026" \
|
-H "Authorization: Bearer test-key-2026" \
|
||||||
-H "Content-Type: application/pkcs10" \
|
-H "Content-Type: application/pkcs10" \
|
||||||
-d "$EST_CSR"
|
-d "$EST_CSR"
|
||||||
@@ -625,8 +645,8 @@ curl -sk -X POST http://localhost:8443/.well-known/est/simpleenroll \
|
|||||||
Decode and inspect the response (if you saved it to a variable):
|
Decode and inspect the response (if you saved it to a variable):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/audit-events | python3 -m json.tool | head -30
|
https://localhost:8443/api/v1/audit-events | python3 -m json.tool | head -30
|
||||||
```
|
```
|
||||||
|
|
||||||
Check the audit trail — you should see an `est_enrollment` event with the CN `est-device.certctl.test`.
|
Check the audit trail — you should see an `est_enrollment` event with the CN `est-device.certctl.test`.
|
||||||
@@ -636,7 +656,7 @@ Check the audit trail — you should see an `est_enrollment` event with the CN `
|
|||||||
EST also supports re-enrollment (certificate renewal). The same CSR format works:
|
EST also supports re-enrollment (certificate renewal). The same CSR format works:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sk -X POST http://localhost:8443/.well-known/est/simplereenroll \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/.well-known/est/simplereenroll \
|
||||||
-H "Authorization: Bearer test-key-2026" \
|
-H "Authorization: Bearer test-key-2026" \
|
||||||
-H "Content-Type: application/pkcs10" \
|
-H "Content-Type: application/pkcs10" \
|
||||||
-d "$EST_CSR"
|
-d "$EST_CSR"
|
||||||
@@ -655,7 +675,7 @@ S/MIME certificates are used for email signing and encryption — a different us
|
|||||||
### Step 11a: Create an S/MIME certificate record
|
### Step 11a: Create an S/MIME certificate record
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
|
||||||
-H "Authorization: Bearer test-key-2026" \
|
-H "Authorization: Bearer test-key-2026" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
@@ -683,7 +703,7 @@ Notice:
|
|||||||
docker exec certctl-test-postgres psql -U certctl -d certctl -c \
|
docker exec certctl-test-postgres psql -U certctl -d certctl -c \
|
||||||
"INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-smime-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
"INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-smime-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||||
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-smime-test/renew \
|
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-smime-test/renew \
|
||||||
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -692,15 +712,15 @@ curl -s -X POST http://localhost:8443/api/v1/certificates/mc-smime-test/renew \
|
|||||||
After the agent processes the job (30-60 seconds), check the certificate details:
|
After the agent processes the job (30-60 seconds), check the certificate details:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/certificates/mc-smime-test | python3 -m json.tool
|
https://localhost:8443/api/v1/certificates/mc-smime-test | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
The certificate should show `"status": "active"`. To verify the EKU on the actual cert, you can export it:
|
The certificate should show `"status": "active"`. To verify the EKU on the actual cert, you can export it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/certificates/mc-smime-test/export/pem | python3 -m json.tool
|
https://localhost:8443/api/v1/certificates/mc-smime-test/export/pem | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
If you decode the certificate PEM, you should see:
|
If you decode the certificate PEM, you should see:
|
||||||
@@ -765,16 +785,16 @@ If you have Go installed, you can build and test the CLI tool:
|
|||||||
go build -o certctl-cli ./cmd/cli
|
go build -o certctl-cli ./cmd/cli
|
||||||
|
|
||||||
# List certificates
|
# List certificates
|
||||||
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 list-certs
|
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 list-certs
|
||||||
|
|
||||||
# Get a specific certificate
|
# Get a specific certificate
|
||||||
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 get-cert mc-acme-test
|
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 get-cert mc-acme-test
|
||||||
|
|
||||||
# Check health
|
# Check health
|
||||||
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 health
|
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 health
|
||||||
|
|
||||||
# Get metrics (JSON format)
|
# Get metrics (JSON format)
|
||||||
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 --format json metrics
|
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 --format json metrics
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -921,15 +941,15 @@ Look for error messages. Common ones:
|
|||||||
**Step 2**: Verify the agent is registered:
|
**Step 2**: Verify the agent is registered:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
http://localhost:8443/api/v1/agents/agent-test-01 | python3 -m json.tool
|
https://localhost:8443/api/v1/agents/agent-test-01 | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3**: Check for pending jobs:
|
**Step 3**: Check for pending jobs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
"http://localhost:8443/api/v1/jobs?status=Pending&status=AwaitingCSR" | python3 -m json.tool
|
"https://localhost:8443/api/v1/jobs?status=Pending&status=AwaitingCSR" | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
If there are pending jobs but the agent isn't picking them up, check that the job's `agent_id` matches `agent-test-01`.
|
If there are pending jobs but the agent isn't picking them up, check that the job's `agent_id` matches `agent-test-01`.
|
||||||
@@ -959,8 +979,8 @@ docker exec certctl-test-nginx nginx -s reload
|
|||||||
**Step 3**: If the files aren't there, the deployment job hasn't completed. Check the jobs:
|
**Step 3**: If the files aren't there, the deployment job hasn't completed. Check the jobs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer test-key-2026" \
|
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
|
||||||
"http://localhost:8443/api/v1/jobs?type=Deployment" | python3 -m json.tool
|
"https://localhost:8443/api/v1/jobs?type=Deployment" | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
Look at the job status. If it's "Running" and stuck, the server's job processor may have picked it up instead of the agent (this was a known bug — the fix skips deployment jobs with `agent_id` in the server's `ProcessPendingJobs`).
|
Look at the job status. If it's "Running" and stuck, the server's job processor may have picked it up instead of the agent (this was a known bug — the fix skips deployment jobs with `agent_id` in the server's `ProcessPendingJobs`).
|
||||||
@@ -1005,7 +1025,7 @@ Change it to a different port, like:
|
|||||||
- "9443:8443"
|
- "9443:8443"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then access the dashboard at http://localhost:9443 instead.
|
Then access the dashboard at https://localhost:9443 instead.
|
||||||
|
|
||||||
### Starting completely fresh
|
### Starting completely fresh
|
||||||
|
|
||||||
@@ -1051,7 +1071,7 @@ docker compose -f docker-compose.test.yml up --build
|
|||||||
|
|
||||||
| What | Value |
|
| What | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Dashboard URL | http://localhost:8443 |
|
| Dashboard URL | https://localhost:8443 (use `--cacert ./test/certs/ca.crt`) |
|
||||||
| API key | `test-key-2026` |
|
| API key | `test-key-2026` |
|
||||||
| NGINX HTTP | http://localhost:8080 |
|
| NGINX HTTP | http://localhost:8080 |
|
||||||
| NGINX HTTPS | https://localhost:8444 |
|
| NGINX HTTPS | https://localhost:8444 |
|
||||||
|
|||||||
+504
-67
@@ -1297,66 +1297,59 @@ curl -s -H "$AUTH" "$SERVER/api/v1/audit?per_page=5" | jq '[.items[] | select(.a
|
|||||||
|
|
||||||
### 5.3 CRL & OCSP
|
### 5.3 CRL & OCSP
|
||||||
|
|
||||||
**Test 5.3.1 — JSON CRL endpoint**
|
> **M-006 note:** The non-standard JSON CRL (`GET /api/v1/crl`) and the authenticated DER CRL (`GET /api/v1/crl/{issuer_id}`) and OCSP (`GET /api/v1/ocsp/{issuer_id}/{serial}`) paths were removed. Revocation-status distribution now lives under the RFC 8615 well-known namespace (`/.well-known/pki/crl/{issuer_id}` and `/.well-known/pki/ocsp/{issuer_id}/{serial}`), served unauthenticated because relying parties (browsers, TLS clients, hardware appliances) do not have certctl API keys.
|
||||||
|
|
||||||
|
**Test 5.3.1 — DER CRL endpoint (RFC 5280 §5, unauthenticated)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/crl" | jq '{total: .total, entries_count: (.entries | length)}'
|
curl -s -D - -o /tmp/crl.der "$SERVER/.well-known/pki/crl/iss-local" | grep -i "content-type"
|
||||||
|
openssl crl -inform der -in /tmp/crl.der -noout -text | head -40
|
||||||
```
|
```
|
||||||
|
|
||||||
**What:** Fetches the JSON-formatted Certificate Revocation List.
|
**What:** Fetches the DER-encoded X.509 CRL for the local issuer without presenting any API credentials.
|
||||||
**Why:** CRL is how relying parties check if a certificate has been revoked. The JSON CRL is the machine-readable API view.
|
**Why:** Relying parties (browsers, TLS libraries, network appliances) don't have certctl API keys. RFC 5280 §5 defines only the DER wire format, and RFC 8615 defines `.well-known/pki/*` as the relying-party namespace. The Content-Type must be `application/pkix-crl` and `openssl crl -inform der` must parse the body.
|
||||||
**Expected:** HTTP 200. `total` > 0 (we revoked several certs above). Entries array contains serial numbers.
|
**Expected:** `Content-Type: application/pkix-crl`, `openssl` prints a valid CRL with the revoked serials we created above.
|
||||||
**PASS if** HTTP 200 and `total` > 0. **FAIL** if total = 0 or 500.
|
**PASS if** Content-Type matches and `openssl crl` parses the body. **FAIL** if JSON/HTML, 401/403, or parse error.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Test 5.3.2 — DER CRL endpoint**
|
**Test 5.3.2 — OCSP: good response for non-revoked cert (RFC 6960, unauthenticated)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -D - -o /dev/null -H "$AUTH" "$SERVER/api/v1/crl/iss-local" | grep -i "content-type"
|
curl -s -w "\nHTTP %{http_code}\n" "$SERVER/.well-known/pki/ocsp/iss-local/mc-api-prod" -o /tmp/ocsp.der
|
||||||
|
openssl ocsp -respin /tmp/ocsp.der -noverify -text 2>/dev/null | head -20
|
||||||
```
|
```
|
||||||
|
|
||||||
**What:** Fetches the DER-encoded X.509 CRL for the local issuer.
|
**What:** Queries the OCSP responder for a non-revoked certificate without any Authorization header.
|
||||||
**Why:** Standard CRL consumers (browsers, TLS libraries) expect DER-encoded CRLs, not JSON. The Content-Type must be correct.
|
**Why:** OCSP is the real-time alternative to CRL. RFC 6960 relying parties do not authenticate to the responder, so the endpoint must be public and return `Content-Type: application/ocsp-response`.
|
||||||
**Expected:** `Content-Type: application/pkix-crl`
|
**Expected:** HTTP 200 with OCSP response indicating "good" status when `openssl ocsp -respin` parses the body.
|
||||||
**PASS if** Content-Type is `application/pkix-crl`. **FAIL** if JSON or other.
|
**PASS if** HTTP 200 and cert status prints "good". **FAIL** if 401/403/500 or "revoked"/"unknown".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Test 5.3.3 — OCSP: good response for non-revoked cert**
|
**Test 5.3.3 — OCSP: revoked response for revoked cert (unauthenticated)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/ocsp/iss-local/mc-api-prod"
|
curl -s -w "\nHTTP %{http_code}\n" "$SERVER/.well-known/pki/ocsp/iss-local/mc-test-full" -o /tmp/ocsp.der
|
||||||
```
|
openssl ocsp -respin /tmp/ocsp.der -noverify -text 2>/dev/null | grep -i "cert status"
|
||||||
|
|
||||||
**What:** Queries the OCSP responder for a non-revoked certificate.
|
|
||||||
**Why:** OCSP is the real-time alternative to CRL. A "good" response means the cert is valid.
|
|
||||||
**Expected:** HTTP 200 with OCSP response indicating "good" status.
|
|
||||||
**PASS if** HTTP 200. **FAIL** if 500.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Test 5.3.4 — OCSP: revoked response for revoked cert**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/ocsp/iss-local/mc-test-full"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**What:** Queries OCSP for a certificate we revoked earlier.
|
**What:** Queries OCSP for a certificate we revoked earlier.
|
||||||
**Why:** OCSP must return "revoked" status for revoked certs. If it still returns "good," relying parties will trust a compromised certificate.
|
**Why:** OCSP must return "revoked" status for revoked certs. If it still returns "good," relying parties will trust a compromised certificate. Endpoint is unauthenticated per RFC 6960.
|
||||||
**Expected:** HTTP 200 with OCSP response indicating "revoked" status.
|
**Expected:** HTTP 200 with OCSP response indicating "revoked" status.
|
||||||
**PASS if** HTTP 200 and response indicates revoked. **FAIL** if response indicates "good".
|
**PASS if** HTTP 200 and status prints "revoked". **FAIL** if status is "good".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Test 5.3.5 — OCSP: unknown serial**
|
**Test 5.3.4 — OCSP: unknown serial (unauthenticated)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/ocsp/iss-local/nonexistent-serial"
|
curl -s -w "\nHTTP %{http_code}\n" "$SERVER/.well-known/pki/ocsp/iss-local/nonexistent-serial" -o /tmp/ocsp.der
|
||||||
|
openssl ocsp -respin /tmp/ocsp.der -noverify -text 2>/dev/null | grep -i "cert status"
|
||||||
```
|
```
|
||||||
|
|
||||||
**What:** Queries OCSP for a serial number the server doesn't recognize.
|
**What:** Queries OCSP for a serial number the server doesn't recognize.
|
||||||
**Why:** OCSP must return "unknown" for serials it doesn't manage, not "good" (which would be a false positive).
|
**Why:** OCSP must return "unknown" for serials it doesn't manage, not "good" (which would be a false positive). Endpoint is public per RFC 6960.
|
||||||
**Expected:** HTTP 200 with OCSP "unknown" response, or HTTP 404.
|
**Expected:** HTTP 200 with OCSP "unknown" response, or HTTP 404.
|
||||||
**PASS if** response is "unknown" or 404. **FAIL** if "good".
|
**PASS if** response is "unknown" or 404. **FAIL** if "good".
|
||||||
|
|
||||||
@@ -1815,6 +1808,37 @@ curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
|
|||||||
|
|
||||||
**Why it matters:** Issuers are the CAs that sign certificates. If issuer management is broken, no new certs can be issued.
|
**Why it matters:** Issuers are the CAs that sign certificates. If issuer management is broken, no new certs can be issued.
|
||||||
|
|
||||||
|
### 9.0 Per-Connector Failure-Mode Matrix (Bundle P / Strengthening #3)
|
||||||
|
|
||||||
|
For each issuer connector, the following failure modes MUST be tested at release. Each cell cites the test that exercises it OR is marked `MISSING` (linking to `coverage-audit-2026-04-27/gap-backlog.md` for follow-on closure work). 12 issuers × 8 modes = 96 cells; condensed legend below.
|
||||||
|
|
||||||
|
**Legend:** ✓ = covered by hermetic test (httptest.Server / fake SMTP / fake SSH / etc.). △ = covered indirectly (e.g. via wrapper-layer tests; not a per-mode regression). MISSING = no test exists; track as gap-backlog row.
|
||||||
|
|
||||||
|
| Connector | 401 | 403 | 429 | 5xx | malformed | partial | timeout | DNS fail |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| ACME (RFC 8555) | ✓ B-J | ✓ B-J | △ | ✓ B-J | ✓ B-J (dir + ARI + EAB) | △ | △ | MISSING |
|
||||||
|
| StepCA (native) | ✓ B-L.B | ✓ B-L.B | MISSING | ✓ B-L.B | ✓ B-L.B (JWE round-trip) | MISSING | △ | MISSING |
|
||||||
|
| Local CA | n/a (in-process) | n/a | n/a | △ (CA load fail) | ✓ Bundle 9 | n/a | n/a | n/a |
|
||||||
|
| Vault PKI | △ | △ | MISSING | △ | △ | MISSING | △ | MISSING |
|
||||||
|
| DigiCert | △ stub | △ stub | MISSING | △ | △ | MISSING | △ | MISSING |
|
||||||
|
| Sectigo | △ stub | △ stub | MISSING | △ | △ | MISSING | △ | MISSING |
|
||||||
|
| GoogleCAS | △ stub | △ stub | MISSING | △ | △ | MISSING | △ | MISSING |
|
||||||
|
| AWS ACM-PCA | △ stub | △ stub | MISSING | △ | △ | MISSING | △ | n/a (SDK retry) |
|
||||||
|
| GlobalSign | △ stub | △ stub | MISSING | △ | △ | MISSING | △ | MISSING |
|
||||||
|
| Entrust | △ stub | △ stub | MISSING | △ | △ | MISSING | △ | MISSING |
|
||||||
|
| EJBCA | △ stub | △ stub | MISSING | △ | △ | MISSING | △ | MISSING |
|
||||||
|
| OpenSSL (script-based) | n/a | n/a | n/a | △ (script-error) | △ | n/a | △ | n/a |
|
||||||
|
|
||||||
|
**Notable gaps surfaced by this matrix:**
|
||||||
|
|
||||||
|
- 429 + Retry-After is MISSING for every cloud / SaaS issuer connector. ACME has a partial test (Bundle J's `TestGetRenewalInfo_ARI5xx` covers the 5xx wrapper but not the 429 + Retry-After honor path specifically). Tracked as M-001-extended.
|
||||||
|
- DNS-failure handling is MISSING across the board. Most connectors rely on Go's net.DialContext + DNS resolution; a broken DNS path produces an unwrapped `lookup` error.
|
||||||
|
- "Partial response" handling (truncated JSON / chunked-encoding mid-cert) is missing for non-ACME/StepCA connectors.
|
||||||
|
|
||||||
|
This matrix replaces the previous per-Part scattershot failure-mode coverage with a single audit-ready surface. When a new failure mode is added (e.g. Bundle J-extended adds Pebble-mock 429), update the cell + cite the test.
|
||||||
|
|
||||||
|
**Target connectors are NOT in this matrix** — they have a similar failure surface (deploy-time write/reload failures) but are tested under Parts 14–17 + 42–46. A separate target-connector failure matrix is tracked as a follow-on.
|
||||||
|
|
||||||
### 9.1 Issuer CRUD
|
### 9.1 Issuer CRUD
|
||||||
|
|
||||||
**Test 6.1.1 — List issuers shows seed data**
|
**Test 6.1.1 — List issuers shows seed data**
|
||||||
@@ -2102,9 +2126,10 @@ go test ./internal/connector/issuer/local/ -run "TestSubCA" -v
|
|||||||
**What:** In sub-CA mode, the DER CRL (Part 31.1) should be signed by the sub-CA key, not a self-signed root.
|
**What:** In sub-CA mode, the DER CRL (Part 31.1) should be signed by the sub-CA key, not a self-signed root.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# After starting in sub-CA mode and revoking a cert:
|
# After starting in sub-CA mode and revoking a cert. The CRL is
|
||||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
# published unauthenticated under the RFC 8615 well-known namespace
|
||||||
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/subca-crl.der
|
# because relying parties don't carry certctl API keys.
|
||||||
|
curl -s "http://localhost:8443/.well-known/pki/crl/iss-local" -o /tmp/subca-crl.der
|
||||||
|
|
||||||
openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
||||||
```
|
```
|
||||||
@@ -3706,23 +3731,24 @@ go test ./internal/service/ -run TestCSRRenewal -v
|
|||||||
|
|
||||||
**Why:** TLS clients need to verify that certificates haven't been revoked. Without OCSP/CRL, a compromised certificate remains trusted until it expires. The short-lived exemption avoids bloating the CRL with certs that expire before distribution.
|
**Why:** TLS clients need to verify that certificates haven't been revoked. Without OCSP/CRL, a compromised certificate remains trusted until it expires. The short-lived exemption avoids bloating the CRL with certs that expire before distribution.
|
||||||
|
|
||||||
### 24.1: DER-Encoded CRL
|
> **M-006 note:** CRL and OCSP are published at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, `application/pkix-crl`) and `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `application/ocsp-response`). Per RFC 8615, `.well-known/pki/*` is the relying-party namespace, and the endpoints are served **unauthenticated** — browsers, TLS libraries, and network appliances do not have certctl API keys. The legacy `GET /api/v1/crl`, `GET /api/v1/crl/{issuer_id}`, and `GET /api/v1/ocsp/{issuer_id}/{serial}` routes were removed.
|
||||||
|
|
||||||
**What:** `GET /api/v1/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA. Content-Type is `application/pkix-crl`. The CRL has 24-hour validity.
|
### 24.1: DER-Encoded CRL (unauthenticated)
|
||||||
|
|
||||||
**Why:** This is the standard CRL format that browsers, TLS libraries, and LDAP directories consume. The existing JSON CRL at `GET /api/v1/crl` is certctl-specific; the DER CRL is interoperable.
|
**What:** `GET /.well-known/pki/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA. Content-Type is `application/pkix-crl`. The CRL has 24-hour validity.
|
||||||
|
|
||||||
|
**Why:** This is the RFC 5280 §5 wire format that browsers, TLS libraries, and LDAP directories consume. It must be reachable without any Authorization header so that relying parties — who have no certctl credentials — can fetch it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Request DER CRL for the local issuer
|
# Request DER CRL for the local issuer. No Authorization header.
|
||||||
curl -s -D - -H "Authorization: Bearer $API_KEY" \
|
curl -s -D - "http://localhost:8443/.well-known/pki/crl/iss-local" \
|
||||||
"http://localhost:8443/api/v1/crl/iss-local" \
|
|
||||||
-o /tmp/crl.der
|
-o /tmp/crl.der
|
||||||
|
|
||||||
# Verify it's valid DER CRL with openssl
|
# Verify it's valid DER CRL with openssl
|
||||||
openssl crl -in /tmp/crl.der -inform DER -noout -text
|
openssl crl -in /tmp/crl.der -inform DER -noout -text
|
||||||
```
|
```
|
||||||
|
|
||||||
**Expected:** 200 OK, Content-Type `application/pkix-crl`, Cache-Control `public, max-age=3600`.
|
**Expected:** 200 OK, Content-Type `application/pkix-crl`.
|
||||||
|
|
||||||
**PASS if:**
|
**PASS if:**
|
||||||
- `openssl crl` parses the DER file successfully
|
- `openssl crl` parses the DER file successfully
|
||||||
@@ -3730,33 +3756,34 @@ openssl crl -in /tmp/crl.der -inform DER -noout -text
|
|||||||
- Validity period is present (thisUpdate / nextUpdate)
|
- Validity period is present (thisUpdate / nextUpdate)
|
||||||
- If any certs have been revoked, they appear in the revocation list with serial + reason
|
- If any certs have been revoked, they appear in the revocation list with serial + reason
|
||||||
|
|
||||||
**FAIL if:** Response is JSON (wrong endpoint), `openssl` rejects the DER format, or headers are wrong.
|
**FAIL if:** Response is JSON (wrong endpoint), `openssl` rejects the DER format, headers are wrong, or the server returns 401/403 (auth must NOT be required).
|
||||||
|
|
||||||
### 24.2: DER CRL — Nonexistent Issuer
|
### 24.2: DER CRL — Nonexistent Issuer
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \
|
curl -s -w "\n%{http_code}" \
|
||||||
"http://localhost:8443/api/v1/crl/iss-nonexistent"
|
"http://localhost:8443/.well-known/pki/crl/iss-nonexistent"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Expected:** 404 Not Found.
|
**Expected:** 404 Not Found.
|
||||||
**PASS if** status code is 404 and body contains "not found".
|
**PASS if** status code is 404 and body contains "not found".
|
||||||
|
|
||||||
### 24.3: OCSP Responder — Good Status
|
### 24.3: OCSP Responder — Good Status (unauthenticated)
|
||||||
|
|
||||||
**What:** `GET /api/v1/ocsp/{issuer_id}/{serial}` returns a signed OCSP response. For a non-revoked certificate, the status is "good".
|
**What:** `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` returns a signed OCSP response. For a non-revoked certificate, the status is "good".
|
||||||
|
|
||||||
**Why:** OCSP is the real-time revocation check that TLS clients perform during the handshake. A "good" response tells the client the cert is still valid.
|
**Why:** OCSP is the real-time RFC 6960 revocation check that TLS clients perform during the handshake. A "good" response tells the client the cert is still valid. Relying parties fetch this without API credentials.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# First, get a certificate's serial number
|
# First, get a certificate's serial number (this uses the authenticated API
|
||||||
|
# because the operator has an API key — that is different from the relying
|
||||||
|
# party fetching the OCSP response).
|
||||||
SERIAL=$(curl -s -H "Authorization: Bearer $API_KEY" \
|
SERIAL=$(curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
"http://localhost:8443/api/v1/certificates/mc-api-prod" | jq -r '.latest_version.serial_number // empty')
|
"http://localhost:8443/api/v1/certificates/mc-api-prod" | jq -r '.latest_version.serial_number // empty')
|
||||||
|
|
||||||
# If serial is available, query OCSP
|
# Query OCSP without any Authorization header.
|
||||||
if [ -n "$SERIAL" ]; then
|
if [ -n "$SERIAL" ]; then
|
||||||
curl -s -D - -H "Authorization: Bearer $API_KEY" \
|
curl -s -D - "http://localhost:8443/.well-known/pki/ocsp/iss-local/$SERIAL" \
|
||||||
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
|
|
||||||
-o /tmp/ocsp.der
|
-o /tmp/ocsp.der
|
||||||
|
|
||||||
# Parse OCSP response
|
# Parse OCSP response
|
||||||
@@ -3771,7 +3798,7 @@ fi
|
|||||||
- Certificate status is "good" for a non-revoked cert
|
- Certificate status is "good" for a non-revoked cert
|
||||||
- Response is signed (producedAt timestamp present)
|
- Response is signed (producedAt timestamp present)
|
||||||
|
|
||||||
**FAIL if:** Response is JSON, OCSP status is wrong, or `openssl` rejects the response.
|
**FAIL if:** Response is JSON, OCSP status is wrong, `openssl` rejects the response, or the endpoint requires auth.
|
||||||
|
|
||||||
### 24.4: OCSP Responder — Revoked Status
|
### 24.4: OCSP Responder — Revoked Status
|
||||||
|
|
||||||
@@ -3784,9 +3811,8 @@ curl -s -X POST -H "Authorization: Bearer $API_KEY" \
|
|||||||
-d '{"reason": "keyCompromise"}' \
|
-d '{"reason": "keyCompromise"}' \
|
||||||
"http://localhost:8443/api/v1/certificates/$CERT_ID/revoke"
|
"http://localhost:8443/api/v1/certificates/$CERT_ID/revoke"
|
||||||
|
|
||||||
# Then query OCSP
|
# Then query OCSP — unauthenticated.
|
||||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
curl -s "http://localhost:8443/.well-known/pki/ocsp/iss-local/$SERIAL" \
|
||||||
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
|
|
||||||
-o /tmp/ocsp-revoked.der
|
-o /tmp/ocsp-revoked.der
|
||||||
|
|
||||||
openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify
|
openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify
|
||||||
@@ -3801,8 +3827,7 @@ openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify
|
|||||||
**What:** Querying a serial number that doesn't exist in the inventory returns an "unknown" OCSP status (not an error — this is the correct OCSP behavior per RFC 6960).
|
**What:** Querying a serial number that doesn't exist in the inventory returns an "unknown" OCSP status (not an error — this is the correct OCSP behavior per RFC 6960).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
curl -s "http://localhost:8443/.well-known/pki/ocsp/iss-local/DEADBEEF" \
|
||||||
"http://localhost:8443/api/v1/ocsp/iss-local/DEADBEEF" \
|
|
||||||
-o /tmp/ocsp-unknown.der
|
-o /tmp/ocsp-unknown.der
|
||||||
|
|
||||||
openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify
|
openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify
|
||||||
@@ -3820,9 +3845,8 @@ openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify
|
|||||||
To test: revoke a cert that was issued under the `prof-short-lived` profile, then check the DER CRL. The revoked short-lived cert should NOT appear.
|
To test: revoke a cert that was issued under the `prof-short-lived` profile, then check the DER CRL. The revoked short-lived cert should NOT appear.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# After revoking a short-lived cert (serial SHORT_SERIAL):
|
# After revoking a short-lived cert (serial SHORT_SERIAL). No auth needed.
|
||||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
curl -s "http://localhost:8443/.well-known/pki/crl/iss-local" -o /tmp/crl.der
|
||||||
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/crl.der
|
|
||||||
|
|
||||||
openssl crl -in /tmp/crl.der -inform DER -text | grep -i "$SHORT_SERIAL"
|
openssl crl -in /tmp/crl.der -inform DER -text | grep -i "$SHORT_SERIAL"
|
||||||
```
|
```
|
||||||
@@ -5009,10 +5033,10 @@ curl -s -w "HTTP %{http_code}\n" -X DELETE -H "$AUTH" "$SERVER/api/v1/audit/$EVE
|
|||||||
|
|
||||||
> **Tip:** Open a second terminal with `docker compose logs -f certctl-server` to watch scheduler log output in real time.
|
> **Tip:** Open a second terminal with `docker compose logs -f certctl-server` to watch scheduler log output in real time.
|
||||||
|
|
||||||
**Test 20.1.1 — Scheduler startup: all 7 loops registered**
|
**Test 20.1.1 — Scheduler startup: all 12 loops registered**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs certctl-server 2>&1 | grep -i "scheduler\|renewal check\|job processor\|health check\|notification\|short-lived\|network scan" | head -20
|
docker compose logs certctl-server 2>&1 | grep -i "scheduler\|renewal check\|job processor\|job retry\|job timeout\|health check\|notification\|notification retry\|short-lived\|network scan\|digest\|endpoint health\|cloud discovery" | head -30
|
||||||
```
|
```
|
||||||
|
|
||||||
**What:** Checks server startup logs for scheduler loop registration.
|
**What:** Checks server startup logs for scheduler loop registration.
|
||||||
@@ -6594,6 +6618,419 @@ helm template certctl deploy/helm/certctl/ --set server.replicaCount=3 | grep 'r
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Part 55: Agent Soft-Retirement (I-004)
|
||||||
|
|
||||||
|
**What this validates:** The full `DELETE /api/v1/agents/{id}` soft-retirement contract — seven HTTP status codes (200/204/400/403/404/405/409/500), opt-in retired-agent listing, sentinel refusal, `410 Gone` heartbeat response, and the force-cascade escape hatch.
|
||||||
|
|
||||||
|
**Why it matters:** Before I-004, there was no retirement surface at all — `DELETE` did not exist and agents could only be removed via raw SQL against the `agents` table. Worse, the schema declared `deployment_targets.agent_id ON DELETE CASCADE`, so any such manual delete silently cascaded through four tables with zero audit trail. This part pins the replacement contract (soft-delete + preflight + force-cascade + sentinel guard + heartbeat 410) so regressions show up here first rather than as orphaned targets in production.
|
||||||
|
|
||||||
|
### 55.1 Migration 000015 Applied
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT column_name FROM information_schema.columns WHERE table_name='agents' AND column_name IN ('retired_at','retired_reason') ORDER BY column_name;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Confirms migration 000015 added the archival columns to the `agents` table.
|
||||||
|
**PASS if** both `retired_at` and `retired_reason` rows are returned. **FAIL** if either is missing (migration did not apply).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.2 FK Constraint Flipped to RESTRICT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT confdeltype FROM pg_constraint WHERE conname='deployment_targets_agent_id_fkey';"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** `confdeltype` is PostgreSQL's one-character code for the FK delete action: `r` = RESTRICT, `c` = CASCADE.
|
||||||
|
**PASS if** the value is `r`. **FAIL** if it is still `c` — that means migration 000015's FK flip did not run, and a hard `DELETE` against an agent row would silently cascade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.3 Clean Retire — 200
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X DELETE "http://localhost:8443/api/v1/agents/ag-test-clean" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Retires an agent that has no active deployment targets, no deployed certificates, and no pending jobs.
|
||||||
|
**PASS if** status code is `200` and response body includes `"retired_at":"<ISO8601>"`, `"cascade":false`, and zero-valued counts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.4 Idempotent Re-Retire — 204
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X DELETE "http://localhost:8443/api/v1/agents/ag-test-clean" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Retires an agent that is already retired.
|
||||||
|
**PASS if** status code is `204` and response body is completely empty (not even a trailing newline from the handler). The 200-shape must NOT be emitted — this is the terminal no-op.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.5 Blocked by Dependencies — 409
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X DELETE "http://localhost:8443/api/v1/agents/ag-with-deps" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Attempts to retire an agent that still has active targets/certificates/jobs.
|
||||||
|
**PASS if** status code is `409` and response body is the three-key `BlockedByDependenciesResponse` shape: `{"error":"blocked_by_dependencies", "message": "...", "counts": {"active_targets": N, "active_certificates": N, "pending_jobs": N}}`. Must NOT be the generic `ErrorResponse` shape — downstream dashboards parse the `counts` key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.6 Force Cascade — 200
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X DELETE "http://localhost:8443/api/v1/agents/ag-with-deps?force=true&reason=decommissioning+rack-7" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Uses the force escape hatch to cascade-retire the dependencies.
|
||||||
|
**PASS if** status code is `200`, response includes `"cascade":true` with the pre-cascade counts, and the subsequent `GET /api/v1/audit-events?action=agent_retirement_cascaded` shows the event with the supplied `reason` and actor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.7 Force Without Reason — 400
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X DELETE "http://localhost:8443/api/v1/agents/ag-other?force=true" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies the `ErrForceReasonRequired` guard — `force=true` without `reason` must be rejected before any state mutation.
|
||||||
|
**PASS if** status code is `400` and no agent/target/job rows were modified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.8 Sentinel Refusal — 403
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for id in server-scanner cloud-aws-sm cloud-azure-kv cloud-gcp-sm; do
|
||||||
|
echo "=== $id ==="
|
||||||
|
curl -sS -X DELETE "http://localhost:8443/api/v1/agents/${id}?force=true&reason=attempt" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies all four sentinel agents refuse retirement even with `force=true`.
|
||||||
|
**PASS if** every request returns `403` and the response body's `error` value is `sentinel_agent` (or the equivalent `ErrAgentIsSentinel` mapping). **FAIL** if any sentinel accepts the request — retiring one silently orphans the network scanner or one of the three cloud secret-manager discovery sources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.9 Unknown ID — 404
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X DELETE "http://localhost:8443/api/v1/agents/ag-does-not-exist" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies `ErrAgentNotFound` maps to 404 (not 500). Ordering matters — the not-found check must come after the sentinel check so a typo'd sentinel ID still returns 403, not 404.
|
||||||
|
**PASS if** status code is `404`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.10 Heartbeat on Retired Agent — 410
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://localhost:8443/api/v1/agents/ag-test-clean/heartbeat" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"os":"linux","architecture":"amd64","hostname":"test","ip_address":"10.0.0.1","version":"2.1.0"}' \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Retired agents get `410 Gone` — the canonical "resource is permanently gone, stop retrying" signal — so `cmd/agent` detects it and exits cleanly.
|
||||||
|
**PASS if** status code is `410`. **FAIL** if it is `404` (wrong ordering — retired-check must run before not-found) or `200` (retired filter missing entirely — agent would keep phoning home forever).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.11 Default List Excludes Retired
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://localhost:8443/api/v1/agents" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
| jq -r '.data[] | select(.id=="ag-test-clean") | .id'
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies the default `/agents` listing filters retired rows via `AgentRepository.ListActive`.
|
||||||
|
**PASS if** output is empty (the retired agent does NOT appear). **FAIL** if `ag-test-clean` shows up — default listings must not expose retired rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.12 Retired Agents Opt-In View
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://localhost:8443/api/v1/agents/retired" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
| jq -r '.data[] | select(.id=="ag-test-clean") | {id, retired_at, retired_reason}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies the opt-in retired-agents view returns the row with `retired_at` and `retired_reason` populated. Go 1.22 ServeMux literal-beats-pattern-var precedence routes `/agents/retired` to this handler rather than `/agents/{id}`.
|
||||||
|
**PASS if** the row appears with non-null `retired_at`. **FAIL** if the row is missing (listing broken) or `retired_at` is null (serialization broken).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.13 Dashboard Stats Counter Excludes Retired
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://localhost:8443/api/v1/stats/summary" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
| jq -r '.total_agents'
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Stats dashboard uses `ListActive`, not `List` — retired agents must not inflate the count.
|
||||||
|
**PASS if** the counter reflects only non-retired rows (verify against `SELECT count(*) FROM agents WHERE retired_at IS NULL`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.14 CLI Retire Subcommand
|
||||||
|
|
||||||
|
```bash
|
||||||
|
certctl-cli agents retire ag-cli-test --force --reason "smoke test"
|
||||||
|
certctl-cli agents list --retired | grep ag-cli-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies the CLI `agents retire` subcommand forwards `--force` and `--reason` via `DeleteWithQuery` and the `agents list --retired` flag hits `/agents/retired` rather than the default listing.
|
||||||
|
**PASS if** the first command succeeds and the second shows the agent in the retired view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.15 MCP Retire Tool Schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/mcp/ -run TestRetireAgent -v -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Verifies the `certctl_retire_agent` MCP tool's input schema accepts `id`, `force`, and `reason`, and that the tool actually propagates `force`/`reason` into the outbound DELETE query string (not the body).
|
||||||
|
**PASS if** exit code 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 55.16 HEAD-State OpenAPI Contract
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx --yes @redocly/cli lint api/openapi.yaml \
|
||||||
|
--config '{"rules":{"operation-4xx-response":"error","no-invalid-media-type-examples":"error"}}'
|
||||||
|
python3 -c "
|
||||||
|
import yaml
|
||||||
|
spec = yaml.safe_load(open('api/openapi.yaml'))
|
||||||
|
del_op = spec['paths']['/api/v1/agents/{id}']['delete']
|
||||||
|
assert set(del_op['responses'].keys()) == {'200','204','400','403','404','405','409','500'}, del_op['responses'].keys()
|
||||||
|
hb = spec['paths']['/api/v1/agents/{id}/heartbeat']['post']
|
||||||
|
assert '410' in hb['responses'], hb['responses'].keys()
|
||||||
|
assert spec['paths']['/api/v1/agents/retired']['get']['operationId'] == 'listRetiredAgents'
|
||||||
|
print('OpenAPI I-004 contract: OK')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Two-part check. Redocly lint confirms the spec is structurally valid; the Python assertions pin the seven DELETE status codes, the 410 heartbeat response, and the retired-agents operationId.
|
||||||
|
**PASS if** redocly prints no errors and the Python script prints `OpenAPI I-004 contract: OK`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 56: Notification Retry & Dead-Letter Queue (I-005)
|
||||||
|
|
||||||
|
**What this validates:** The full retry lifecycle for `notification_events` rows — transient notifier failures are re-armed with exponential backoff (`2^retry_count` minutes capped at 1h, 5-attempt budget), rows that exhaust the budget land in the terminal `dead` status, the dead-letter depth is surfaced both on the dashboard and via a Prometheus counter, and operators can requeue dead rows once the underlying outage is resolved.
|
||||||
|
|
||||||
|
**Why it matters:** Before I-005, a failed notification was a silent drop. `internal/service/notification.go` flipped `status` to `failed` and never came back to it, because `ProcessPendingNotifications` only lists rows whose `status='pending'`. A 5xx from Slack, a 30-second SMTP stall, or a misrouted webhook URL could each lose a critical alert (cert expiry, CA compromise, approval-rejected) with no trace beyond a single log line. Part 56 pins the replacement contract (retry loop + DLQ + dashboard surface + Prometheus metric + operator requeue) so regressions show up here rather than as a post-incident "why didn't we get paged?" review.
|
||||||
|
|
||||||
|
### 56.1 Migration 000016 Columns Applied
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT column_name FROM information_schema.columns WHERE table_name='notification_events' AND column_name IN ('retry_count','next_retry_at','last_error') ORDER BY column_name;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Confirms migration 000016 added the retry bookkeeping columns to `notification_events`.
|
||||||
|
**PASS if** all three rows (`last_error`, `next_retry_at`, `retry_count`) are returned. **FAIL** if any is missing — the migration did not apply and the retry loop will error on every tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.2 Partial Retry-Sweep Index Present
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT indexdef FROM pg_indexes WHERE tablename='notification_events' AND indexname='idx_notification_events_retry_sweep';"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Confirms the partial index `idx_notification_events_retry_sweep ON notification_events(next_retry_at) WHERE status = 'failed' AND next_retry_at IS NOT NULL` exists and has the expected predicate.
|
||||||
|
**PASS if** the returned `indexdef` includes `WHERE ((status = 'failed'::text) AND (next_retry_at IS NOT NULL))`. **FAIL** if the index is missing or unpartialed — the retry sweep will scan the full notification history instead of the small retry-eligible slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.3 Failed Notification Retries On Next Tick
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Seed a failed notification with next_retry_at in the past
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"UPDATE notification_events SET status='failed', retry_count=0, next_retry_at=NOW() - INTERVAL '1 minute', last_error='transient SMTP timeout' WHERE id='notif-demo-1';"
|
||||||
|
|
||||||
|
# Wait for the retry loop to sweep (default CERTCTL_NOTIFICATION_RETRY_INTERVAL=2m)
|
||||||
|
sleep 130
|
||||||
|
|
||||||
|
# Observe the post-sweep state
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT id, status, retry_count, next_retry_at IS NOT NULL AS has_next_retry FROM notification_events WHERE id='notif-demo-1';"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Exercises the retry loop's failure path. The seeded row is re-dispatched through the notifier registry; in the demo environment the notifier does not exist for `email` so the sweep either delivers (`status='sent'`) or records a failed attempt (`retry_count=1`, `next_retry_at` re-armed).
|
||||||
|
**PASS if** either `status='sent'` (delivered on retry) or the row is still `failed` with `retry_count >= 1` and `has_next_retry=t`. **FAIL** if the row is still `failed` with `retry_count=0` and `next_retry_at` in the past — the retry loop is not actually running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.4 Exhausted Notification Transitions To Dead
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Seed a row one failure shy of exhaustion — retry_count=4 means the next
|
||||||
|
# tick's failure is the 5th attempt (notifRetryMaxAttempts-1 check at
|
||||||
|
# internal/service/notification.go:531).
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"UPDATE notification_events SET status='failed', retry_count=4, next_retry_at=NOW() - INTERVAL '1 minute', last_error='persistent outage', channel='channel-that-does-not-exist' WHERE id='notif-demo-2';"
|
||||||
|
|
||||||
|
sleep 130
|
||||||
|
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT id, status, retry_count, last_error FROM notification_events WHERE id='notif-demo-2';"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** The row at `retry_count=4` enters the sweep, the notifier lookup fails (channel unknown), the exhaustion branch fires, and `MarkAsDead` flips the row. Note: the "notifier unknown" branch at notification.go:494-503 promotes to `sent` for demo parity, so for a strict DLQ assertion seed a row whose channel is a known registered notifier that will reject delivery — alternatively run against the integration test fixture where the retry-exhaustion path is deterministic.
|
||||||
|
**PASS if** `status='dead'` and `last_error` reflects the send failure. **FAIL** if the row is still `failed` with `retry_count >= 5` — the exhaustion branch did not fire and the row will retry forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.5 Dead Row Has Null next_retry_at
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT COUNT(*) FROM notification_events WHERE status='dead' AND next_retry_at IS NOT NULL;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** `MarkAsDead` must clear `next_retry_at` so the partial retry-sweep index stops matching the row. If this invariant breaks, a dead row keeps appearing in `ListRetryEligible` and the exhaustion branch fires on every sweep.
|
||||||
|
**PASS if** the count is `0`. **FAIL** if any dead rows still carry a non-null `next_retry_at` — the DLQ is leaky and the row will re-enter the retry rotation on the next tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.6 DashboardSummary Populates NotificationsDead
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Seed a dead row so the count is observable
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"UPDATE notification_events SET status='dead', next_retry_at=NULL, last_error='demo DLQ fixture' WHERE id='notif-demo-3';"
|
||||||
|
|
||||||
|
curl -sS "http://localhost:8443/api/v1/stats/summary" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
| python3 -c "import sys,json; s=json.load(sys.stdin); assert 'notifications_dead' in s, 'missing notifications_dead field'; assert s['notifications_dead'] >= 1, s['notifications_dead']; print('notifications_dead:', s['notifications_dead'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Confirms `DashboardSummary.NotificationsDead` (`internal/service/stats.go:66`) is populated by `notifRepo.CountByStatus(ctx, "dead")` (stats.go:137-142) and surfaced in the dashboard summary JSON.
|
||||||
|
**PASS if** the field is present and reflects at least the seeded dead row. **FAIL** if the field is missing (`SetNotifRepo` was not called on StatsService) or stuck at zero despite seeded dead rows (repository `CountByStatus` is broken).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.7 Prometheus Counter Emits certctl_notification_dead_total
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://localhost:8443/api/v1/metrics/prometheus" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
| grep -E '^# (HELP|TYPE) certctl_notification_dead_total|^certctl_notification_dead_total '
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** The Prometheus endpoint (`internal/api/handler/metrics.go:217-219`) emits three lines: `# HELP certctl_notification_dead_total Number of notifications in the dead-letter queue.`, `# TYPE certctl_notification_dead_total counter`, and a bare `certctl_notification_dead_total <value>` value line. Operator alert thresholds per the I-005 spec: `> 0` warning, `> 10` critical.
|
||||||
|
**PASS if** all three lines are present and the value is `>= 1` when dead rows exist. **FAIL** if any of the three lines is missing — the metric name is misspelled, the `# TYPE` is wrong, or `DashboardSummary.NotificationsDead` is not wired into the metrics handler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.8 Requeue Resets Retry Bookkeeping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Confirm the row is in 'dead' with the full retry history
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT id, status, retry_count, next_retry_at, last_error FROM notification_events WHERE id='notif-demo-3';"
|
||||||
|
|
||||||
|
# Requeue via the operator endpoint
|
||||||
|
curl -sS -X POST "http://localhost:8443/api/v1/notifications/notif-demo-3/requeue" \
|
||||||
|
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
|
||||||
|
-w "\nHTTP %{http_code}\n"
|
||||||
|
|
||||||
|
# Confirm the atomic reset
|
||||||
|
docker compose -f deploy/docker-compose.yml exec postgres \
|
||||||
|
psql -U certctl -d certctl -c \
|
||||||
|
"SELECT id, status, retry_count, next_retry_at, last_error FROM notification_events WHERE id='notif-demo-3';"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Exercises the operator-driven escape hatch (`POST /api/v1/notifications/{id}/requeue`). The repository's `Requeue` must atomically flip `status → pending`, reset `retry_count → 0`, clear `next_retry_at → NULL`, and clear `last_error → NULL` — see `internal/service/notification.go:571-576` and the pinning test at `notification_handler_test.go:307-347`.
|
||||||
|
**PASS if** HTTP `200` with JSON body `{"status":"requeued"}` AND the post-requeue row has `status='pending'`, `retry_count=0`, `next_retry_at IS NULL`, `last_error IS NULL`. **FAIL** if any of the four fields is not reset — `ProcessPendingNotifications` will not treat this as a fresh attempt and the audit trail will be ambiguous.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.9 GUI Dead Letter Tab Threads ?status=dead
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npx vitest run src/pages/NotificationsPage.test.tsx -t 'Dead letter tab fetches notifications with status=dead'
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** The two-tab toolbar on `/notifications` routes the "Dead letter" tab's query through `getNotifications({ status: 'dead', per_page: '100' })`. This test verifies the React Query's `queryKey: ['notifications', activeTab]` (`NotificationsPage.tsx:31`) actually translates the tab click into the server-side filter — not client-side filtering of the full inbox.
|
||||||
|
**PASS if** the Vitest assertion at `NotificationsPage.test.tsx:104-128` passes. **FAIL** if the Dead letter tab is merely a client-side filter on the `all` response — the DLQ-only code path (`NotificationRepository.ListByStatus`) is not exercised, which matters for pagination correctness once the inbox grows beyond 100 rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.10 Requeue Button MutationFn Wrapper
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npx vitest run src/pages/NotificationsPage.test.tsx -t 'clicking Requeue invokes requeueNotification'
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** `react-query` v5's `mutate(id)` passes a second positional argument (the mutation context object) to the `mutationFn`. If `mutationFn: requeueNotification` is used directly, the API client receives `(id, { client })` — an extra argument that the strict-match `toHaveBeenCalledWith('notif-dead-001')` assertion at `NotificationsPage.test.tsx:181` rejects. The fix is an explicit single-arg arrow: `mutationFn: (id: string) => requeueNotification(id)` at `NotificationsPage.tsx:64`.
|
||||||
|
**PASS if** the Vitest assertion passes (the API client was called with exactly one argument). **FAIL** if the wrapper is inadvertently removed — silent success in runtime, loud failure in this contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 56.11 HEAD-State OpenAPI Contract
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx --yes @redocly/cli lint api/openapi.yaml \
|
||||||
|
--config '{"rules":{"operation-4xx-response":"error","no-invalid-media-type-examples":"error"}}'
|
||||||
|
python3 -c "
|
||||||
|
import yaml
|
||||||
|
spec = yaml.safe_load(open('api/openapi.yaml'))
|
||||||
|
post = spec['paths']['/api/v1/notifications/{id}/requeue']['post']
|
||||||
|
assert post['operationId'] == 'requeueNotification', post['operationId']
|
||||||
|
assert set(post['responses'].keys()) >= {'200','400','404','405','500'}, post['responses'].keys()
|
||||||
|
print('OpenAPI I-005 contract: OK')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What:** Two-part check. Redocly lint confirms the spec is structurally valid; the Python assertions pin the requeue endpoint's `operationId` and the five minimum response codes (200/400/404/405/500).
|
||||||
|
**PASS if** redocly prints no errors and the Python script prints `OpenAPI I-005 contract: OK`. **FAIL** if the `operationId` changed or any of the five responses is missing — downstream MCP/CLI clients rely on the contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Release Sign-Off
|
## Release Sign-Off
|
||||||
|
|
||||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||||
@@ -6934,7 +7371,7 @@ These must be green before starting manual QA:
|
|||||||
|
|
||||||
| Test | Description | Method | Pass? | Date | Notes |
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|------|-------------|--------|-------|------|-------|
|
|------|-------------|--------|-------|------|-------|
|
||||||
| 20.1.1 | Scheduler startup: all 7 loops registered | Manual | ☐ | | |
|
| 20.1.1 | Scheduler startup: all 12 loops registered | Manual | ☐ | | |
|
||||||
| 20.1.2 | Job processor loop fires (30s interval) | Manual | ☐ | | |
|
| 20.1.2 | Job processor loop fires (30s interval) | Manual | ☐ | | |
|
||||||
| 20.1.3 | Agent health check marks offline (2m interval) | Manual | ☐ | | |
|
| 20.1.3 | Agent health check marks offline (2m interval) | Manual | ☐ | | |
|
||||||
| 20.1.4 | Notification processor fires (1m interval) | Manual | ☐ | | |
|
| 20.1.4 | Notification processor fires (1m interval) | Manual | ☐ | | |
|
||||||
@@ -7595,10 +8032,10 @@ These must be green before starting manual QA:
|
|||||||
| Category | Count |
|
| Category | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
||||||
| ☐ Auto (not yet run) | 129 |
|
| ☐ Auto (not yet run) | 136 |
|
||||||
| — Skipped (preconditions not met in demo) | 5 |
|
| — Skipped (preconditions not met in demo) | 5 |
|
||||||
| ☐ Manual (requires hands-on verification) | 282 |
|
| ☐ Manual (requires hands-on verification) | 286 |
|
||||||
| **Total** | **560** |
|
| **Total** | **571** |
|
||||||
|
|
||||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# certctl Testing Strategy & Deep-Scan Operator Runbook
|
||||||
|
|
||||||
|
This doc covers the **testing topology** (per-PR fast gates vs. daily deep-scan
|
||||||
|
gates), and the **operator runbook** for re-running each deep-scan tool locally
|
||||||
|
when the CI receipt is ambiguous or when an operator wants to validate a fix
|
||||||
|
before the next scheduled scan.
|
||||||
|
|
||||||
|
For the manual end-to-end QA playbook, see [`testing-guide.md`](testing-guide.md).
|
||||||
|
For the security posture / per-finding closure log, see [`security.md`](security.md).
|
||||||
|
|
||||||
|
## CI workflow split
|
||||||
|
|
||||||
|
certctl runs two GitHub Actions workflows:
|
||||||
|
|
||||||
|
- **`.github/workflows/ci.yml`** — runs on every push/PR. Fast feedback only.
|
||||||
|
Includes `gofmt`, `go vet`, `golangci-lint`, `go test -short -count=1`,
|
||||||
|
`govulncheck`, the per-layer coverage gates, and the regression-grep guards
|
||||||
|
(the M-009 mutation budget, the L-001 InsecureSkipVerify guard, the H-001
|
||||||
|
Dockerfile SHA-pin guard, the M-012 USER-directive guard, etc.).
|
||||||
|
- **`.github/workflows/security-deep-scan.yml`** — runs daily 06:00 UTC and on
|
||||||
|
manual dispatch. Heavyweight tools that need docker, network egress to
|
||||||
|
scanner registries, or wall-clock budgets the per-PR check can't tolerate.
|
||||||
|
Includes `gosec`, `osv-scanner`, the `-race -count=10` full-suite run,
|
||||||
|
`trivy` image scan, `syft` SBOM, ZAP baseline DAST, `nuclei`,
|
||||||
|
`schemathesis` OpenAPI fuzz, `testssl.sh`, `go-mutesting` mutation testing,
|
||||||
|
and `semgrep p/react-security`.
|
||||||
|
|
||||||
|
Receipts from each scheduled run are uploaded as a 30-day-retention artefact
|
||||||
|
named `security-deep-scan-<run-id>`. Audit them via the GitHub Actions UI;
|
||||||
|
download the artefact zip for any scan that surfaces a finding.
|
||||||
|
|
||||||
|
## Operator runbook — local re-run procedures
|
||||||
|
|
||||||
|
These are the same commands the workflow runs, intended for an operator with
|
||||||
|
a workstation that has docker + the Go toolchain installed. The local-run
|
||||||
|
shape is identical to CI; the difference is wall-clock and the artefact
|
||||||
|
location (CI uploads; local writes to `$PWD`).
|
||||||
|
|
||||||
|
### Mutation testing (D-003)
|
||||||
|
|
||||||
|
**Tool:** [`go-mutesting`](https://github.com/zimmski/go-mutesting). Mutates
|
||||||
|
each AST node in turn (flips comparisons, swaps return values, removes
|
||||||
|
statements) and re-runs the package's tests. A mutant is **killed** if any
|
||||||
|
test fails; **surviving** mutants indicate a coverage gap (no test caught
|
||||||
|
the bug the mutant introduced).
|
||||||
|
|
||||||
|
**Targets:** the three security-critical packages whose coverage gate is
|
||||||
|
**85%** in `ci.yml`:
|
||||||
|
|
||||||
|
- `internal/crypto/`
|
||||||
|
- `internal/pkcs7/`
|
||||||
|
- `internal/connector/issuer/local/`
|
||||||
|
|
||||||
|
**Acceptance threshold:** ≥80% mutation kill ratio per package. Surviving
|
||||||
|
mutants below that threshold get triaged in
|
||||||
|
`cowork/comprehensive-audit-2026-04-25/d003-mutation-results.md` — either
|
||||||
|
ship a targeted unit test that kills the mutant, or document an
|
||||||
|
equivalent-mutation justification.
|
||||||
|
|
||||||
|
**Local run:**
|
||||||
|
|
||||||
|
```
|
||||||
|
go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest
|
||||||
|
for pkg in ./internal/crypto/... ./internal/pkcs7/... ./internal/connector/issuer/local/...; do
|
||||||
|
echo "=== $pkg ==="
|
||||||
|
$(go env GOPATH)/bin/go-mutesting "$pkg"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool prints one line per mutant (`PASS` = killed, `FAIL` = surviving)
|
||||||
|
plus a per-package summary `The mutation score is X.YZ`. CPU-bound, single
|
||||||
|
core, takes ~10 minutes on a 2024-era laptop for the three packages combined.
|
||||||
|
|
||||||
|
**Sandbox note:** `go-mutesting` writes a mutant copy of the source tree to
|
||||||
|
`/tmp/go-mutesting/` per run; needs ≥2 GB free disk. Sandboxed CI runners
|
||||||
|
are sized for this; constrained dev sandboxes are not.
|
||||||
|
|
||||||
|
### DAST baseline (D-004)
|
||||||
|
|
||||||
|
**Tool:** [OWASP ZAP `baseline`](https://www.zaproxy.org/docs/docker/baseline-scan/).
|
||||||
|
Spiders the running server's URL surface and runs the OWASP-ZAP active+passive
|
||||||
|
rule pack. **Baseline** mode skips the destructive active-scan rules; it's safe
|
||||||
|
against a non-throwaway environment.
|
||||||
|
|
||||||
|
**Target:** the live `deploy/docker-compose.yml` stack on `https://localhost:8443`.
|
||||||
|
|
||||||
|
**Acceptance:** zero HIGH/CRITICAL alerts. WARN/INFO alerts get triaged in the
|
||||||
|
ZAP report; some are unavoidable (e.g., HSTS preload-list nag is a deployment
|
||||||
|
recommendation, not a server defect).
|
||||||
|
|
||||||
|
**Local run:**
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
|
sleep 20 # wait for /ready to flip OK; check `curl --cacert deploy/test/certs/ca.crt https://localhost:8443/ready`
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v "$PWD":/zap/wrk \
|
||||||
|
ghcr.io/zaproxy/zaproxy:stable \
|
||||||
|
zap-baseline.py -t https://localhost:8443 \
|
||||||
|
-r zap-report.html -J zap-report.json
|
||||||
|
docker compose -f deploy/docker-compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTML report opens in a browser; the JSON is machine-readable for triage.
|
||||||
|
|
||||||
|
### TLS audit (D-005)
|
||||||
|
|
||||||
|
**Tool:** [`testssl.sh`](https://testssl.sh/). Probes the TLS handshake and
|
||||||
|
each enabled cipher suite; reports protocol-version weaknesses, cipher
|
||||||
|
weaknesses, certificate-chain issues, and known CVE patterns (Heartbleed,
|
||||||
|
ROBOT, BEAST, etc.).
|
||||||
|
|
||||||
|
**Target:** the live stack on `https://localhost:8443`.
|
||||||
|
|
||||||
|
**Acceptance:** zero HIGH/CRITICAL findings. certctl pins
|
||||||
|
`tls.Config.MinVersion = tls.VersionTLS13` (`cmd/server/tls.go`), so anything
|
||||||
|
that surfaces is either (a) a real defect, (b) a testssl false positive, or
|
||||||
|
(c) a deployment-config issue worth documenting in the operator runbook.
|
||||||
|
|
||||||
|
**Local run:**
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
|
sleep 20
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v "$PWD":/data \
|
||||||
|
drwetter/testssl.sh:latest \
|
||||||
|
--jsonfile /data/testssl.json https://localhost:8443
|
||||||
|
docker compose -f deploy/docker-compose.yml down
|
||||||
|
|
||||||
|
# Filter to actionable severities
|
||||||
|
jq '[.scanResult[] | select(.severity == "HIGH" or .severity == "CRITICAL")]' testssl.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend semgrep (D-007)
|
||||||
|
|
||||||
|
**Tool:** [`semgrep`](https://semgrep.dev/) with the maintained
|
||||||
|
[`p/react-security` ruleset](https://semgrep.dev/p/react-security). Catches
|
||||||
|
React-specific XSS / injection patterns: `dangerouslySetInnerHTML` without
|
||||||
|
sanitization, `target="_blank"` without `rel="noopener noreferrer"`,
|
||||||
|
`href={userInput}`, `eval`, `document.write`, etc.
|
||||||
|
|
||||||
|
**Target:** the frontend source tree at `web/src/`.
|
||||||
|
|
||||||
|
**Acceptance:** zero findings. Bundle 8 already verified
|
||||||
|
`dangerouslySetInnerHTML` count at zero and the `target="_blank"`
|
||||||
|
rel-noopener pin via simple grep guards in `ci.yml`; semgrep adds defence
|
||||||
|
in depth — it catches escape patterns the greps don't see (e.g.,
|
||||||
|
`href={user_input}`, runtime `eval`, `document.write`).
|
||||||
|
|
||||||
|
**Local run:**
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run --rm -v "$PWD":/src returntocorp/semgrep:latest \
|
||||||
|
semgrep --config=p/react-security --json /src/web/src \
|
||||||
|
> semgrep-react.json
|
||||||
|
|
||||||
|
# Count findings
|
||||||
|
jq '.results | length' semgrep-react.json
|
||||||
|
|
||||||
|
# Pretty-print findings
|
||||||
|
jq '.results[] | {rule_id: .check_id, path, line: .start.line, message: .extra.message}' semgrep-react.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If the count is non-zero, every result has a `check_id` (e.g.
|
||||||
|
`react.dangerouslySetInnerHTML`) and a `message` describing the escape
|
||||||
|
pattern. Triage each: either fix the call site, or — for legitimate edge
|
||||||
|
cases — add a `// nosem: <check_id> — <reason>` directive on the
|
||||||
|
preceding line.
|
||||||
|
|
||||||
|
## Cadence
|
||||||
|
|
||||||
|
| Tool | Trigger | Wall-clock | Owner |
|
||||||
|
|----------------------|------------------------------------|------------|----------------|
|
||||||
|
| go-mutesting | daily deep-scan + manual dispatch | ~10 min | maintainers |
|
||||||
|
| ZAP baseline (DAST) | daily deep-scan + manual dispatch | ~5 min | maintainers |
|
||||||
|
| testssl.sh | daily deep-scan + manual dispatch | ~3 min | maintainers |
|
||||||
|
| semgrep react | daily deep-scan + manual dispatch | ~1 min | maintainers |
|
||||||
|
| `make verify` | every commit (pre-push) | ~1 min | every developer |
|
||||||
|
| ci.yml fast gates | every push/PR | ~3 min | every developer |
|
||||||
|
|
||||||
|
Re-run any of the deep-scan tools locally when:
|
||||||
|
|
||||||
|
- A CI receipt surfaces an unexpected finding and you want to bisect against
|
||||||
|
a local change before pushing.
|
||||||
|
- You're cutting a release tag and want belt-and-suspenders evidence beyond
|
||||||
|
the most recent scheduled scan.
|
||||||
|
- You're adding a new feature in the relevant surface (crypto code →
|
||||||
|
re-run mutation testing; new HTTP handler → re-run schemathesis + ZAP;
|
||||||
|
new TLS-config knob → re-run testssl).
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [`docs/security.md`](security.md) — security posture, per-finding closure log.
|
||||||
|
- [`docs/testing-guide.md`](testing-guide.md) — manual end-to-end QA playbook.
|
||||||
|
- [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — per-PR fast gates.
|
||||||
|
- [`.github/workflows/security-deep-scan.yml`](../.github/workflows/security-deep-scan.yml) — daily deep-scan gates.
|
||||||
|
- [`scripts/install-security-tools.sh`](../scripts/install-security-tools.sh) — Go-host-installed tools (the docker-based tools are not in this script).
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## InsecureSkipVerify justifications (Audit L-001)
|
||||||
|
|
||||||
|
`crypto/tls.Config.InsecureSkipVerify` short-circuits standard certificate
|
||||||
|
chain validation. Each production use site below has a justification —
|
||||||
|
the shape is "this code path is fundamentally pre-trust or
|
||||||
|
trust-from-context, and chain validation in the stdlib path is not the
|
||||||
|
right tool". Test-only sites are not enumerated here.
|
||||||
|
|
||||||
|
The CI grep guard `Forbidden bare InsecureSkipVerify regression guard
|
||||||
|
(L-001)` in `.github/workflows/ci.yml` fails the build if any new
|
||||||
|
`InsecureSkipVerify: true` lands in a non-test file without a
|
||||||
|
`//nolint:gosec` comment carrying a justification — adding a new entry
|
||||||
|
to this table is the right way to extend the surface.
|
||||||
|
|
||||||
|
| Site (file:line) | Trigger | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| `cmd/agent/main.go:59,125,136,1259,1262` | `--insecure-skip-verify` CLI flag | Dev escape hatch; docs/tls.md and the agent install script direct operators to use a real CA bundle in production. The server emits a startup WARN when set. |
|
||||||
|
| `cmd/agent/verify.go:70,78` | TLS deployment verification probe | The agent is verifying that its own freshly-deployed cert is being served. The chain may be self-signed or signed by an upstream the agent host doesn't trust; what matters is the leaf-cert match against what the agent just deployed. The verifier compares the served leaf bytes to the expected leaf, not the chain. |
|
||||||
|
| `internal/tlsprobe/probe.go:33,47,54` | Network scanner / discovery probe | Discovery's job is to find every cert on the network, including expired, self-signed, and not-yet-deployed certs. Validating the chain would silently skip the broken-cert results that are precisely what operators want to know about. |
|
||||||
|
| `internal/mcp/client.go:35` | MCP CLI `--insecure` flag | Dev escape hatch for local-only MCP testing against a self-signed control plane. |
|
||||||
|
| `internal/cli/client.go:39` | `certctl --insecure` flag | Same shape as the agent flag — local dev only. |
|
||||||
|
| `internal/connector/target/f5/f5.go:128` | F5 BIG-IP iControl REST | F5 default install ships with a self-signed cert; operators who haven't replaced it use `config.Insecure`. The connector logs this on every dial and the operator-facing config docs this. |
|
||||||
|
| `internal/connector/issuer/acme/acme.go:146` | Pebble (ACME test server) | Hard-coded for tests that drive against Pebble locally. Pebble issues self-signed; verifying the chain would defeat the purpose. |
|
||||||
|
| `internal/service/network_scan.go:460` | Network scanner probe | Same rationale as `tlsprobe/probe.go` above — discovery surfaces broken certs by design. |
|
||||||
|
|
||||||
|
**What is NOT covered by this list:** `*_test.go` files use
|
||||||
|
`InsecureSkipVerify` freely against `httptest.Server` instances; that's a
|
||||||
|
test-fixture pattern, not a production trust decision. The grep guard
|
||||||
|
ignores `_test.go`.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- [`security.md`](security.md) — overall security posture, OCSP Must-Staple guidance, encryption-at-rest spec
|
||||||
|
- 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`).
|
||||||
+2
-2
@@ -107,13 +107,13 @@ The demo seeds certificates across multiple issuers, agents, and deployment targ
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/shankar0123/certctl.git
|
git clone https://github.com/shankar0123/certctl.git
|
||||||
cd certctl/deploy && docker compose up -d
|
cd certctl/deploy && docker compose up -d
|
||||||
# Dashboard at http://localhost:8443
|
# Dashboard at https://localhost:8443 (self-signed cert — pin deploy/test/certs/ca.crt)
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
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
|
## License
|
||||||
|
|
||||||
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.
|
certctl is source-available under the [Business Source License 1.1](../LICENSE). Free for any use except offering a competing managed service.
|
||||||
|
|
||||||
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).**
|
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
|
## What This Does
|
||||||
|
|
||||||
- Deploys certctl server (control plane) with PostgreSQL
|
- Deploys certctl server (control plane) with PostgreSQL
|
||||||
@@ -36,6 +38,13 @@ flowchart TD
|
|||||||
|
|
||||||
If you don't have a real domain or can't open port 80, see [Customization Tips](#customization-tips) below.
|
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
|
## Quick Start
|
||||||
|
|
||||||
### 1. Clone or copy this example
|
### 1. Clone or copy this example
|
||||||
@@ -122,7 +131,7 @@ docker compose logs -f certctl-server certctl-agent
|
|||||||
|
|
||||||
### 5. Access the dashboard
|
### 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:
|
You should see:
|
||||||
- An empty certificate inventory (no certs issued yet)
|
- An empty certificate inventory (no certs issued yet)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**What this does:** Issues wildcard certificates (e.g., `*.example.com`) from Let's Encrypt using DNS-01 challenge validation.
|
**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:
|
This example is ideal for:
|
||||||
- Issuing wildcard certificates (`*.example.com`)
|
- Issuing wildcard certificates (`*.example.com`)
|
||||||
- Services behind NAT, firewalls, or non-public networks
|
- Services behind NAT, firewalls, or non-public networks
|
||||||
@@ -9,6 +11,13 @@ This example is ideal for:
|
|||||||
- Internal PKI with public DNS names
|
- Internal PKI with public DNS names
|
||||||
- Scenarios where you have programmatic access to your DNS provider's API
|
- 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
|
## Prerequisites
|
||||||
|
|
||||||
Before running this example, you need:
|
Before running this example, you need:
|
||||||
@@ -74,7 +83,7 @@ This starts:
|
|||||||
|
|
||||||
### Step 5: Access the Dashboard
|
### 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
|
### Step 6: Create a Wildcard Certificate
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ services:
|
|||||||
- certctl-network
|
- certctl-network
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
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.
|
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
|
## The Use Case
|
||||||
|
|
||||||
You have:
|
You have:
|
||||||
@@ -45,6 +47,13 @@ flowchart TD
|
|||||||
- **Domain for ACME** (optional) — if using real Let's Encrypt, not needed for demo
|
- **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)
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
### 1. Clone or navigate to this directory
|
### 1. Clone or navigate to this directory
|
||||||
@@ -83,7 +92,7 @@ This spins up:
|
|||||||
|
|
||||||
### 4. Access the dashboard
|
### 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:
|
You should see:
|
||||||
- Empty cert inventory (fresh start)
|
- Empty cert inventory (fresh start)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Private CA + Traefik Example
|
# 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:
|
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)
|
- All services are internal (VPN, private networks)
|
||||||
@@ -29,6 +31,13 @@ flowchart TD
|
|||||||
C -->|TLS handshakes| D
|
C -->|TLS handshakes| D
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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)
|
## Quick Start (Self-Signed CA)
|
||||||
|
|
||||||
The simplest way to get running in 2 minutes:
|
The simplest way to get running in 2 minutes:
|
||||||
@@ -58,7 +67,7 @@ EOF
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 4. Access the dashboards
|
# 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
|
# - Traefik dashboard: http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,7 +121,7 @@ Once the stack is running:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Create a certificate profile in certctl (defines allowed key types, TTL, etc.)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"id": "prof-internal",
|
"id": "prof-internal",
|
||||||
@@ -123,7 +132,7 @@ curl -X POST http://localhost:8443/api/v1/profiles \
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
# 2. Create a renewal policy (defines issuer, renewal thresholds, etc.)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"id": "pol-internal",
|
"id": "pol-internal",
|
||||||
@@ -135,7 +144,7 @@ curl -X POST http://localhost:8443/api/v1/policies \
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
# 3. Create a certificate (triggers issuance immediately)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"common_name": "api.internal.local",
|
"common_name": "api.internal.local",
|
||||||
@@ -144,7 +153,7 @@ curl -X POST http://localhost:8443/api/v1/certificates \
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
# 4. Create a Traefik target (agent will deploy to this)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"id": "target-traefik-01",
|
"id": "target-traefik-01",
|
||||||
@@ -156,7 +165,7 @@ curl -X POST http://localhost:8443/api/v1/targets \
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
# 5. Create a deployment job (agent picks this up and deploys)
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"target_ids": ["target-traefik-01"]
|
"target_ids": ["target-traefik-01"]
|
||||||
@@ -209,16 +218,16 @@ The server provides a REST API on port 8443. Example queries:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all certificates
|
# List all certificates
|
||||||
curl http://localhost:8443/api/v1/certificates
|
curl https://localhost:8443/api/v1/certificates
|
||||||
|
|
||||||
# Check certificate status
|
# Check certificate status
|
||||||
curl http://localhost:8443/api/v1/certificates/{cert-id}
|
curl https://localhost:8443/api/v1/certificates/{cert-id}
|
||||||
|
|
||||||
# View audit trail
|
# View audit trail
|
||||||
curl http://localhost:8443/api/v1/audit
|
curl https://localhost:8443/api/v1/audit
|
||||||
|
|
||||||
# Check renewal policy compliance
|
# Check renewal policy compliance
|
||||||
curl http://localhost:8443/api/v1/policies/{policy-id}
|
curl https://localhost:8443/api/v1/policies/{policy-id}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Traefik Dashboard
|
### Traefik Dashboard
|
||||||
@@ -290,7 +299,7 @@ Changes are picked up automatically (file watcher enabled).
|
|||||||
docker compose logs certctl-agent | grep heartbeat
|
docker compose logs certctl-agent | grep heartbeat
|
||||||
|
|
||||||
# Check deployment job status
|
# 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
|
# Check Traefik is watching the directory
|
||||||
docker compose exec traefik ls -la /etc/traefik/certs/
|
docker compose exec traefik ls -la /etc/traefik/certs/
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This example demonstrates certctl managing certificates issued by **Smallstep step-ca** and deploying them to **HAProxy**.
|
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
|
## Scenario
|
||||||
|
|
||||||
You're a Smallstep user running step-ca as your internal PKI. You have HAProxy load balancers that need certificates. This setup:
|
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
|
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:
|
Wait for all services to reach healthy state:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -69,7 +78,7 @@ certctl-haproxy-... healthy
|
|||||||
Open your browser to:
|
Open your browser to:
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:8443
|
https://localhost:8443
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see an empty dashboard. This is expected — no certificates issued yet.
|
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).
|
This defines what certificates certctl can issue (key algorithm, max TTL, allowed names).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8443/api/v1/profiles \
|
curl -X POST https://localhost:8443/api/v1/profiles \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "internal-web",
|
"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.
|
This tells certctl where to deploy certificates on the HAProxy server.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8443/api/v1/targets \
|
curl -X POST https://localhost:8443/api/v1/targets \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "haproxy-01",
|
"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.
|
This ties a certificate profile to a deployment target and sets renewal thresholds.
|
||||||
|
|
||||||
```bash
|
```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' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "haproxy-internal-web",
|
"name": "haproxy-internal-web",
|
||||||
@@ -130,7 +139,7 @@ curl -X POST http://localhost:8443/api/v1/renewal-policies \
|
|||||||
Get the issuer ID:
|
Get the issuer ID:
|
||||||
|
|
||||||
```bash
|
```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.
|
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.
|
Request a certificate via the API. The server will sign it via step-ca.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8443/api/v1/certificates \
|
curl -X POST https://localhost:8443/api/v1/certificates \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"common_name": "api.internal.example.com",
|
"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:
|
Get the certificate ID and trigger deployment:
|
||||||
|
|
||||||
```bash
|
```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' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"target_id": "<target_id_from_step_4>"
|
"target_id": "<target_id_from_step_4>"
|
||||||
@@ -171,7 +180,7 @@ The agent will:
|
|||||||
|
|
||||||
### 8. Verify in Dashboard
|
### 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 certificate (status: Active, expiry in 90 days)
|
||||||
- 1 deployment job (status: Completed)
|
- 1 deployment job (status: Completed)
|
||||||
- 1 agent (heartbeat: recent)
|
- 1 agent (heartbeat: recent)
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/leanovate/gopter v0.2.11
|
||||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||||
github.com/pkg/sftp v1.13.10
|
github.com/pkg/sftp v1.13.10
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.45.0
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,9 +82,9 @@ require (
|
|||||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,29 +1,87 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
|
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||||
|
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||||
|
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||||
|
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||||
|
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||||
|
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||||
|
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||||
|
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||||
|
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||||
|
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||||
|
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||||
|
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||||
|
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||||
|
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
||||||
|
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
||||||
|
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
||||||
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
|
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||||
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
|
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||||
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
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 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
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 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns=
|
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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||||
|
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||||
|
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||||
|
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
|
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||||
|
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE=
|
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/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 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
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 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
|
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
|
||||||
github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4=
|
github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4=
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
@@ -38,8 +96,21 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
|||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -47,32 +118,121 @@ 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-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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
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/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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
|
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
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/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 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
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/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||||
|
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||||
|
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
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-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||||
|
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||||
|
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
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 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
|
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||||
|
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||||
|
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
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/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 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
@@ -85,26 +245,47 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
|||||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
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 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
|
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
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/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 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
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 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||||
|
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
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 h1:2ZKn+w/BJeL43sCxI2jhPLRv73oVVOjEKZjKkflyqxg=
|
||||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc=
|
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 h1:AKIJL2PfBX2uie0Mn5pxtG1+zut3hAVMZbRfoXecFzI=
|
||||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321/go.mod h1:JajVhkiG2bYSNYYPYuWG7WZHr42CTjMTcCjfInRNCqc=
|
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321/go.mod h1:JajVhkiG2bYSNYYPYuWG7WZHr42CTjMTcCjfInRNCqc=
|
||||||
|
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||||
|
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
|
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||||
|
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||||
|
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||||
|
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||||
|
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||||
|
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
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/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
@@ -117,22 +298,38 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
|||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||||
|
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
|
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
|
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
|
||||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||||
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
@@ -143,14 +340,33 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
|
|||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||||
|
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
|
||||||
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
|
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
|
||||||
|
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
||||||
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
|
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||||
|
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||||
|
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||||
|
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||||
|
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
|
||||||
|
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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.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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
@@ -158,6 +374,7 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
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/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
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/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 h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
|
||||||
@@ -168,11 +385,24 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
|
|||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
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 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
||||||
|
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||||
|
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
|
||||||
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
|
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
||||||
@@ -189,45 +419,180 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
|||||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||||
|
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||||
|
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
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.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||||
|
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||||
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
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.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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
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/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
@@ -236,44 +601,223 @@ golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
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/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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
|
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||||
|
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
|
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
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/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||||
|
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||||
|
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||||
|
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||||
|
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||||
|
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
||||||
|
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
||||||
|
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||||
|
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||||
|
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||||
google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0=
|
google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI=
|
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk=
|
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 h1:6GQBEOdGkX6MMTLT9V+TjtIRZCw9VPD5Z+yHY9wMgS0=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 h1:6GQBEOdGkX6MMTLT9V+TjtIRZCw9VPD5Z+yHY9wMgS0=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||||
|
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||||
|
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||||
|
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
||||||
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
||||||
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||||
|
|||||||
+40
-2
@@ -75,6 +75,14 @@ EXAMPLES:
|
|||||||
--server-url https://certctl.example.com \\
|
--server-url https://certctl.example.com \\
|
||||||
--api-key YOUR_API_KEY
|
--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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +330,7 @@ setup_linux_config() {
|
|||||||
# Agent ID (unique identifier in the fleet)
|
# Agent ID (unique identifier in the fleet)
|
||||||
CERTCTL_AGENT_ID=$AGENT_ID
|
CERTCTL_AGENT_ID=$AGENT_ID
|
||||||
|
|
||||||
# Control plane server URL
|
# Control plane server URL (HTTPS-only as of v2.2)
|
||||||
CERTCTL_SERVER_URL=$SERVER_URL
|
CERTCTL_SERVER_URL=$SERVER_URL
|
||||||
|
|
||||||
# API authentication key
|
# API authentication key
|
||||||
@@ -334,6 +342,21 @@ CERTCTL_KEYGEN_MODE=agent
|
|||||||
# Key storage directory (agent-side keygen)
|
# Key storage directory (agent-side keygen)
|
||||||
CERTCTL_KEY_DIR=$key_dir
|
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)
|
# Logging level (debug, info, warn, error)
|
||||||
# CERTCTL_LOG_LEVEL=info
|
# CERTCTL_LOG_LEVEL=info
|
||||||
|
|
||||||
@@ -373,7 +396,7 @@ setup_macos_config() {
|
|||||||
# Agent ID (unique identifier in the fleet)
|
# Agent ID (unique identifier in the fleet)
|
||||||
CERTCTL_AGENT_ID=$AGENT_ID
|
CERTCTL_AGENT_ID=$AGENT_ID
|
||||||
|
|
||||||
# Control plane server URL
|
# Control plane server URL (HTTPS-only as of v2.2)
|
||||||
CERTCTL_SERVER_URL=$SERVER_URL
|
CERTCTL_SERVER_URL=$SERVER_URL
|
||||||
|
|
||||||
# API authentication key
|
# API authentication key
|
||||||
@@ -385,6 +408,21 @@ CERTCTL_KEYGEN_MODE=agent
|
|||||||
# Key storage directory (agent-side keygen)
|
# Key storage directory (agent-side keygen)
|
||||||
CERTCTL_KEY_DIR=$key_dir
|
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)
|
# Logging level (debug, info, warn, error)
|
||||||
# CERTCTL_LOG_LEVEL=info
|
# CERTCTL_LOG_LEVEL=info
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -120,7 +121,7 @@ func TestGetCertificate_PathInjection(t *testing.T) {
|
|||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
// Force a 404 so we can distinguish "service was called" from
|
// Force a 404 so we can distinguish "service was called" from
|
||||||
// "parser accepted the ID"; a 200 with null body is also fine.
|
// "parser accepted the ID"; a 200 with null body is also fine.
|
||||||
mock.GetCertificateFn = func(id string) (*domain.ManagedCertificate, error) {
|
mock.GetCertificateFn = func(_ context.Context, id string) (*domain.ManagedCertificate, error) {
|
||||||
return nil, ErrMockNotFound
|
return nil, ErrMockNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ func TestUpdateCertificate_PathInjection(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.UpdateCertificateFn = func(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
mock.UpdateCertificateFn = func(_ context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||||
return nil, ErrMockNotFound
|
return nil, ErrMockNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ func TestArchiveCertificate_PathInjection(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.ArchiveCertificateFn = func(id string) error { return ErrMockNotFound }
|
mock.ArchiveCertificateFn = func(_ context.Context, id string) error { return ErrMockNotFound }
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/x", nil)
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/x", nil)
|
||||||
req.URL.Path = "/api/v1/certificates/" + tc.input
|
req.URL.Path = "/api/v1/certificates/" + tc.input
|
||||||
@@ -227,7 +228,7 @@ func TestGetCertificateVersions_MultiSegment(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.GetCertificateVersionsFn = func(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
mock.GetCertificateVersionsFn = func(_ context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
||||||
return []domain.CertificateVersion{}, 0, nil
|
return []domain.CertificateVersion{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,26 +247,30 @@ func TestGetCertificateVersions_MultiSegment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleOCSP_MultiSegment exercises the OCSP responder's 2-segment path
|
// TestHandleOCSP_MultiSegment exercises the OCSP responder's 2-segment path
|
||||||
// parser (/api/v1/ocsp/{issuer_id}/{serial_hex}). Each leg is attacker-
|
// parser (/.well-known/pki/ocsp/{issuer_id}/{serial_hex}). Each leg is
|
||||||
// controlled and the serial can be arbitrary length. This is a key adversarial
|
// attacker-controlled and the serial can be arbitrary length. This is a key
|
||||||
// surface because the serial is passed directly to the CA-operations service,
|
// adversarial surface because the serial is passed directly to the
|
||||||
// which is expected to treat it as an opaque identifier.
|
// 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) {
|
func TestHandleOCSP_MultiSegment(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
}{
|
}{
|
||||||
{"missing_serial", "/api/v1/ocsp/iss-local"},
|
{"missing_serial", "/.well-known/pki/ocsp/iss-local"},
|
||||||
{"missing_both", "/api/v1/ocsp/"},
|
{"missing_both", "/.well-known/pki/ocsp/"},
|
||||||
{"empty_issuer", "/api/v1/ocsp//01ABCDEF"},
|
{"empty_issuer", "/.well-known/pki/ocsp//01ABCDEF"},
|
||||||
{"empty_serial", "/api/v1/ocsp/iss-local/"},
|
{"empty_serial", "/.well-known/pki/ocsp/iss-local/"},
|
||||||
{"traversal_issuer", "/api/v1/ocsp/..%2F..%2Fetc/passwd/01"},
|
{"traversal_issuer", "/.well-known/pki/ocsp/..%2F..%2Fetc/passwd/01"},
|
||||||
{"null_byte_serial", "/api/v1/ocsp/iss-local/01\x00FF"},
|
{"null_byte_serial", "/.well-known/pki/ocsp/iss-local/01\x00FF"},
|
||||||
{"sql_injection_serial", "/api/v1/ocsp/iss-local/01'; DROP TABLE--"},
|
{"sql_injection_serial", "/.well-known/pki/ocsp/iss-local/01'; DROP TABLE--"},
|
||||||
{"negative_hex_serial", "/api/v1/ocsp/iss-local/-1"},
|
{"negative_hex_serial", "/.well-known/pki/ocsp/iss-local/-1"},
|
||||||
{"unicode_serial", "/api/v1/ocsp/iss-local/01\u2010FF"},
|
{"unicode_serial", "/.well-known/pki/ocsp/iss-local/01\u2010FF"},
|
||||||
{"extremely_long_serial", "/api/v1/ocsp/iss-local/" + strings.Repeat("F", 10000)},
|
{"extremely_long_serial", "/.well-known/pki/ocsp/iss-local/" + strings.Repeat("F", 10000)},
|
||||||
{"extra_segments", "/api/v1/ocsp/iss-local/01FF/extra/segments"},
|
{"extra_segments", "/.well-known/pki/ocsp/iss-local/01FF/extra/segments"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -277,7 +282,7 @@ func TestHandleOCSP_MultiSegment(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.GetOCSPResponseFn = func(issuerID, serialHex string) ([]byte, error) {
|
mock.GetOCSPResponseFn = func(_ context.Context, issuerID, serialHex string) ([]byte, error) {
|
||||||
return nil, ErrMockNotFound
|
return nil, ErrMockNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +305,9 @@ func TestHandleOCSP_MultiSegment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetDERCRL_IssuerPathInjection exercises /api/v1/crl/{issuer_id}.
|
// 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) {
|
func TestGetDERCRL_IssuerPathInjection(t *testing.T) {
|
||||||
for _, tc := range adversarialPathInputs() {
|
for _, tc := range adversarialPathInputs() {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -311,12 +318,12 @@ func TestGetDERCRL_IssuerPathInjection(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.GenerateDERCRLFn = func(issuerID string) ([]byte, error) {
|
mock.GenerateDERCRLFn = func(_ context.Context, issuerID string) ([]byte, error) {
|
||||||
return nil, ErrMockNotFound
|
return nil, ErrMockNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl/x", nil)
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/x", nil)
|
||||||
req.URL.Path = "/api/v1/crl/" + tc.input
|
req.URL.Path = "/.well-known/pki/crl/" + tc.input
|
||||||
req = req.WithContext(contextWithRequestID())
|
req = req.WithContext(contextWithRequestID())
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -76,7 +77,7 @@ func TestListCertificates_PaginationAbuse(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.ListCertificatesWithFilterFn = func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||||
// Sanity: page/perPage on the filter must never be negative
|
// Sanity: page/perPage on the filter must never be negative
|
||||||
// and perPage must never exceed 500 after parsing.
|
// and perPage must never exceed 500 after parsing.
|
||||||
if filter.Page < 1 {
|
if filter.Page < 1 {
|
||||||
@@ -133,7 +134,7 @@ func TestListCertificates_SortAbuse(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.ListCertificatesWithFilterFn = func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||||
return []domain.ManagedCertificate{}, 0, nil
|
return []domain.ManagedCertificate{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ func TestListCertificates_FieldsAbuse(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.ListCertificatesWithFilterFn = func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||||
return []domain.ManagedCertificate{}, 0, nil
|
return []domain.ManagedCertificate{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +220,7 @@ func TestListCertificates_TimeRangeAbuse(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.ListCertificatesWithFilterFn = func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||||
return []domain.ManagedCertificate{}, 0, nil
|
return []domain.ManagedCertificate{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +264,7 @@ func TestListCertificates_CursorAbuse(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.ListCertificatesWithFilterFn = func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||||
return []domain.ManagedCertificate{}, 0, nil
|
return []domain.ManagedCertificate{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +315,7 @@ func TestListCertificates_FilterInjection(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.ListCertificatesWithFilterFn = func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||||
return []domain.ManagedCertificate{}, 0, nil
|
return []domain.ManagedCertificate{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +375,7 @@ func TestCreateCertificate_BodyAbuse(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.CreateCertificateFn = func(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
mock.CreateCertificateFn = func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||||
// If we ever reach this, the handler accepted a malformed
|
// If we ever reach this, the handler accepted a malformed
|
||||||
// body. Return a sentinel that passes but flag it.
|
// body. Return a sentinel that passes but flag it.
|
||||||
c := cert
|
c := cert
|
||||||
@@ -419,7 +420,7 @@ func TestCreateCertificate_HugeBody(t *testing.T) {
|
|||||||
sb.WriteString(`]}`)
|
sb.WriteString(`]}`)
|
||||||
|
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.CreateCertificateFn = func(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
mock.CreateCertificateFn = func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||||
c := cert
|
c := cert
|
||||||
c.ID = "mc-huge"
|
c.ID = "mc-huge"
|
||||||
return &c, nil
|
return &c, nil
|
||||||
@@ -476,7 +477,7 @@ func TestRevokeCertificate_ReasonAbuse(t *testing.T) {
|
|||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
// The mock always returns "invalid revocation reason" so we
|
// The mock always returns "invalid revocation reason" so we
|
||||||
// verify the handler's errMsg→status mapping turns it into a 400.
|
// verify the handler's errMsg→status mapping turns it into a 400.
|
||||||
mock.RevokeCertificateFn = func(id string, reason string) error {
|
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||||||
// The service uses domain.IsValidRevocationReason. If we got
|
// The service uses domain.IsValidRevocationReason. If we got
|
||||||
// through to here with something bogus, simulate a real
|
// through to here with something bogus, simulate a real
|
||||||
// service error.
|
// service error.
|
||||||
@@ -500,7 +501,7 @@ func TestRevokeCertificate_ReasonAbuse(t *testing.T) {
|
|||||||
// service error message, which is fragile — this test catches regressions.
|
// service error message, which is fragile — this test catches regressions.
|
||||||
func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
|
func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.RevokeCertificateFn = func(id string, reason string) error {
|
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||||||
return fmt.Errorf("cannot revoke: certificate is already revoked")
|
return fmt.Errorf("cannot revoke: certificate is already revoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,8 +521,8 @@ func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
|
|||||||
// TestRevokeCertificate_NotFound verifies 404 mapping.
|
// TestRevokeCertificate_NotFound verifies 404 mapping.
|
||||||
func TestRevokeCertificate_NotFound(t *testing.T) {
|
func TestRevokeCertificate_NotFound(t *testing.T) {
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.RevokeCertificateFn = func(id string, reason string) error {
|
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||||||
return fmt.Errorf("certificate not found")
|
return fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bundle-5 / Audit H-007 / CWE-306 + CWE-288:
|
||||||
|
//
|
||||||
|
// Pre-Bundle-5, POST /api/v1/agents accepted any request and registered
|
||||||
|
// the supplied agent payload — any host with network reach to the server
|
||||||
|
// could enroll a fake agent and start polling for work without a shared
|
||||||
|
// secret. This file implements the bootstrap-token defence.
|
||||||
|
//
|
||||||
|
// Contract:
|
||||||
|
//
|
||||||
|
// - When CERTCTL_AGENT_BOOTSTRAP_TOKEN is empty (the v2.0.x default), the
|
||||||
|
// handler accepts registrations as before. main.go logs a one-shot WARN
|
||||||
|
// at startup announcing the v2.2.0 deprecation: bootstrap token will
|
||||||
|
// become required in v2.2.0 and unset will fail-loud.
|
||||||
|
//
|
||||||
|
// - When the token is non-empty, every registration request must carry
|
||||||
|
// `Authorization: Bearer <token>` whose value matches the configured
|
||||||
|
// token byte-for-byte. The compare uses crypto/subtle.ConstantTimeCompare
|
||||||
|
// to defeat timing oracles.
|
||||||
|
//
|
||||||
|
// - Mismatch / missing / malformed → 401 with
|
||||||
|
// {"error":"invalid_or_missing_bootstrap_token"} JSON body. The handler
|
||||||
|
// does NOT echo what the client sent (defence-in-depth against credential
|
||||||
|
// shape leakage to a token spray probe).
|
||||||
|
//
|
||||||
|
// Generation guidance (lives in docs/quickstart.md): `openssl rand -hex 32`
|
||||||
|
// for 256-bit entropy. Operators rotate by setting the new value, restarting
|
||||||
|
// the server, then re-issuing the new token to whoever drives agent
|
||||||
|
// enrollment.
|
||||||
|
|
||||||
|
// ErrBootstrapTokenInvalid is the sentinel returned by verifyBootstrapToken
|
||||||
|
// on any non-accept path (missing header, malformed Bearer token, mismatch).
|
||||||
|
// Handlers translate this into HTTP 401 with a fixed error string.
|
||||||
|
var ErrBootstrapTokenInvalid = errors.New("invalid or missing agent bootstrap token")
|
||||||
|
|
||||||
|
// Operator-visible deprecation WARN for the warn-mode default lives in
|
||||||
|
// cmd/server/main.go — emitted once at startup, not per-request, so a
|
||||||
|
// busy registration endpoint doesn't flood the log.
|
||||||
|
|
||||||
|
// verifyBootstrapToken returns nil when the request should proceed and
|
||||||
|
// ErrBootstrapTokenInvalid when it should be rejected.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
//
|
||||||
|
// r — incoming HTTP request
|
||||||
|
// expected — the configured token; empty = warn-mode pass-through
|
||||||
|
//
|
||||||
|
// Token extraction order:
|
||||||
|
// 1. `Authorization: Bearer <token>` (canonical)
|
||||||
|
// 2. (Future) X-Certctl-Bootstrap-Token: <token> — reserved, not yet read
|
||||||
|
//
|
||||||
|
// All comparisons use crypto/subtle.ConstantTimeCompare. Even when the
|
||||||
|
// presented token is the wrong length, we still copy bytes through the
|
||||||
|
// constant-time path so the timing signature is uniform.
|
||||||
|
func verifyBootstrapToken(r *http.Request, expected string) error {
|
||||||
|
if expected == "" {
|
||||||
|
// Warn-mode pass-through. The startup WARN in main.go is the
|
||||||
|
// operator-visible signal; this fast path stays silent so a busy
|
||||||
|
// endpoint doesn't add log noise per request.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return ErrBootstrapTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearerPrefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||||
|
return ErrBootstrapTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
presented := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||||
|
if presented == "" {
|
||||||
|
return ErrBootstrapTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time compare. We pad the shorter side so the comparison
|
||||||
|
// runs in a length-independent code path; subtle.ConstantTimeCompare
|
||||||
|
// requires equal-length slices.
|
||||||
|
expectedBytes := []byte(expected)
|
||||||
|
presentedBytes := []byte(presented)
|
||||||
|
if len(expectedBytes) != len(presentedBytes) {
|
||||||
|
// Run a dummy compare to keep the timing similar regardless of
|
||||||
|
// length-vs-content failure mode.
|
||||||
|
_ = subtle.ConstantTimeCompare(expectedBytes, expectedBytes)
|
||||||
|
return ErrBootstrapTokenInvalid
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare(expectedBytes, presentedBytes) != 1 {
|
||||||
|
return ErrBootstrapTokenInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user