v2.0.47: HTTPS Everywhere — TLS-only control plane, agents/CLI/MCP

Breaking change release. Plaintext HTTP listener removed. The certctl
control plane now terminates TLS 1.3 on :8443 via
http.Server.ListenAndServeTLS. No CERTCTL_TLS_ENABLED=false escape
hatch. No dual-listener mode. One-step cutover per docs/upgrade-to-tls.md.

Server
- cmd/server/tls.go: certHolder with SIGHUP hot-reload + atomic cert
  swap, buildServerTLSConfig (TLS 1.3 min, GetCertificate callback),
  preflightServerTLS validation
- cmd/server/main.go: ListenAndServeTLS in place of ListenAndServe,
  watchSIGHUP wiring, cert/key path config threading
- tls_test.go: 418-line regression coverage of reload, preflight,
  callback behavior, SAN validation

Config
- CERTCTL_TLS_CERT_PATH / CERTCTL_TLS_KEY_PATH (required)
- Plaintext rejection: agents/CLI/MCP pre-flight-fail on http://
  URLs with a pointer to docs/upgrade-to-tls.md

Agents, CLI, MCP
- All three pre-flight-reject http:// URLs with fail-loud diagnostic
- CERTCTL_SERVER_CA_BUNDLE_PATH for private-CA trust
- CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY for dev-only bypass
  (loud warning on startup)
- install-agent.sh emits both vars as commented template lines

docker-compose
- certctl-tls-init sidecar generates SAN-valid self-signed cert into
  deploy/test/certs/ on first boot
- All demo-stack curls pin against ca.crt with --cacert

Helm chart
- Three TLS provisioning modes, exactly one required:
  - server.tls.existingSecret (operator-supplied)
  - server.tls.certManager.enabled (cert-manager integration)
  - server.tls.selfSigned.enabled (eval only — not for production)
- server-certificate.yaml template for cert-manager mode
- helm install without a TLS source fails at template render with
  a pointer to docs/tls.md

CI
- .github/workflows/ci.yml Helm Chart Validation step renders the
  chart in both existingSecret and cert-manager modes, plus an
  inverse guard-regression test that asserts helm template MUST
  refuse to render when no TLS source is configured. Previously
  the single `helm template` invocation hit the certctl.tls.required
  fail-loud guard and exit-1'd CI. Four invocations now: lint
  (existingSecret), template (existingSecret), template
  (cert-manager), template (no args — must fail).

Integration tests
- deploy/test/integration_test.go stands up the Compose stack over
  HTTPS, extracts the CA bundle, and exercises every certctl API
  over https://localhost:8443
- All 34 integration subtests green (per Phase 8 local CI-parity)

Documentation
- New: docs/tls.md (provisioning patterns, rotation, SIGHUP reload)
- New: docs/upgrade-to-tls.md (one-step cutover, no-downgrade
  warnings, fleet-roll sequencing)
- CHANGELOG.md: v2.2.0 "HTTPS Everywhere — The Irony" entry
  (file heading unchanged; release tag is v2.0.47)
- All curls in docs/, examples/, deploy/helm/ guides use
  https://localhost:8443 --cacert

Verification
- grep -rn "ListenAndServe[^T]" cmd/ internal/ → 0 hits
- grep -rn "\"http://" cmd/ internal/ → 2 benign hits (Caddy admin
  API default, SSRF doc comment) — zero certctl endpoints
- Tasks #197–#206 (Phases 0–8) all closed in the tracker

Files: 65 changed, 3489 insertions, 372 deletions (pre-CI-fix).
This commit is contained in:
shankar0123
2026-04-20 03:31:05 +00:00
parent 04c7eca615
commit 52248be717
66 changed files with 3518 additions and 375 deletions
+8 -5
View File
@@ -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.
**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
# 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"}
```
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
@@ -307,8 +309,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:
```bash
# Dashboard with auth enabled
open http://localhost:8443
# Dashboard with auth enabled (HTTPS-only as of v2.2; browser will warn on the self-signed cert —
# accept the warning or trust `deploy/test/certs/ca.crt` in your OS keychain)
open https://localhost:8443
# API key: test-key-2026
# NGINX serving a self-signed placeholder
+100 -5
View File
@@ -4,8 +4,12 @@
#
# Spins up the full certctl platform with real CA backends for manual QA:
#
# 0. certctl-tls-init — one-shot init container; writes self-signed
# server.crt/.key/ca.crt into ./test/certs (bind
# mount, not a named volume — host-readable for
# the Go integration test binary)
# 1. PostgreSQL 16 — database (clean, no demo data)
# 2. certctl-server — control plane API + web dashboard on :8443
# 2. certctl-server — control plane API + web dashboard on :8443 (HTTPS)
# 3. certctl-agent — polls for work, deploys certs to NGINX
# 4. step-ca — private CA (JWK provisioner, auto-bootstraps)
# 5. Pebble — ACME test server (simulates Let's Encrypt)
@@ -16,15 +20,74 @@
# cd deploy
# 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
# NGINX: https://localhost:8444 (self-signed placeholder until cert deployed)
#
# Integration tests: `go test -tags integration ./deploy/test/...` picks up
# the CA bundle at ./test/certs/ca.crt automatically via CERTCTL_TEST_CA_BUNDLE.
#
# See docs/test-env.md for the full walkthrough.
# =============================================================================
services:
# ---------------------------------------------------------------------------
# HTTPS-Everywhere Phase 6 — self-signed TLS bootstrap for the test harness.
# ---------------------------------------------------------------------------
# Mirrors the production `certctl-tls-init` (see docker-compose.yml §10-43)
# but writes into a *host bind mount* (./test/certs) instead of a named
# volume. The named-volume approach works fine inside Docker but hides the
# CA bundle from the Go integration test binary that runs on the host; the
# bind mount exposes /etc/certctl/tls/ca.crt at deploy/test/certs/ca.crt
# so `newTestClient()` can load it into an x509.CertPool and validate the
# self-signed server cert. Test-only divergence, explicitly documented.
#
# The generated cert has SAN=DNS:certctl-server,DNS:localhost,IP:127.0.0.1
# so both in-cluster traffic (agent → certctl-server:8443) and host traffic
# (go test → localhost:8443) validate cleanly. Destroy via
# `docker compose -f docker-compose.test.yml down -v` + `rm -rf test/certs`
# to force regeneration. Keys written 0600, certs 0644, owned 1000:1000
# (the UID the server binary runs as inside its container per Dockerfile:64).
certctl-tls-init:
image: alpine/openssl:latest
container_name: certctl-test-tls-init
restart: "no"
entrypoint: /bin/sh
command:
- -c
- |
set -eu
CERT=/etc/certctl/tls/server.crt
KEY=/etc/certctl/tls/server.key
CA=/etc/certctl/tls/ca.crt
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
echo "TLS cert already present at $$CERT — skipping generation"
else
mkdir -p /etc/certctl/tls
openssl req -x509 -newkey ed25519 -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 (ed25519, 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
# ---------------------------------------------------------------------------
@@ -168,6 +231,12 @@ services:
condition: service_started
step-ca:
condition: service_healthy
# HTTPS-Everywhere Phase 6: block server boot until the init container
# has written server.crt / server.key / ca.crt into ./test/certs. The
# init container runs once and exits 0; service_completed_successfully
# makes that a gating dependency rather than a liveness one.
certctl-tls-init:
condition: service_completed_successfully
# Run as root so update-ca-certificates can write to /etc/ssl/certs.
# Container isolation provides the security boundary.
user: "0:0"
@@ -179,6 +248,12 @@ services:
# Server
CERTCTL_SERVER_HOST: 0.0.0.0
CERTCTL_SERVER_PORT: 8443
# HTTPS-Everywhere Phase 6: point the server at the init-container-generated
# cert/key pair (bind-mounted from ./test/certs). Same paths as production
# compose so the server binary code path is identical; only the host-side
# storage differs (bind mount vs named volume — see §certctl-tls-init block).
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
CERTCTL_LOG_LEVEL: debug
# Auth — API key required (production-like)
@@ -224,12 +299,22 @@ services:
- ./test/setup-trust.sh:/app/setup-trust.sh:ro
# step-ca data volume (root cert at /certs/root_ca.crt, key at /secrets/provisioner_key)
- stepca_data:/stepca-data:ro
# HTTPS-Everywhere Phase 6: read-only bind mount of the init-generated
# TLS material. The init container writes here; server reads here; the
# agent mounts the same host path at the same container path (see below)
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
- ./test/certs:/etc/certctl/tls:ro
networks:
certctl-test:
ipv4_address: 10.30.50.6
healthcheck:
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the Bearer token
test: ["CMD", "curl", "-f", "-H", "Authorization: Bearer test-key-2026", "http://localhost:8443/health"]
# HTTPS-Everywhere Phase 6: healthcheck now speaks TLS with --cacert to
# verify the self-signed server cert against the init-generated bundle.
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the
# Bearer token. curl exits non-zero on both TLS handshake failure and
# non-2xx status — either failure keeps depends_on: {condition:
# service_healthy} from unblocking the agent, which is what we want.
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "-H", "Authorization: Bearer test-key-2026", "https://localhost:8443/health"]
interval: 10s
timeout: 5s
start_period: 30s
@@ -290,7 +375,13 @@ services:
certctl-server:
condition: service_healthy
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_AGENT_NAME: test-agent-01
CERTCTL_AGENT_ID: agent-test-01
@@ -300,6 +391,10 @@ services:
volumes:
- agent_keys:/var/lib/certctl/keys
- nginx_certs:/nginx-certs
# HTTPS-Everywhere Phase 6: same bind mount as the server, same path,
# so /etc/certctl/tls/ca.crt resolves to the identical bytes. This is
# the only way the CN=certctl-server cert validates on the agent side.
- ./test/certs:/etc/certctl/tls:ro
networks:
certctl-test:
ipv4_address: 10.30.50.8
+55 -2
View File
@@ -1,4 +1,47 @@
services:
# HTTPS-Everywhere Phase 3 — self-signed TLS bootstrap (init container).
# Generates a CN=certctl-server ed25519 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.
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 ed25519 -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 (ed25519, 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
postgres:
image: postgres:16-alpine
@@ -50,10 +93,14 @@ services:
depends_on:
postgres:
condition: service_healthy
certctl-tls-init:
condition: service_completed_successfully
environment:
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable
CERTCTL_SERVER_HOST: 0.0.0.0
CERTCTL_SERVER_PORT: 8443
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
CERTCTL_LOG_LEVEL: info
CERTCTL_AUTH_TYPE: none
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
@@ -61,10 +108,12 @@ services:
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key} # AES-256-GCM for dynamic issuer/target config
ports:
- "8443:8443"
volumes:
- certs:/etc/certctl/tls:ro
networks:
- certctl-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8443/health"]
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "https://localhost:8443/health"]
interval: 10s
timeout: 5s
retries: 5
@@ -99,13 +148,15 @@ services:
certctl-server:
condition: service_healthy
environment:
CERTCTL_SERVER_URL: http://certctl-server:8443
CERTCTL_SERVER_URL: https://certctl-server:8443
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
CERTCTL_AGENT_NAME: docker-agent
CERTCTL_LOG_LEVEL: info
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
volumes:
- agent_keys:/var/lib/certctl/keys
- certs:/etc/certctl/tls:ro
networks:
- certctl-network
healthcheck:
@@ -134,3 +185,5 @@ volumes:
driver: local
agent_keys:
driver: local
certs:
driver: local
+7 -4
View File
@@ -236,10 +236,12 @@ kubectl get svc -l app.kubernetes.io/instance=certctl
kubectl get ingress
kubectl describe ingress certctl
# Test API connectivity
# Test API connectivity (HTTPS-only as of v2.2)
POD=$(kubectl get pods -l app.kubernetes.io/component=server -o jsonpath='{.items[0].metadata.name}')
kubectl port-forward $POD 8443:8443 &
curl -H "Authorization: Bearer $API_KEY" http://localhost:8443/health
# If the chart provisioned a self-signed cert, fetch the CA bundle from the TLS secret first:
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
curl --cacert /tmp/certctl-ca.crt -H "Authorization: Bearer $API_KEY" https://localhost:8443/health
```
### Step 6: Access the Dashboard
@@ -333,9 +335,10 @@ kubectl logs $POD | tail -20
# Port forward to API
kubectl port-forward svc/certctl-server 8443:8443 &
# Create a test certificate
# Create a test certificate (HTTPS-only as of v2.2 — pin the chart-provisioned CA bundle)
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
API_KEY="your-api-key"
curl -X POST http://localhost:8443/api/v1/certificates \
curl --cacert /tmp/certctl-ca.crt -X POST https://localhost:8443/api/v1/certificates \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
+4 -2
View File
@@ -33,9 +33,11 @@ kubectl get pods -l app.kubernetes.io/instance=certctl
# View server logs
kubectl logs -l app.kubernetes.io/component=server -f
# Access the API
# Access the API (HTTPS-only as of v2.2; use --cacert or -k depending on your cert provisioning)
kubectl port-forward svc/certctl-server 8443:8443 &
curl http://localhost:8443/health
# If the chart provisioned a self-signed cert, fetch the CA bundle from the secret first:
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
curl --cacert /tmp/certctl-ca.crt https://localhost:8443/health
```
## Next Steps
+20 -14
View File
@@ -4,36 +4,46 @@
{{- else if contains "NodePort" .Values.server.service.type }}
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "certctl.fullname" . }}-server)
echo http://$NODE_IP:$NODE_PORT
echo https://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.server.service.type }}
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server --template "{.status.loadBalancer.ingress[0].ip}")
echo http://$SERVICE_IP:{{ .Values.server.service.port }}
echo https://$SERVICE_IP:{{ .Values.server.service.port }}
{{- else }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=server" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
echo "Visit https://127.0.0.1:8443 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8443:$CONTAINER_PORT
{{- end }}
2. Get the default API key:
2. Talk to the HTTPS-only server from your workstation:
# Export the CA bundle that signed the server cert (self-signed or cert-manager-issued)
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.tls.secretName" . }} \
-o jsonpath='{.data.ca\.crt}' | base64 --decode > /tmp/certctl-ca.crt
# (If ca.crt is empty, fall back to tls.crt — typical when the Secret
# was created from a self-signed bootstrap cert without a separate CA.)
# Adapt the URL below to match the Server URL printed in step 1.
curl --cacert /tmp/certctl-ca.crt https://127.0.0.1:8443/health
3. Get the default API key:
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server -o jsonpath="{.data.api-key}" | base64 --decode; echo
3. Get PostgreSQL connection details:
4. Get PostgreSQL connection details:
Host: {{ include "certctl.fullname" . }}-postgres.{{ .Release.Namespace }}.svc.cluster.local
Port: 5432
Database: {{ .Values.postgresql.auth.database }}
Username: {{ .Values.postgresql.auth.username }}
Password: $(kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-postgres -o jsonpath="{.data.password}" | base64 --decode)
4. Check deployment status:
5. Check deployment status:
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
5. View server logs:
6. View server logs:
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=server -f
{{- if .Values.agent.enabled }}
6. View agent logs:
7. View agent logs:
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=agent -f
{{- end }}
@@ -58,11 +68,7 @@ IMPORTANT NOTES FOR PRODUCTION:
- Use an external PostgreSQL managed service (AWS RDS, Cloud SQL, etc.)
- Set postgresql.enabled=false and configure CERTCTL_DATABASE_URL in values
5. Enable HTTPS/TLS using an Ingress with certificate management:
- Configure cert-manager for automatic TLS certificate renewal
- Update ingress values with your domain and certificate issuer
6. Review security contexts and network policies:
5. Review security contexts and network policies:
- All containers run as non-root
- Implement network policies to restrict traffic between components
- Consider pod security policies or security standards for your cluster
+48 -2
View File
@@ -118,8 +118,54 @@ postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ includ
{{- end }}
{{/*
Server URL (for agents)
Server URL (for agents). HTTPS-only as of v2.2 see docs/tls.md.
*/}}
{{- define "certctl.serverURL" -}}
http://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
https://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
{{- end }}
{{/*
TLS Secret name resolver.
Operator-facing precedence:
1. server.tls.existingSecret operator points at a pre-existing kubernetes.io/tls Secret
2. server.tls.certManager.secretName explicit secret name for the cert-manager Certificate CR
3. "<fullname>-tls" default when cert-manager is enabled but secretName is blank
Never emits an empty string that case is already excluded by certctl.tls.required below,
which must be invoked by any template that depends on the resolved secret name.
*/}}
{{- define "certctl.tls.secretName" -}}
{{- if .Values.server.tls.existingSecret -}}
{{- .Values.server.tls.existingSecret -}}
{{- else if .Values.server.tls.certManager.secretName -}}
{{- .Values.server.tls.certManager.secretName -}}
{{- else -}}
{{- printf "%s-tls" (include "certctl.fullname" .) -}}
{{- end -}}
{{- end }}
{{/*
TLS configuration gate.
HTTPS is the only supported listener mode (v2.2+). The server refuses to start
without a cert/key pair mounted at server.tls.mountPath, so `helm template` /
`helm install` must fail loudly at render-time rather than shipping a broken
Deployment that crash-loops with "tls config required".
Operators MUST configure EXACTLY ONE of:
(a) server.tls.existingSecret: <name-of-kubernetes.io/tls-secret>
(b) server.tls.certManager.enabled: true (+ issuerRef.name populated)
Any template that mounts the TLS Secret must call
`{{ include "certctl.tls.required" . }}` at the top so this guard runs once
per affected resource. No-op when configured correctly.
*/}}
{{- define "certctl.tls.required" -}}
{{- if and (not .Values.server.tls.existingSecret) (not .Values.server.tls.certManager.enabled) -}}
{{- fail "\n\ncertctl refuses to start without TLS.\n\nSet EXACTLY ONE of:\n --set server.tls.existingSecret=<your-kubernetes.io/tls-secret-name>\nOR\n --set server.tls.certManager.enabled=true \\\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md for the full setup walkthrough, including bootstrap\nguidance for air-gapped clusters without cert-manager.\n" -}}
{{- end -}}
{{- if and .Values.server.tls.certManager.enabled (not .Values.server.tls.certManager.issuerRef.name) -}}
{{- fail "\n\nserver.tls.certManager.enabled=true but server.tls.certManager.issuerRef.name is empty.\n\nSet:\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md.\n" -}}
{{- end -}}
{{- end }}
@@ -1,4 +1,5 @@
{{- if .Values.agent.enabled }}
{{- include "certctl.tls.required" . }}
{{- if eq .Values.agent.kind "DaemonSet" }}
apiVersion: apps/v1
kind: DaemonSet
@@ -53,6 +54,8 @@ spec:
fieldPath: metadata.name
- name: CERTCTL_KEY_DIR
value: {{ .Values.agent.keyDir }}
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
{{- if .Values.agent.discoveryDirs }}
- name: CERTCTL_DISCOVERY_DIRS
valueFrom:
@@ -70,12 +73,19 @@ spec:
mountPath: {{ .Values.agent.keyDir }}
- name: tmp
mountPath: /tmp
- name: server-tls
mountPath: {{ .Values.server.tls.mountPath }}
readOnly: true
volumes:
- name: agent-keys
emptyDir:
sizeLimit: 1Gi
- name: tmp
emptyDir: {}
- name: server-tls
secret:
secretName: {{ include "certctl.tls.secretName" . }}
defaultMode: 0400
{{- else if eq .Values.agent.kind "Deployment" }}
apiVersion: apps/v1
kind: Deployment
@@ -135,6 +145,8 @@ spec:
{{- end }}
- name: CERTCTL_KEY_DIR
value: {{ .Values.agent.keyDir }}
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
{{- if .Values.agent.discoveryDirs }}
- name: CERTCTL_DISCOVERY_DIRS
valueFrom:
@@ -152,11 +164,18 @@ spec:
mountPath: {{ .Values.agent.keyDir }}
- name: tmp
mountPath: /tmp
- name: server-tls
mountPath: {{ .Values.server.tls.mountPath }}
readOnly: true
volumes:
- name: agent-keys
emptyDir:
sizeLimit: 1Gi
- name: tmp
emptyDir: {}
- name: server-tls
secret:
secretName: {{ include "certctl.tls.secretName" . }}
defaultMode: 0400
{{- end }}
{{- end }}
+13 -3
View File
@@ -1,14 +1,24 @@
{{- if .Values.ingress.enabled }}
{{- if and .Values.ingress.certManager.enabled (not .Values.ingress.certManager.issuerRef.name) -}}
{{- fail "\n\ningress.certManager.enabled=true but ingress.certManager.issuerRef.name is empty.\n\nSet:\n --set ingress.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nThis is separate from server.tls.certManager — it issues the external-facing\nIngress cert, not the in-cluster server TLS cert. See docs/tls.md.\n" -}}
{{- end -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "certctl.fullname" . }}
labels:
{{- include "certctl.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- if .Values.ingress.certManager.enabled }}
{{- if eq .Values.ingress.certManager.issuerRef.kind "ClusterIssuer" }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
{{- else }}
cert-manager.io/issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
{{- end }}
{{- end }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
@@ -33,7 +43,7 @@ spec:
pathType: {{ .pathType }}
backend:
service:
name: {{ include "certctl.fullname" . }}-server
name: {{ include "certctl.fullname" $ }}-server
port:
number: {{ $.Values.server.service.port }}
{{- end }}
@@ -0,0 +1,31 @@
{{- if .Values.server.tls.certManager.enabled }}
{{- include "certctl.tls.required" . }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "certctl.fullname" . }}-server-tls
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
secretName: {{ include "certctl.tls.secretName" . }}
commonName: {{ .Values.server.tls.certManager.commonName | quote }}
dnsNames:
{{- range .Values.server.tls.certManager.dnsNames }}
- {{ . | quote }}
{{- end }}
duration: {{ .Values.server.tls.certManager.duration }}
renewBefore: {{ .Values.server.tls.certManager.renewBefore }}
usages:
- server auth
- digital signature
- key encipherment
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
issuerRef:
name: {{ .Values.server.tls.certManager.issuerRef.name | quote }}
kind: {{ .Values.server.tls.certManager.issuerRef.kind }}
group: {{ .Values.server.tls.certManager.issuerRef.group }}
{{- end }}
@@ -1,3 +1,4 @@
{{- include "certctl.tls.required" . }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -32,7 +33,7 @@ spec:
image: {{ include "certctl.serverImage" . }}
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
ports:
- name: http
- name: https
containerPort: {{ .Values.server.port }}
protocol: TCP
env:
@@ -40,6 +41,10 @@ spec:
value: "0.0.0.0"
- name: CERTCTL_SERVER_PORT
value: "{{ .Values.server.port }}"
- name: CERTCTL_SERVER_TLS_CERT_PATH
value: "{{ .Values.server.tls.mountPath }}/tls.crt"
- name: CERTCTL_SERVER_TLS_KEY_PATH
value: "{{ .Values.server.tls.mountPath }}/tls.key"
- name: CERTCTL_DATABASE_URL
valueFrom:
secretKeyRef:
@@ -172,12 +177,19 @@ spec:
volumeMounts:
- name: tmp
mountPath: /tmp
- name: tls
mountPath: {{ .Values.server.tls.mountPath }}
readOnly: true
{{- if .Values.server.volumeMounts }}
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
- name: tls
secret:
secretName: {{ include "certctl.tls.secretName" . }}
defaultMode: 0400
{{- if .Values.server.volumes }}
{{- toYaml .Values.server.volumes | nindent 8 }}
{{- end }}
@@ -13,8 +13,8 @@ spec:
type: {{ .Values.server.service.type }}
ports:
- port: {{ .Values.server.service.port }}
targetPort: http
targetPort: https
protocol: TCP
name: http
name: https
selector:
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
+52 -4
View File
@@ -48,11 +48,12 @@ server:
drop:
- ALL
# Liveness and readiness probes
# Liveness and readiness probes (HTTPS-only as of v2.2)
livenessProbe:
httpGet:
path: /health
port: http
port: https
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
@@ -61,12 +62,50 @@ server:
readinessProbe:
httpGet:
path: /readyz
port: http
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
# TLS configuration — REQUIRED. HTTPS is the only supported mode (v2.2+).
# Operator must configure EXACTLY ONE of:
# (a) server.tls.existingSecret: <name> # pre-existing kubernetes.io/tls Secret
# (b) server.tls.certManager.enabled: true # provision a cert-manager Certificate CR
# Refusing to set either makes `helm template` fail with a diagnostic pointing at docs/tls.md.
tls:
# Name of a pre-existing Secret (type kubernetes.io/tls) holding tls.crt + tls.key (+ optional ca.crt).
# Leave empty to fall through to the cert-manager path.
existingSecret: ""
# Mount path for the TLS Secret inside the server + agent containers.
mountPath: /etc/certctl/tls
# cert-manager auto-provisioning. Opt-in (off by default per milestone §3.4).
certManager:
enabled: false
# Secret name the cert-manager Certificate CR writes into. Agents and the server
# both read from this Secret. If empty, defaults to "<fullname>-tls".
secretName: ""
# Cert-manager issuer reference.
issuerRef:
name: "" # e.g. "letsencrypt-prod" or "internal-ca"
kind: ClusterIssuer # ClusterIssuer or Issuer
group: cert-manager.io
# Subject fields on the issued cert.
commonName: "certctl-server"
dnsNames:
- certctl-server
- localhost
# Certificate lifetime + renewal window.
duration: 2160h # 90 days
renewBefore: 360h # 15 days
# Service type (ClusterIP, LoadBalancer, NodePort)
service:
type: ClusterIP
@@ -356,7 +395,16 @@ ingress:
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
# Optional cert-manager integration for the public-facing Ingress cert.
# This is completely independent of server.tls.* — the Ingress terminates
# an *additional* TLS hop between the internet and the in-cluster Service.
# Leave disabled unless an Ingress is exposing certctl to the outside world.
certManager:
enabled: false
issuerRef:
name: "" # e.g. "letsencrypt-prod"
kind: ClusterIssuer # ClusterIssuer or Issuer
hosts:
- host: certctl.local
paths:
+92 -8
View File
@@ -47,11 +47,30 @@ func envOr(key, fallback string) string {
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 (
serverURL = envOr("CERTCTL_TEST_SERVER_URL", "http://localhost:8443")
apiKey = envOr("CERTCTL_TEST_API_KEY", "test-key-2026")
dbURL = envOr("CERTCTL_TEST_DB_URL", "postgres://certctl:testpass@localhost:5432/certctl?sslmode=disable")
nginxTLS = envOr("CERTCTL_TEST_NGINX_TLS", "localhost:8444")
serverURL = envOr("CERTCTL_TEST_SERVER_URL", "https://localhost:8443")
apiKey = envOr("CERTCTL_TEST_API_KEY", "test-key-2026")
dbURL = envOr("CERTCTL_TEST_DB_URL", "postgres://certctl:testpass@localhost:5432/certctl?sslmode=disable")
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
}
// 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 {
return &testClient{
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: buildTLSConfig(),
},
},
baseURL: serverURL,
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) {
url := c.baseURL + path
req, err := http.NewRequest(method, url, body)
@@ -724,11 +801,18 @@ func TestIntegrationSuite(t *testing.T) {
}
// Check DER CRL served unauthenticated under /.well-known/pki/ per
// RFC 5280 §5 + RFC 8615 (M-006). Use a plain http.Get — no Bearer
// token — to prove the endpoint is reachable by relying parties that
// have no certctl API credentials.
// RFC 5280 §5 + RFC 8615 (M-006). Use newUnauthHTTPClient() — no
// 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 := http.Get(serverURL + "/.well-known/pki/crl/iss-local")
resp, err := newUnauthHTTPClient().Get(serverURL + "/.well-known/pki/crl/iss-local")
if err != nil {
t.Fatalf("GET DER CRL: %v", err)
}
+53 -9
View File
@@ -19,16 +19,29 @@
//
// Environment overrides:
//
// CERTCTL_QA_SERVER_URL (default: http://localhost:8443)
// CERTCTL_QA_API_KEY (default: change-me-in-production)
// CERTCTL_QA_DB_URL (default: postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable)
// CERTCTL_QA_REPO_DIR (default: ../.. — the certctl repo root)
// CERTCTL_QA_SERVER_URL (default: https://localhost:8443)
// CERTCTL_QA_API_KEY (default: change-me-in-production)
// CERTCTL_QA_DB_URL (default: postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable)
// 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).
package integration_test
import (
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
@@ -50,10 +63,12 @@ func qaEnv(key, fallback string) string {
}
var (
qaServerURL = qaEnv("CERTCTL_QA_SERVER_URL", "http://localhost:8443")
qaAPIKey = qaEnv("CERTCTL_QA_API_KEY", "change-me-in-production")
qaDBURL = qaEnv("CERTCTL_QA_DB_URL", "postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable")
qaRepoDir = qaEnv("CERTCTL_QA_REPO_DIR", filepath.Join("..", ".."))
qaServerURL = qaEnv("CERTCTL_QA_SERVER_URL", "https://localhost:8443")
qaAPIKey = qaEnv("CERTCTL_QA_API_KEY", "change-me-in-production")
qaDBURL = qaEnv("CERTCTL_QA_DB_URL", "postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable")
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")
)
// ---------------------------------------------------------------------------
@@ -66,9 +81,38 @@ type qaClient struct {
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 {
return &qaClient{
http: &http.Client{Timeout: 30 * time.Second},
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{TLSClientConfig: buildQATLSConfig()},
},
baseURL: qaServerURL,
apiKey: qaAPIKey,
}
+30 -4
View File
@@ -1,5 +1,30 @@
#!/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
# =============================================================================
#
@@ -32,10 +57,11 @@ set -euo pipefail
# Config
# ---------------------------------------------------------------------------
COMPOSE_FILE="docker-compose.test.yml"
API_URL="http://localhost:8443"
API_URL="https://localhost:8443"
API_KEY="test-key-2026"
NGINX_TLS="localhost:8444"
AUTH_HEADER="Authorization: Bearer ${API_KEY}"
CACERT="./certs/ca.crt"
# Flags
BUILD=true
@@ -91,7 +117,7 @@ header() {
# API helper: GET endpoint, return JSON body. Exits 1 on HTTP error.
api_get() {
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
@@ -99,10 +125,10 @@ api_post() {
local path="$1"
local body="${2:-}"
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
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
}