Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 397d2a1588 | |||
| 65567d0d83 | |||
| 0abd984285 | |||
| ec21c9bb29 | |||
| cb2ef9d0e7 | |||
| da79dde611 | |||
| 935ea1bf9f | |||
| 11e752ac01 | |||
| 03472072b8 | |||
| 63e6f3ef91 | |||
| a00bb349c4 | |||
| 78c7bc16b0 | |||
| 1f98f31f83 | |||
| 6d508cf53f | |||
| 591dcfb139 | |||
| 4881056528 | |||
| 6da60d1287 | |||
| baafab50c5 | |||
| 9b5b9ad3a2 | |||
| 1b4c55af65 | |||
| 01607f8614 | |||
| d27cf3545b | |||
| 144bd5fdf9 | |||
| c617a686d6 | |||
| 09ff51c5ae | |||
| 5716d227b1 | |||
| 67ccbb46fd | |||
| 6d5ca5ec9d | |||
| fde5b39d53 | |||
| de9264baf7 | |||
| 305c7dc851 | |||
| 10f9574bcd | |||
| a0afa7ab6f | |||
| 4655f68e87 | |||
| 677c28aeca | |||
| 1f065d67bb | |||
| fe70910755 | |||
| fd6f236a5c | |||
| 200bdf990f | |||
| 3e5cc86c5a | |||
| 3e3e68fd3a | |||
| fd6ae98222 | |||
| b4ac0cda43 | |||
| a41f271c58 | |||
| be72627aeb | |||
| ef92b07448 | |||
| 5b301f9354 | |||
| 2e297b430e | |||
| 7bc6ad9823 | |||
| 6ccdf45179 | |||
| 69483786aa | |||
| 1f5ab16b18 | |||
| a8d04cded4 | |||
| 8308beb5bb | |||
| b9633e5b1a | |||
| d55807947e | |||
| d9fd0a147e | |||
| 03593d4304 | |||
| 87355c3efb | |||
| f92d148881 | |||
| 50c520e1ff | |||
| 8380cb7946 | |||
| 6d8ab54f46 | |||
| e19c240a79 | |||
| 5c38bc3bfe | |||
| b5687aece8 | |||
| cdb6ebdb6a | |||
| bb85f1a56e | |||
| 44c4d89011 | |||
| eaccbcdcf1 | |||
| 4e3cff0729 | |||
| 09c819d424 |
@@ -31,9 +31,25 @@ jobs:
|
|||||||
- name: Go Vet
|
- name: Go Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Install golangci-lint
|
||||||
|
run: |
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.4
|
||||||
|
|
||||||
|
- name: Run golangci-lint
|
||||||
|
run: golangci-lint run ./... --timeout 5m
|
||||||
|
|
||||||
|
- name: Install govulncheck
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
run: govulncheck ./...
|
||||||
|
|
||||||
|
- name: Race Detection
|
||||||
|
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... -count=1 -timeout 300s
|
||||||
|
|
||||||
- name: Go Test with Coverage
|
- name: Go Test with Coverage
|
||||||
run: |
|
run: |
|
||||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... -count=1 -cover -coverprofile=coverage.out
|
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... -count=1 -cover -coverprofile=coverage.out
|
||||||
|
|
||||||
- name: Check Coverage Thresholds
|
- name: Check Coverage Thresholds
|
||||||
run: |
|
run: |
|
||||||
@@ -41,7 +57,7 @@ jobs:
|
|||||||
echo "=== Coverage Report ==="
|
echo "=== Coverage Report ==="
|
||||||
go tool cover -func=coverage.out | tail -1
|
go tool cover -func=coverage.out | tail -1
|
||||||
|
|
||||||
# Check service layer coverage (target: 70%+)
|
# Check service layer coverage (target: 60%+)
|
||||||
SERVICE_COV=$(go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
SERVICE_COV=$(go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||||
echo "Service layer coverage: ${SERVICE_COV}%"
|
echo "Service layer coverage: ${SERVICE_COV}%"
|
||||||
|
|
||||||
@@ -49,13 +65,29 @@ jobs:
|
|||||||
HANDLER_COV=$(go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
HANDLER_COV=$(go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||||
echo "Handler layer coverage: ${HANDLER_COV}%"
|
echo "Handler layer coverage: ${HANDLER_COV}%"
|
||||||
|
|
||||||
|
# Check domain layer coverage (target: 40%+)
|
||||||
|
DOMAIN_COV=$(go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||||
|
echo "Domain layer coverage: ${DOMAIN_COV}%"
|
||||||
|
|
||||||
|
# Check middleware layer coverage (target: 50%+)
|
||||||
|
MIDDLEWARE_COV=$(go tool cover -func=coverage.out | grep 'internal/api/middleware' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||||
|
echo "Middleware layer coverage: ${MIDDLEWARE_COV}%"
|
||||||
|
|
||||||
# Fail if thresholds not met
|
# Fail if thresholds not met
|
||||||
if [ "$(echo "$SERVICE_COV < 30" | bc -l)" -eq 1 ]; then
|
if [ "$(echo "$SERVICE_COV < 55" | bc -l)" -eq 1 ]; then
|
||||||
echo "::error::Service layer coverage ${SERVICE_COV}% is below 30% threshold"
|
echo "::error::Service layer coverage ${SERVICE_COV}% is below 55% threshold"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$(echo "$HANDLER_COV < 50" | bc -l)" -eq 1 ]; then
|
if [ "$(echo "$HANDLER_COV < 60" | bc -l)" -eq 1 ]; then
|
||||||
echo "::error::Handler layer coverage ${HANDLER_COV}% is below 50% threshold"
|
echo "::error::Handler layer coverage ${HANDLER_COV}% is below 60% threshold"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(echo "$DOMAIN_COV < 40" | bc -l)" -eq 1 ]; then
|
||||||
|
echo "::error::Domain layer coverage ${DOMAIN_COV}% is below 40% threshold"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(echo "$MIDDLEWARE_COV < 30" | bc -l)" -eq 1 ]; then
|
||||||
|
echo "::error::Middleware layer coverage ${MIDDLEWARE_COV}% is below 30% threshold"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Coverage thresholds passed!"
|
echo "Coverage thresholds passed!"
|
||||||
@@ -93,3 +125,20 @@ jobs:
|
|||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: npx vite build
|
run: npx vite build
|
||||||
|
|
||||||
|
helm-lint:
|
||||||
|
name: Helm Chart Validation
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
with:
|
||||||
|
version: '3.13.0'
|
||||||
|
|
||||||
|
- name: Lint Helm Chart
|
||||||
|
run: helm lint deploy/helm/certctl/
|
||||||
|
|
||||||
|
- name: Template Helm Chart
|
||||||
|
run: helm template certctl deploy/helm/certctl/ > /dev/null
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ jobs:
|
|||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
docker pull shankar0123.docker.scarf.sh/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||||
docker pull ghcr.io/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
docker pull shankar0123.docker.scarf.sh/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ vendor/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.log
|
*.log
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Private keys (agent-generated, never commit)
|
||||||
|
cmd/agent/*.key
|
||||||
|
cmd/agent/*.pem
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
@@ -60,6 +65,7 @@ certctl-cli
|
|||||||
|
|
||||||
# Private strategy docs
|
# Private strategy docs
|
||||||
roadmap.md
|
roadmap.md
|
||||||
|
SECURITY_REMEDIATION.md
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
settings:
|
||||||
|
staticcheck:
|
||||||
|
checks:
|
||||||
|
- "all"
|
||||||
|
- "-ST1005" # error strings should not be capitalized (pre-existing style)
|
||||||
|
- "-ST1000" # package comment style (pre-existing)
|
||||||
|
- "-ST1003" # naming convention (pre-existing)
|
||||||
|
- "-ST1016" # method receiver naming (pre-existing)
|
||||||
|
- "-QF1001" # apply De Morgan's law (style suggestion)
|
||||||
|
- "-QF1003" # convert if/else to switch (style suggestion)
|
||||||
|
- "-QF1012" # use fmt.Fprintf (style suggestion)
|
||||||
|
- "-SA1019" # deprecated API usage (elliptic.Marshal — Go hasn't removed it)
|
||||||
|
- "-SA9003" # empty branch (intentional in switch stubs)
|
||||||
|
- "-S1009" # redundant nil check (pre-existing style)
|
||||||
|
- "-S1011" # use single append with spread (pre-existing style)
|
||||||
|
exclusions:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
|
||||||
|
# Linters temporarily disabled — re-enable incrementally as pre-existing issues are fixed:
|
||||||
|
# - errcheck (50 issues — unchecked error returns throughout codebase)
|
||||||
|
# - gocritic (50 issues — diagnostic/performance suggestions)
|
||||||
|
# - gosec (23 issues — security warnings in test/stub code)
|
||||||
|
# - ineffassign (13 issues — dead assignments)
|
||||||
|
# - noctx (25 issues — http.Get without context)
|
||||||
|
# - bodyclose (response body close missing)
|
||||||
@@ -19,7 +19,7 @@ Change Date: March 14, 2033
|
|||||||
Change License: Apache License, Version 2.0
|
Change License: Apache License, Version 2.0
|
||||||
|
|
||||||
For information about alternative licensing arrangements for the Licensed Work,
|
For information about alternative licensing arrangements for the Licensed Work,
|
||||||
please contact: skreddy040@gmail.com
|
please contact: certctl@proton.me
|
||||||
|
|
||||||
Notice
|
Notice
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
|
|
||||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||||
|
|
||||||
90+ API endpoints. 21 database tables. 900+ tests. Full GUI. Ships with Docker Compose.
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
timeline
|
timeline
|
||||||
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
||||||
@@ -26,36 +24,22 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||||

|
[](https://github.com/shankar0123/certctl/releases)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Guide | Description |
|
| Guide | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
|
| [Why certctl?](docs/why-certctl.md) | Competitive positioning — how certctl compares to open-source and enterprise certificate management platforms |
|
||||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||||
| [Quick Start](docs/quickstart.md) | Get running in 5 minutes — dashboard, API, CLI, discovery, stakeholder demo flow |
|
| [Quick Start](docs/quickstart.md) | Get running in 5 minutes — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||||
|
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
||||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||||
| [Manual Testing Guide](docs/testing-guide.md) | 284 tests across 25 areas — full V2 QA runbook with exact commands and pass/fail criteria |
|
|
||||||
|
|
||||||
## Contents
|
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,471 Go tests + 193 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
|
||||||
|
|
||||||
- [Why certctl Exists](#why-certctl-exists)
|
|
||||||
- [What It Does](#what-it-does)
|
|
||||||
- [Screenshots](#screenshots)
|
|
||||||
- [Quick Start](#quick-start)
|
|
||||||
- [Architecture](#architecture)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [MCP Server (AI Integration)](#mcp-server-ai-integration)
|
|
||||||
- [CLI](#cli)
|
|
||||||
- [API Overview](#api-overview)
|
|
||||||
- [Supported Integrations](#supported-integrations)
|
|
||||||
- [Development](#development)
|
|
||||||
- [Security](#security)
|
|
||||||
- [Roadmap](#roadmap)
|
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
## Why certctl Exists
|
## Why certctl Exists
|
||||||
|
|
||||||
@@ -63,69 +47,109 @@ Certificate lifecycle tooling today falls into two camps: expensive enterprise p
|
|||||||
|
|
||||||
certctl fills that gap. It's **CA-agnostic** — the issuer connector interface means you can plug in any certificate authority: a self-signed local CA for dev, Let's Encrypt via ACME for public certs, Smallstep step-ca for your private PKI, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. You're never locked to a single CA vendor, and you can run multiple issuers simultaneously for different certificate types.
|
certctl fills that gap. It's **CA-agnostic** — the issuer connector interface means you can plug in any certificate authority: a self-signed local CA for dev, Let's Encrypt via ACME for public certs, Smallstep step-ca for your private PKI, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. You're never locked to a single CA vendor, and you can run multiple issuers simultaneously for different certificate types.
|
||||||
|
|
||||||
It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, and HAProxy today, with the same pluggable connector model for any server that accepts cert files. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, and Caddy — all using the same pluggable connector model for any server that accepts cert files. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||||
|
|
||||||
|
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venafi, Keyfactor), see [Why certctl?](docs/why-certctl.md)
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (95 endpoints under `/api/v1/` + `/.well-known/est/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates on disk, and submit CSRs — private keys never leave your servers. The **network scanner** discovers certificates on TLS endpoints across your infrastructure without requiring agents. The **EST server** (RFC 7030) enables device and WiFi certificate enrollment via industry-standard Enrollment over Secure Transport. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
|
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||||
|
|
||||||
**Core capabilities:**
|
- **Web dashboard** — 22 operational pages: certificate inventory, deployment timeline with TLS verification, bulk operations (renew/revoke/reassign), discovery triage, network scan management, approval workflows, audit trail with CSV/JSON export, agent fleet overview with OS/arch grouping, short-lived credential monitoring, digest email preview
|
||||||
|
- **REST API** — 99 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters
|
||||||
|
- **Agents** — generate private keys locally (ECDSA P-256), discover existing certs on disk (PEM/DER), submit CSRs only (private keys never leave your servers)
|
||||||
|
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents, concurrent scanning with configurable timeouts
|
||||||
|
- **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included
|
||||||
|
- **S/MIME + EKU support** — issue certificates with emailProtection, codeSigning, timeStamping, clientAuth EKUs; email SAN routing for S/MIME
|
||||||
|
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||||
|
- **Post-deployment verification** — agent-side TLS probe confirms the target serves the correct certificate by SHA-256 fingerprint match
|
||||||
|
- **Approval workflows** — require human sign-off on renewals before deployment
|
||||||
|
- **Background scheduler** — 7 automated loops: renewal checks, job processing, agent health, notifications, short-lived cert expiry, network scanning, and scheduled certificate digest emails
|
||||||
|
- **ACME Renewal Information (ARI, RFC 9702)** — CA-directed renewal timing; certctl asks the CA when to renew instead of using fixed thresholds
|
||||||
|
- **Scheduled certificate digest emails** — HTML digest with certificate stats, expiration timeline, and job health; optional daily briefing via SMTP
|
||||||
|
- **Helm chart** — Production-ready Kubernetes deployment with server, PostgreSQL, and agent DaemonSet
|
||||||
|
|
||||||
- **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds.
|
For the full capability breakdown — revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
|
||||||
- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01 and DNS-01 challenges (Let's Encrypt, Sectigo, any ACME-compatible CA), Smallstep step-ca (native /sign API), and OpenSSL/Custom CA (delegate to any shell script). Pluggable interface — add your own CA in one file.
|
|
||||||
- **Agent-side key generation** — agents generate ECDSA P-256 keys locally, store them with 0600 permissions, and submit only the CSR. Private keys never touch the control plane. This is the default mode, not an opt-in feature.
|
|
||||||
- **Certificate discovery** — agents scan filesystems for existing PEM/DER certificates and report findings for triage. The network scanner probes TLS endpoints across CIDR ranges to find certificates you didn't know existed.
|
|
||||||
- **Revocation infrastructure** — RFC 5280 revocation with all standard reason codes, DER-encoded X.509 CRL per issuer, embedded OCSP responder, and short-lived certificate exemption (certs under 1 hour skip CRL/OCSP).
|
|
||||||
- **Policy engine** — 5 rule types with violation tracking and severity levels. Certificate profiles enforce allowed key types, maximum TTL, and crypto constraints at enrollment time.
|
|
||||||
- **Immutable audit trail** — every action recorded to an append-only log. Every API call recorded with method, path, actor, SHA-256 body hash, response status, and latency. No update or delete on audit records.
|
|
||||||
- **Operational dashboard** — Full React GUI with certificate inventory, bulk operations (multi-select renew/revoke/reassign), deployment timeline visualization, inline policy editing, agent fleet overview, expiration heatmaps, and real-time short-lived credential tracking.
|
|
||||||
- **Observability** — JSON and Prometheus metrics endpoints, 5 stats API endpoints for dashboards, structured slog logging with request ID propagation. Compatible with Prometheus, Grafana Agent, Datadog Agent, and Victoria Metrics.
|
|
||||||
- **Notifications** — threshold-based alerting with deduplication. Routes to email, webhooks, Slack, Microsoft Teams, PagerDuty, and OpsGenie.
|
|
||||||
- **EST enrollment (RFC 7030)** — built-in Enrollment over Secure Transport server for device certificate enrollment. Supports WiFi/802.1X, MDM, and IoT use cases. PKCS#7 certs-only wire format, accepts PEM or base64-encoded DER CSRs, configurable issuer and profile binding.
|
|
||||||
- **AI and CLI access** — MCP server exposes all 78 API operations as tools for Claude, Cursor, and any MCP-compatible client. CLI tool with 12 subcommands for terminal workflows and scripting.
|
|
||||||
|
|
||||||
```mermaid
|
## Supported Integrations
|
||||||
flowchart LR
|
|
||||||
subgraph "Control Plane"
|
|
||||||
API["REST API + Dashboard\n:8443"]
|
|
||||||
PG[("PostgreSQL")]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Your Infrastructure"
|
### Certificate Issuers
|
||||||
A1["Agent"] --> T1["NGINX"]
|
| Issuer | Status | Type |
|
||||||
A2["Agent"] --> T2["Apache / HAProxy"]
|
|--------|--------|------|
|
||||||
A3["Agent"] --> T3["F5 · IIS"]
|
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
||||||
end
|
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01 + DNS-PERSIST-01) | `ACME` |
|
||||||
|
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
||||||
|
| step-ca | Implemented | `StepCA` |
|
||||||
|
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||||
|
| Vault PKI | Future | — |
|
||||||
|
| DigiCert | Future | — |
|
||||||
|
|
||||||
API --> PG
|
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
||||||
A1 & A2 & A3 -->|"CSR + status\n(no private keys)"| API
|
|
||||||
API -->|"Signed certs"| A1 & A2 & A3
|
### Deployment Targets
|
||||||
API -->|"Issue/Renew"| CA["Certificate Authorities\nLocal CA · ACME · step-ca · OpenSSL"]
|
| Target | Status | Type |
|
||||||
```
|
|--------|--------|------|
|
||||||
|
| NGINX | Implemented | `NGINX` |
|
||||||
|
| Apache httpd | Implemented | `Apache` |
|
||||||
|
| HAProxy | Implemented | `HAProxy` |
|
||||||
|
| Traefik | Implemented | `Traefik` |
|
||||||
|
| Caddy | Implemented | `Caddy` |
|
||||||
|
| F5 BIG-IP | Interface only | `F5` |
|
||||||
|
| Microsoft IIS | Interface only | `IIS` |
|
||||||
|
|
||||||
|
### Notifiers
|
||||||
|
| Notifier | Status | Type |
|
||||||
|
|----------|--------|------|
|
||||||
|
| Email (SMTP) | Implemented | `Email` |
|
||||||
|
| Webhooks | Implemented | `Webhook` |
|
||||||
|
| Slack | Implemented | `Slack` |
|
||||||
|
| Microsoft Teams | Implemented | `Teams` |
|
||||||
|
| PagerDuty | Implemented | `PagerDuty` |
|
||||||
|
| OpsGenie | Implemented | `OpsGenie` |
|
||||||
|
|
||||||
|
All connectors are pluggable — build your own by implementing the [connector interface](docs/connectors.md).
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
| | |
|
<table>
|
||||||
|---|---|
|
<tr>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-dashboard.png"><img src="docs/screenshots/v2-dashboard.png" width="270" alt="Dashboard"></a><br><b>Dashboard</b><br><sub>Stats, expiration heatmap, renewal trends</sub></td>
|
||||||
| **Dashboard** — real-time stats, expiration heatmap, renewal trends, issuance rate | **Certificates** — full inventory with status filters, environment, owner, team |
|
<td><a href="docs/screenshots/v2-certificates.png"><img src="docs/screenshots/v2-certificates.png" width="270" alt="Certificates"></a><br><b>Certificates</b><br><sub>Inventory with status, owner, team filters</sub></td>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-agents.png"><img src="docs/screenshots/v2-agents.png" width="270" alt="Agents"></a><br><b>Agents</b><br><sub>Fleet health, OS/arch, IP, version</sub></td>
|
||||||
| **Agents** — fleet health, hostname, OS/arch, IP, version tracking | **Fleet Overview** — OS distribution, status breakdown, version analysis |
|
</tr>
|
||||||
|  |  |
|
<tr>
|
||||||
| **Jobs** — issuance, renewal, deployment job queue with status filters | **Notifications** — expiration warnings, renewal results, unread/all toggle |
|
<td><a href="docs/screenshots/v2-fleet.png"><img src="docs/screenshots/v2-fleet.png" width="270" alt="Fleet Overview"></a><br><b>Fleet Overview</b><br><sub>OS distribution, status breakdown</sub></td>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-jobs.png"><img src="docs/screenshots/v2-jobs.png" width="270" alt="Jobs"></a><br><b>Jobs</b><br><sub>Issuance, renewal, deployment queue</sub></td>
|
||||||
| **Policies** — enforcement rules for ownership, environments, lifetime, renewal | **Profiles** — enrollment templates with key types, max TTL, crypto constraints |
|
<td><a href="docs/screenshots/v2-notifications.png"><img src="docs/screenshots/v2-notifications.png" width="270" alt="Notifications"></a><br><b>Notifications</b><br><sub>Expiration warnings, renewal results</sub></td>
|
||||||
|  |  |
|
</tr>
|
||||||
| **Issuers** — CA connectors (Local CA, Let's Encrypt, step-ca, DigiCert) | **Targets** — deployment targets (NGINX, F5 BIG-IP, IIS, HAProxy) |
|
<tr>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
|
||||||
| **Owners** — certificate ownership with email and team assignment | **Teams** — organizational grouping for notification routing |
|
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
|
||||||
|  |  |
|
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td>
|
||||||
| **Agent Groups** — dynamic grouping by OS, arch, CIDR, version | **Audit Trail** — immutable log with filters, CSV/JSON export |
|
</tr>
|
||||||
|  | |
|
<tr>
|
||||||
| **Short-Lived Credentials** — ephemeral certs with live TTL countdown | |
|
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-owners.png"><img src="docs/screenshots/v2-owners.png" width="270" alt="Owners"></a><br><b>Owners</b><br><sub>Cert ownership with team assignment</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-teams.png"><img src="docs/screenshots/v2-teams.png" width="270" alt="Teams"></a><br><b>Teams</b><br><sub>Org grouping for notification routing</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="docs/screenshots/v2-agent-groups.png"><img src="docs/screenshots/v2-agent-groups.png" width="270" alt="Agent Groups"></a><br><b>Agent Groups</b><br><sub>Dynamic grouping by OS, arch, CIDR</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-audit-trail.png"><img src="docs/screenshots/v2-audit-trail.png" width="270" alt="Audit Trail"></a><br><b>Audit Trail</b><br><sub>Immutable log, CSV/JSON export</sub></td>
|
||||||
|
<td><a href="docs/screenshots/v2-short-lived.png"><img src="docs/screenshots/v2-short-lived.png" width="270" alt="Short-Lived"></a><br><b>Short-Lived Creds</b><br><sub>Ephemeral certs with live TTL countdown</sub></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
> **22 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (with approval workflow), notifications, policies, profiles, issuers, targets (wizard with NGINX/Apache/HAProxy/Traefik/Caddy/F5/IIS), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, and network scan management.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker Pull
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||||
|
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
### Docker Compose (Recommended)
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -172,30 +196,7 @@ export CERTCTL_AGENT_ID=agent-local-01
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```mermaid
|
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 6 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
||||||
flowchart TB
|
|
||||||
subgraph "Control Plane (certctl-server)"
|
|
||||||
DASH["Web Dashboard\nReact SPA"]
|
|
||||||
API["REST API\nGo 1.25 net/http"]
|
|
||||||
SVC["Service Layer"]
|
|
||||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
|
||||||
SCHED["Scheduler\nRenewal · Jobs · Health · Notifications · Short-Lived Expiry · Network Scan"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Data Store"
|
|
||||||
PG[("PostgreSQL 16\n21 tables\nTEXT primary keys")]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Agents"
|
|
||||||
AG["certctl-agent\nKey generation · CSR · Deployment"]
|
|
||||||
end
|
|
||||||
|
|
||||||
DASH --> API
|
|
||||||
API --> SVC --> REPO --> PG
|
|
||||||
SCHED --> SVC
|
|
||||||
AG -->|"Heartbeat + CSR"| API
|
|
||||||
API -->|"Cert + Chain"| AG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
@@ -204,344 +205,90 @@ flowchart TB
|
|||||||
- **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons.
|
- **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons.
|
||||||
- **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution.
|
- **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution.
|
||||||
|
|
||||||
### Database Schema
|
PostgreSQL 16 with 21 tables covering certificates, versions, policies, issuers, targets, agents, jobs, teams, owners, profiles, agent groups, revocations, discovery, network scans, and audit events. See the [Architecture Guide](docs/architecture.md) for the full schema.
|
||||||
|
|
||||||
| Table | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `managed_certificates` | Certificate records with metadata, status, expiry, tags |
|
|
||||||
| `certificate_versions` | Historical versions with PEM chains and CSRs |
|
|
||||||
| `renewal_policies` | Renewal window, auto-renew settings, retry config, alert thresholds |
|
|
||||||
| `issuers` | CA configurations (Local CA, ACME, etc.) |
|
|
||||||
| `deployment_targets` | Target systems (NGINX, F5, IIS) with agent assignments |
|
|
||||||
| `agents` | Registered agents with heartbeat tracking, OS/arch/IP metadata |
|
|
||||||
| `jobs` | Issuance, renewal, deployment, and validation jobs |
|
|
||||||
| `teams` | Organizational groups for certificate ownership |
|
|
||||||
| `owners` | Individual owners with email for notifications |
|
|
||||||
| `policy_rules` | Enforcement rules (allowed issuers, environments, metadata) |
|
|
||||||
| `policy_violations` | Flagged non-compliance with severity levels |
|
|
||||||
| `audit_events` | Immutable action log (append-only, no update/delete) |
|
|
||||||
| `notification_events` | Email and webhook notification records |
|
|
||||||
| `certificate_target_mappings` | Many-to-many cert ↔ target relationships |
|
|
||||||
| `certificate_profiles` | Named enrollment profiles with allowed key types, max TTL, crypto constraints |
|
|
||||||
| `agent_groups` | Dynamic device grouping by OS, architecture, IP CIDR, version |
|
|
||||||
| `agent_group_members` | Manual include/exclude membership for agent groups |
|
|
||||||
| `certificate_revocations` | Revocation records with RFC 5280 reason codes, serial numbers, issuer notification status |
|
|
||||||
| `discovered_certificates` | Filesystem and network-discovered certificates with fingerprint deduplication |
|
|
||||||
| `discovery_scans` | Discovery scan history with timestamps and agent attribution |
|
|
||||||
| `network_scan_targets` | Network scan target definitions with CIDRs, ports, schedule, and scan metrics |
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All server environment variables use the `CERTCTL_` prefix:
|
All environment variables use the `CERTCTL_` prefix. Full reference below (39 variables across server, agent, and connector config).
|
||||||
|
|
||||||
|
### Server — Core
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
||||||
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port |
|
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port (1–65535) |
|
||||||
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string |
|
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string (required) |
|
||||||
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | Connection pool size |
|
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | PostgreSQL connection pool size (min 1) |
|
||||||
| `CERTCTL_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to migration SQL files |
|
||||||
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` or `text` |
|
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max HTTP request body in bytes (default 1MB) |
|
||||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` |
|
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||||
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` (structured) or `text` (human-readable) |
|
||||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) |
|
|
||||||
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) |
|
|
||||||
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
|
|
||||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default) or `dns-01` |
|
|
||||||
| `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode |
|
|
||||||
| `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode |
|
|
||||||
| `CERTCTL_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (empty = same-origin, `*` = all) |
|
|
||||||
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable/disable token bucket rate limiting |
|
|
||||||
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second limit |
|
|
||||||
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Maximum burst size for rate limiter |
|
|
||||||
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to SQL migration files |
|
|
||||||
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often the scheduler checks for expiring certs |
|
|
||||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often the scheduler processes pending jobs |
|
|
||||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | How often the scheduler checks agent health |
|
|
||||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | How often the scheduler processes pending notifications |
|
|
||||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | — | Script to create DNS-01 `_acme-challenge` TXT record |
|
|
||||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record |
|
|
||||||
| `CERTCTL_STEPCA_URL` | — | step-ca server URL |
|
|
||||||
| `CERTCTL_STEPCA_PROVISIONER` | — | step-ca JWK provisioner name |
|
|
||||||
| `CERTCTL_STEPCA_KEY_PATH` | — | Path to step-ca provisioner private key (JWK JSON) |
|
|
||||||
| `CERTCTL_STEPCA_PASSWORD` | — | step-ca provisioner key password |
|
|
||||||
| `CERTCTL_OPENSSL_SIGN_SCRIPT` | — | Script for OpenSSL/Custom CA certificate signing |
|
|
||||||
| `CERTCTL_OPENSSL_REVOKE_SCRIPT` | — | Script for OpenSSL/Custom CA certificate revocation |
|
|
||||||
| `CERTCTL_OPENSSL_CRL_SCRIPT` | — | Script for OpenSSL/Custom CA CRL generation |
|
|
||||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | `30` | Timeout for OpenSSL script execution |
|
|
||||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side network certificate discovery (TLS scanning) |
|
|
||||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the scheduler runs network scans |
|
|
||||||
| `CERTCTL_EST_ENABLED` | `false` | Enable EST (RFC 7030) enrollment endpoints under /.well-known/est/ |
|
|
||||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Issuer connector ID used for EST certificate enrollment |
|
|
||||||
| `CERTCTL_EST_PROFILE_ID` | — | Optional certificate profile ID to constrain EST enrollments |
|
|
||||||
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL for notifications |
|
|
||||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams incoming webhook URL |
|
|
||||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 routing key |
|
|
||||||
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key |
|
|
||||||
|
|
||||||
Agent environment variables:
|
### Server — Auth, CORS, Rate Limiting
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` (demo only) |
|
||||||
|
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
||||||
|
| `CERTCTL_CORS_ORIGINS` | *(empty = deny all)* | Comma-separated allowed origins, or `*` for dev |
|
||||||
|
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable token bucket rate limiting |
|
||||||
|
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second per client |
|
||||||
|
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Max burst size |
|
||||||
|
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
|
||||||
|
|
||||||
|
### Server — Scheduler
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often to check expiring certs (min 1m) |
|
||||||
|
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often to process pending jobs (min 1s) |
|
||||||
|
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | Agent heartbeat check frequency (min 1s) |
|
||||||
|
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | Notification send frequency (min 1s) |
|
||||||
|
|
||||||
|
### Server — Sub-CA Mode
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_CA_CERT_PATH` | — | PEM-encoded CA certificate for sub-CA mode |
|
||||||
|
| `CERTCTL_CA_KEY_PATH` | — | PEM-encoded CA private key (RSA, ECDSA, PKCS#8) |
|
||||||
|
|
||||||
|
### Server — Feature Flags
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST enrollment endpoints |
|
||||||
|
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||||
|
| `CERTCTL_EST_PROFILE_ID` | — | Constrain EST to a specific certificate profile |
|
||||||
|
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side TLS network scanning |
|
||||||
|
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often scheduled scans run |
|
||||||
|
| `CERTCTL_VERIFY_DEPLOYMENT` | `true` | TLS verification after certificate deployment |
|
||||||
|
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||||
|
| `CERTCTL_VERIFY_DELAY` | `2s` | Delay before verification probe |
|
||||||
|
|
||||||
|
### Server — Notification Connectors
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL (enables Slack) |
|
||||||
|
| `CERTCTL_SLACK_CHANNEL` | — | Override default webhook channel |
|
||||||
|
| `CERTCTL_SLACK_USERNAME` | `certctl` | Bot display name |
|
||||||
|
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams webhook URL (enables Teams) |
|
||||||
|
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 key (enables PagerDuty) |
|
||||||
|
| `CERTCTL_PAGERDUTY_SEVERITY` | `warning` | Event severity: `info`, `warning`, `error`, `critical` |
|
||||||
|
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key (enables OpsGenie) |
|
||||||
|
| `CERTCTL_OPSGENIE_PRIORITY` | `P3` | Alert priority: `P1`–`P5` |
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
||||||
| `CERTCTL_API_KEY` | — | Agent API key |
|
| `CERTCTL_API_KEY` | — | Agent API key for authentication |
|
||||||
| `CERTCTL_AGENT_NAME` | `certctl-agent` | Agent display name |
|
|
||||||
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
||||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for storing private keys (agent keygen mode) |
|
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory (0600 perms) |
|
||||||
| `CERTCTL_DISCOVERY_DIRS` | — | Comma-separated directories to scan for existing certificates (e.g., `/etc/nginx/certs,/etc/ssl/certs`) |
|
| `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
|
||||||
|
|
||||||
Docker Compose overrides these for the demo stack (see `deploy/docker-compose.yml`): port `8443`, auth type `none`, database pointing to the postgres container.
|
Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
|
||||||
|
|
||||||
## MCP Server (AI Integration)
|
|
||||||
|
|
||||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install
|
|
||||||
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
|
||||||
|
|
||||||
# Configure
|
|
||||||
export CERTCTL_SERVER_URL=http://localhost:8443 # certctl API endpoint
|
|
||||||
export CERTCTL_API_KEY=your-api-key # optional if auth disabled
|
|
||||||
|
|
||||||
# Run (stdio transport — add to your AI client config)
|
|
||||||
mcp-server
|
|
||||||
```
|
|
||||||
|
|
||||||
**Claude Desktop** (`claude_desktop_config.json`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"certctl": {
|
|
||||||
"command": "mcp-server",
|
|
||||||
"env": {
|
|
||||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
|
||||||
"CERTCTL_API_KEY": "your-api-key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
78 tools organized by resource: certificates (9), CRL/OCSP (3), issuers (6), targets (5), agents (8), jobs (5), policies (6), profiles (5), teams (5), owners (5), agent groups (6), audit (2), notifications (3), stats (5), metrics (1), health (4).
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
certctl ships a command-line tool for terminal-based certificate management workflows.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install
|
|
||||||
go install github.com/shankar0123/certctl/cmd/cli@latest
|
|
||||||
|
|
||||||
# Configure
|
|
||||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
|
||||||
export CERTCTL_API_KEY=your-api-key
|
|
||||||
|
|
||||||
# Certificate commands
|
|
||||||
certctl-cli certs list # List all certificates
|
|
||||||
certctl-cli certs get mc-api-prod # Get certificate details
|
|
||||||
certctl-cli certs renew mc-api-prod # Trigger renewal
|
|
||||||
certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
|
||||||
|
|
||||||
# Agent and job commands
|
|
||||||
certctl-cli agents list # List registered agents
|
|
||||||
certctl-cli agents get ag-web-prod # Get agent details
|
|
||||||
certctl-cli jobs list # List jobs
|
|
||||||
certctl-cli jobs get job-123 # Get job details
|
|
||||||
certctl-cli jobs cancel job-123 # Cancel a pending job
|
|
||||||
|
|
||||||
# Operations
|
|
||||||
certctl-cli status # Server health + summary stats
|
|
||||||
certctl-cli import certs.pem # Bulk import from PEM file
|
|
||||||
certctl-cli version # Show CLI version
|
|
||||||
|
|
||||||
# Output formats
|
|
||||||
certctl-cli certs list --format json # JSON output (default: table)
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Overview
|
|
||||||
|
|
||||||
All endpoints are under `/api/v1/` and return JSON. List endpoints support pagination (`?page=1&per_page=50`). Full request/response schemas are available in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
|
||||||
|
|
||||||
### Certificates
|
|
||||||
```
|
|
||||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
|
||||||
POST /api/v1/certificates Create
|
|
||||||
GET /api/v1/certificates/{id} Get
|
|
||||||
PUT /api/v1/certificates/{id} Update
|
|
||||||
DELETE /api/v1/certificates/{id} Archive (soft delete)
|
|
||||||
GET /api/v1/certificates/{id}/versions Version history
|
|
||||||
GET /api/v1/certificates/{id}/deployments List deployment targets
|
|
||||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
|
||||||
POST /api/v1/certificates/{id}/deploy Trigger deployment → 202 Accepted
|
|
||||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
|
||||||
GET /api/v1/crl Certificate Revocation List (JSON)
|
|
||||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
|
||||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agents
|
|
||||||
```
|
|
||||||
GET /api/v1/agents List
|
|
||||||
POST /api/v1/agents Register
|
|
||||||
GET /api/v1/agents/{id} Get
|
|
||||||
POST /api/v1/agents/{id}/heartbeat Record heartbeat
|
|
||||||
POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
|
||||||
GET /api/v1/agents/{id}/certificates/{certId} Retrieve signed certificate
|
|
||||||
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
|
||||||
POST /api/v1/agents/{id}/jobs/{jobId}/status Report job completion/failure
|
|
||||||
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
|
||||||
```
|
|
||||||
|
|
||||||
### Certificate Discovery
|
|
||||||
```
|
|
||||||
GET /api/v1/discovered-certificates List discovered certificates (?agent_id, ?status)
|
|
||||||
GET /api/v1/discovered-certificates/{id} Get discovery detail
|
|
||||||
POST /api/v1/discovered-certificates/{id}/claim Link discovered cert to managed cert
|
|
||||||
POST /api/v1/discovered-certificates/{id}/dismiss Dismiss discovery
|
|
||||||
GET /api/v1/discovery-scans List discovery scan history
|
|
||||||
GET /api/v1/discovery-summary Aggregated discovery status (new, claimed, dismissed counts)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
```
|
|
||||||
GET /api/v1/issuers List issuers
|
|
||||||
POST /api/v1/issuers Create
|
|
||||||
GET /api/v1/issuers/{id} Get
|
|
||||||
PUT /api/v1/issuers/{id} Update
|
|
||||||
DELETE /api/v1/issuers/{id} Delete
|
|
||||||
POST /api/v1/issuers/{id}/test Test connectivity
|
|
||||||
|
|
||||||
GET /api/v1/targets List deployment targets
|
|
||||||
POST /api/v1/targets Create
|
|
||||||
GET /api/v1/targets/{id} Get
|
|
||||||
PUT /api/v1/targets/{id} Update
|
|
||||||
DELETE /api/v1/targets/{id} Delete
|
|
||||||
```
|
|
||||||
|
|
||||||
### Organization
|
|
||||||
```
|
|
||||||
GET /api/v1/teams List teams
|
|
||||||
POST /api/v1/teams Create
|
|
||||||
GET /api/v1/teams/{id} Get
|
|
||||||
PUT /api/v1/teams/{id} Update
|
|
||||||
DELETE /api/v1/teams/{id} Delete
|
|
||||||
GET /api/v1/owners List owners
|
|
||||||
POST /api/v1/owners Create
|
|
||||||
GET /api/v1/owners/{id} Get
|
|
||||||
PUT /api/v1/owners/{id} Update
|
|
||||||
DELETE /api/v1/owners/{id} Delete
|
|
||||||
```
|
|
||||||
|
|
||||||
### Operations
|
|
||||||
```
|
|
||||||
GET /api/v1/jobs List (filter: status, type)
|
|
||||||
GET /api/v1/jobs/{id} Get
|
|
||||||
POST /api/v1/jobs/{id}/cancel Cancel
|
|
||||||
POST /api/v1/jobs/{id}/approve Approve (interactive renewal)
|
|
||||||
POST /api/v1/jobs/{id}/reject Reject (interactive renewal)
|
|
||||||
|
|
||||||
GET /api/v1/policies List policy rules
|
|
||||||
POST /api/v1/policies Create
|
|
||||||
GET /api/v1/policies/{id} Get
|
|
||||||
PUT /api/v1/policies/{id} Update (enable/disable)
|
|
||||||
DELETE /api/v1/policies/{id} Delete
|
|
||||||
GET /api/v1/policies/{id}/violations List violations for rule
|
|
||||||
|
|
||||||
GET /api/v1/profiles List certificate profiles
|
|
||||||
POST /api/v1/profiles Create
|
|
||||||
GET /api/v1/profiles/{id} Get
|
|
||||||
PUT /api/v1/profiles/{id} Update
|
|
||||||
DELETE /api/v1/profiles/{id} Delete
|
|
||||||
|
|
||||||
GET /api/v1/agent-groups List agent groups
|
|
||||||
POST /api/v1/agent-groups Create
|
|
||||||
GET /api/v1/agent-groups/{id} Get
|
|
||||||
PUT /api/v1/agent-groups/{id} Update
|
|
||||||
DELETE /api/v1/agent-groups/{id} Delete
|
|
||||||
GET /api/v1/agent-groups/{id}/members List members
|
|
||||||
|
|
||||||
GET /api/v1/audit Query audit trail
|
|
||||||
GET /api/v1/audit/{id} Get audit event
|
|
||||||
GET /api/v1/notifications List notifications
|
|
||||||
GET /api/v1/notifications/{id} Get notification
|
|
||||||
POST /api/v1/notifications/{id}/read Mark as read
|
|
||||||
```
|
|
||||||
|
|
||||||
### Observability
|
|
||||||
```
|
|
||||||
GET /api/v1/stats/summary Dashboard summary (totals, expiring, agents, jobs)
|
|
||||||
GET /api/v1/stats/certificates-by-status Certificate counts grouped by status
|
|
||||||
GET /api/v1/stats/expiration-timeline Expiration buckets (?days=30)
|
|
||||||
GET /api/v1/stats/job-trends Job success/failure over time (?days=7)
|
|
||||||
GET /api/v1/stats/issuance-rate Certificate issuance rate (?days=7)
|
|
||||||
GET /api/v1/metrics JSON metrics (gauges, counters, uptime)
|
|
||||||
GET /api/v1/metrics/prometheus Prometheus exposition format (text/plain)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network Discovery
|
|
||||||
```
|
|
||||||
GET /api/v1/network-scan-targets List scan targets
|
|
||||||
POST /api/v1/network-scan-targets Create scan target (CIDRs, ports, schedule)
|
|
||||||
GET /api/v1/network-scan-targets/{id} Get scan target
|
|
||||||
PUT /api/v1/network-scan-targets/{id} Update scan target
|
|
||||||
DELETE /api/v1/network-scan-targets/{id} Delete scan target
|
|
||||||
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate scan
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
```
|
|
||||||
GET /api/v1/auth/info Auth mode info (no auth required)
|
|
||||||
GET /api/v1/auth/check Validate credentials
|
|
||||||
```
|
|
||||||
|
|
||||||
### EST Enrollment (RFC 7030)
|
|
||||||
```
|
|
||||||
GET /.well-known/est/cacerts CA certificate chain (PKCS#7 certs-only)
|
|
||||||
POST /.well-known/est/simpleenroll Simple enrollment (PEM or base64-DER CSR)
|
|
||||||
POST /.well-known/est/simplereenroll Simple re-enrollment (certificate renewal)
|
|
||||||
GET /.well-known/est/csrattrs CSR attributes request
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health
|
|
||||||
```
|
|
||||||
GET /health Server health check
|
|
||||||
GET /ready Readiness check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Supported Integrations
|
|
||||||
|
|
||||||
### Certificate Issuers
|
|
||||||
| Issuer | Status | Type |
|
|
||||||
|--------|--------|------|
|
|
||||||
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
|
||||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01) | `ACME` |
|
|
||||||
| step-ca | Implemented | `StepCA` |
|
|
||||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
|
||||||
| Vault PKI | Planned | — |
|
|
||||||
| DigiCert | Planned | — |
|
|
||||||
|
|
||||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS.
|
|
||||||
|
|
||||||
### Deployment Targets
|
|
||||||
| Target | Status | Type |
|
|
||||||
|--------|--------|------|
|
|
||||||
| NGINX | Implemented | `NGINX` |
|
|
||||||
| Apache httpd | Implemented | `Apache` |
|
|
||||||
| HAProxy | Implemented | `HAProxy` |
|
|
||||||
| F5 BIG-IP | Interface only | `F5` |
|
|
||||||
| Microsoft IIS | Interface only | `IIS` |
|
|
||||||
| Kubernetes Secrets | Planned | — |
|
|
||||||
|
|
||||||
### Notifiers
|
|
||||||
| Notifier | Status | Type |
|
|
||||||
|----------|--------|------|
|
|
||||||
| Email (SMTP) | Implemented | `Email` |
|
|
||||||
| Webhooks | Implemented | `Webhook` |
|
|
||||||
| Slack | Implemented | `Slack` |
|
|
||||||
| Microsoft Teams | Implemented | `Teams` |
|
|
||||||
| PagerDuty | Implemented | `PagerDuty` |
|
|
||||||
| OpsGenie | Implemented | `OpsGenie` |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -552,16 +299,26 @@ make install-tools
|
|||||||
# Run tests
|
# Run tests
|
||||||
make test
|
make test
|
||||||
|
|
||||||
|
# Run tests with race detection (same as CI)
|
||||||
|
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/...
|
||||||
|
|
||||||
# Run with coverage
|
# Run with coverage
|
||||||
make test-coverage
|
make test-coverage
|
||||||
|
|
||||||
# Lint
|
# Lint (runs golangci-lint with project config)
|
||||||
make lint
|
make lint
|
||||||
|
|
||||||
|
# Vulnerability scan
|
||||||
|
govulncheck ./...
|
||||||
|
|
||||||
# Format
|
# Format
|
||||||
make fmt
|
make fmt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CI Pipeline
|
||||||
|
|
||||||
|
Every push and PR runs: `go vet`, `go test -race` (race detection), `golangci-lint` (11 linters including gosec and bodyclose), `govulncheck` (dependency CVE scanning), and per-layer coverage thresholds (service 60%, handler 60%, domain 40%, middleware 50%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build. See `.github/workflows/ci.yml` for details.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -583,47 +340,177 @@ make docker-clean # Stop + remove volumes
|
|||||||
- API key and JWT auth types supported; `none` for demo/development
|
- API key and JWT auth types supported; `none` for demo/development
|
||||||
- Auth type and secret configured via `CERTCTL_AUTH_TYPE` and `CERTCTL_AUTH_SECRET`
|
- Auth type and secret configured via `CERTCTL_AUTH_TYPE` and `CERTCTL_AUTH_SECRET`
|
||||||
|
|
||||||
|
### CORS
|
||||||
|
- **Deny-by-default**: Empty `CERTCTL_CORS_ORIGINS` blocks all cross-origin requests. Operators must explicitly list allowed origins (comma-separated) or set `*` for development.
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
- Shell command injection prevention on all connector scripts (strict character whitelist, no metacharacters)
|
||||||
|
- RFC 1123 domain name validation, base64url ACME token validation
|
||||||
|
- SSRF protection in network scanner (loopback, link-local, multicast, broadcast ranges filtered)
|
||||||
|
|
||||||
|
### Concurrency Safety
|
||||||
|
- Scheduler loops protected by `sync/atomic.Bool` idempotency guards — duplicate ticks are skipped
|
||||||
|
- Graceful shutdown waits up to 30 seconds for in-flight work before database close
|
||||||
|
|
||||||
### Audit Trail
|
### Audit Trail
|
||||||
- Immutable append-only log in PostgreSQL (`audit_events` table)
|
- Immutable append-only log in PostgreSQL (`audit_events` table)
|
||||||
- Every lifecycle action attributed to an actor with timestamp and resource reference
|
- Every lifecycle action attributed to an actor with timestamp and resource reference
|
||||||
- No update or delete operations on audit records
|
- No update or delete operations on audit records
|
||||||
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency (M19)
|
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
99 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||||
|
|
||||||
|
### Key Endpoints
|
||||||
|
```
|
||||||
|
# Certificate lifecycle
|
||||||
|
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||||
|
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||||
|
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||||
|
GET /api/v1/certificates/{id}/export/pem Export PEM (JSON or file download)
|
||||||
|
POST /api/v1/certificates/{id}/export/pkcs12 Export PKCS#12 bundle (no private key)
|
||||||
|
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||||
|
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||||
|
|
||||||
|
# Agent operations
|
||||||
|
POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
||||||
|
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
||||||
|
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
||||||
|
|
||||||
|
# Discovery & network scanning
|
||||||
|
GET /api/v1/discovered-certificates List discovered certs (?agent_id, ?status)
|
||||||
|
POST /api/v1/discovered-certificates/{id}/claim Link to managed cert
|
||||||
|
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate TLS scan
|
||||||
|
|
||||||
|
# Jobs & approval
|
||||||
|
POST /api/v1/jobs/{id}/approve Approve interactive renewal
|
||||||
|
POST /api/v1/jobs/{id}/reject Reject interactive renewal
|
||||||
|
|
||||||
|
# Post-deployment verification
|
||||||
|
POST /api/v1/jobs/{id}/verify Submit TLS verification result
|
||||||
|
GET /api/v1/jobs/{id}/verification Get verification status
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
GET /api/v1/metrics/prometheus Prometheus exposition format
|
||||||
|
GET /api/v1/stats/summary Dashboard summary
|
||||||
|
|
||||||
|
# Digest emails (scheduled briefing)
|
||||||
|
GET /api/v1/digest/preview HTML email preview
|
||||||
|
POST /api/v1/digest/send Send digest immediately
|
||||||
|
|
||||||
|
# EST enrollment (RFC 7030)
|
||||||
|
POST /.well-known/est/simpleenroll Device certificate enrollment
|
||||||
|
GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
|
||||||
|
```
|
||||||
|
|
||||||
|
Full CRUD is available for certificates, agents, issuers, targets, teams, owners, policies, profiles, agent groups, notifications, and audit events. See the [OpenAPI spec](api/openapi.yaml) or [Feature Inventory](docs/features.md) for the complete endpoint reference.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
go install github.com/shankar0123/certctl/cmd/cli@latest
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||||
|
export CERTCTL_API_KEY=your-api-key
|
||||||
|
|
||||||
|
# Certificate commands
|
||||||
|
certctl-cli certs list # List all certificates
|
||||||
|
certctl-cli certs get mc-api-prod # Get certificate details
|
||||||
|
certctl-cli certs renew mc-api-prod # Trigger renewal
|
||||||
|
certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
||||||
|
|
||||||
|
# Agent and job commands
|
||||||
|
certctl-cli agents list # List registered agents
|
||||||
|
certctl-cli jobs list # List jobs
|
||||||
|
certctl-cli jobs cancel job-123 # Cancel a pending job
|
||||||
|
|
||||||
|
# Operations
|
||||||
|
certctl-cli status # Server health + summary stats
|
||||||
|
certctl-cli import certs.pem # Bulk import from PEM file
|
||||||
|
|
||||||
|
# Output formats
|
||||||
|
certctl-cli certs list --format json # JSON output (default: table)
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Server (AI Integration)
|
||||||
|
|
||||||
|
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||||
|
export CERTCTL_API_KEY=your-api-key
|
||||||
|
|
||||||
|
# Run (stdio transport — add to your AI client config)
|
||||||
|
mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude Desktop** (`claude_desktop_config.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"certctl": {
|
||||||
|
"command": "mcp-server",
|
||||||
|
"env": {
|
||||||
|
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||||
|
"CERTCTL_API_KEY": "your-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### V1 (v1.0.0 released)
|
### V1 (v1.0.0)
|
||||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a full React dashboard wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||||
|
|
||||||
### V2: Operational Maturity
|
### V2: Operational Maturity
|
||||||
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
|
||||||
- **M11: Crypto Policy + Profiles + Ownership** ✅ — certificate profiles (named enrollment profiles with allowed key types, max TTL, crypto constraints), certificate ownership tracking (owners + teams + notification routing), dynamic agent groups (OS/arch/IP CIDR/version matching), interactive renewal approval (AwaitingApproval state)
|
30 milestones complete, 1500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||||
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), step-ca issuer connector (native /sign API with JWK provisioner auth)
|
|
||||||
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
|
**What shipped (all ✅):**
|
||||||
- **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
|
|
||||||
- **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view
|
- **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing), ACME ARI (RFC 9702, CA-directed renewal timing)
|
||||||
- **M14: Observability** ✅ — dashboard charts (expiration heatmap, cert status distribution, job trends, issuance rate), agent fleet overview with OS/arch grouping, JSON metrics endpoint, stats API (5 endpoints), structured logging with request IDs, deployment rollback
|
- **Revocation** — RFC 5280 reason codes, DER-encoded X.509 CRL, embedded OCSP responder, short-lived cert exemption
|
||||||
- **M18a: MCP Server** ✅ (V2.1) — AI-native integration, all 78 REST API endpoints exposed as MCP tools for Claude, Cursor, OpenClaw, and any MCP-compatible client
|
- **Profiles + Ownership** — certificate profiles (key types, max TTL, crypto constraints), ownership tracking (owners + teams), dynamic agent groups, interactive renewal approval
|
||||||
- **M19: Immutable API Audit Log** ✅ — every API call recorded to immutable audit trail (method, path, actor, SHA-256 body hash, status, latency), async recording via goroutine, configurable path exclusions
|
- **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
|
||||||
- **M16a: Notifier Connectors** ✅ — Slack (incoming webhook), Microsoft Teams (MessageCard), PagerDuty (Events API v2), OpsGenie (Alert API v2) — config-driven enablement via env vars
|
- **Discovery** — filesystem scanning (PEM/DER) + network TLS scanning (CIDR ranges), triage workflow (claim/dismiss), network scan target management
|
||||||
- **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout)
|
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
||||||
- **M16b: CLI + Bulk Import** ✅ — `certctl-cli` with 12 subcommands (certs list/get/renew/revoke, agents list/get, jobs list/get/cancel, import, status, version), stdlib-only, JSON/table output
|
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
|
||||||
- **M20: Enhanced Query API** ✅ — sparse field selection (`?fields=`), sort with direction (`?sort=-notAfter`), time-range filters (`expires_before`, `created_after`, etc.), cursor-based pagination (`?cursor=&page_size=`), `GET /certificates/{id}/deployments`, additional filters (`agent_id`, `profile_id`)
|
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
|
||||||
- **M18b: Filesystem Cert Discovery** ✅ — agents scan configured directories (PEM/DER), report findings to control plane, deduplication by SHA-256 fingerprint, claim/dismiss/triage workflow via API
|
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
|
||||||
- **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default)
|
- **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
|
||||||
- **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics
|
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
|
||||||
- **M23: EST Server (RFC 7030)** ✅ — Enrollment over Secure Transport for device/WiFi certificate enrollment, 4 endpoints under /.well-known/est/, PKCS#7 certs-only wire format, base64-encoded DER CSR input, configurable issuer + profile binding, audit trail, 28 new tests
|
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
|
||||||
- **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
|
|
||||||
|
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match, verification status visible in deployment timeline
|
||||||
|
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based), both in target wizard GUI
|
||||||
|
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail, GUI export buttons
|
||||||
|
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing, EKU badges in GUI
|
||||||
|
- **ACME ARI (RFC 9702)** — CA-directed renewal timing with graceful threshold fallback for non-ARI CAs, reduces unnecessary early renewals
|
||||||
|
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; optional daily/hourly/weekly briefings via SMTP
|
||||||
|
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet, Agent DaemonSet, security contexts, resource limits, optional Ingress, ServiceAccount
|
||||||
|
- **ACME ARI (RFC 9702)** — CA-directed renewal timing: instead of renewing at fixed thresholds, the CA tells certctl the optimal renewal window, gracefully degrading to thresholds when ARI is unavailable
|
||||||
|
- **Email Digest Service** — Scheduled HTML digest emails with certificate stats, expiration timeline (90d), job health, and active agent count; falls back to certificate owner emails if no recipients configured
|
||||||
|
- **Helm Chart** — Production-ready Kubernetes deployment with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, optional Ingress, security contexts, and full values.yaml configuration
|
||||||
|
|
||||||
### V3: certctl Pro
|
### V3: certctl Pro
|
||||||
|
|
||||||
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, and premium CA integrations.
|
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, and premium CA integrations.
|
||||||
|
|
||||||
> **Need SSO, RBAC, F5/IIS deployment, or real-time fleet operations?** [Join the certctl Pro waitlist](https://forms.gle/YOUR_FORM_ID) — early access shipping Q2 2026.
|
|
||||||
|
|
||||||
### V4+: Cloud, Scale & Passive Discovery
|
### V4+: Cloud, Scale & Passive Discovery
|
||||||
Passive network discovery (TLS listener), Kubernetes integration, cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support, and platform-scale features.
|
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Vault PKI, Google CAS, EJBCA), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||||
|
|
||||||
## 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 offer certctl as a managed/hosted certificate management service to third parties.
|
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties.
|
||||||
|
|
||||||
|
For licensing inquiries: certctl@proton.me
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ tags:
|
|||||||
description: Certificate discovery — filesystem scanning by agents and network TLS probing
|
description: Certificate discovery — filesystem scanning by agents and network TLS probing
|
||||||
- name: Network Scan
|
- name: Network Scan
|
||||||
description: Network scan target management for active TLS certificate discovery
|
description: Network scan target management for active TLS certificate discovery
|
||||||
|
- name: Digest
|
||||||
|
description: Scheduled certificate digest email notifications
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
# ─── Health & Auth ───────────────────────────────────────────────────
|
# ─── Health & Auth ───────────────────────────────────────────────────
|
||||||
@@ -367,6 +369,84 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
# ─── Certificate Export ──────────────────────────────────────────────
|
||||||
|
/api/v1/certificates/{id}/export/pem:
|
||||||
|
get:
|
||||||
|
tags: [Certificates]
|
||||||
|
summary: Export certificate as PEM
|
||||||
|
description: |
|
||||||
|
Returns the certificate and its chain in PEM format. By default returns JSON
|
||||||
|
with cert_pem, chain_pem, and full_pem fields. Add ?download=true to get the
|
||||||
|
full PEM chain as a file download with Content-Disposition headers.
|
||||||
|
operationId: exportCertificatePEM
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
- name: download
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: ["true"]
|
||||||
|
description: Set to "true" to get a file download instead of JSON.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: PEM export
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
cert_pem:
|
||||||
|
type: string
|
||||||
|
description: Leaf certificate PEM
|
||||||
|
chain_pem:
|
||||||
|
type: string
|
||||||
|
description: Intermediate/root chain PEM
|
||||||
|
full_pem:
|
||||||
|
type: string
|
||||||
|
description: Full PEM chain (cert + intermediates)
|
||||||
|
application/x-pem-file:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
description: Full PEM file (when download=true)
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/certificates/{id}/export/pkcs12:
|
||||||
|
post:
|
||||||
|
tags: [Certificates]
|
||||||
|
summary: Export certificate as PKCS#12
|
||||||
|
description: |
|
||||||
|
Returns a PKCS#12 (.p12) bundle containing the certificate and chain.
|
||||||
|
Private keys are NOT included — they live on agents and never touch the control plane.
|
||||||
|
The bundle is encrypted with the provided password (or empty password if omitted).
|
||||||
|
operationId: exportCertificatePKCS12
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Password to encrypt the PKCS#12 bundle (can be empty)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: PKCS#12 binary
|
||||||
|
content:
|
||||||
|
application/x-pkcs12:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
# ─── CRL & OCSP ─────────────────────────────────────────────────────
|
# ─── CRL & OCSP ─────────────────────────────────────────────────────
|
||||||
/api/v1/crl:
|
/api/v1/crl:
|
||||||
get:
|
get:
|
||||||
@@ -2294,6 +2374,56 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
# ─── Digest ────────────────────────────────────────────────────────
|
||||||
|
/api/v1/digest/preview:
|
||||||
|
get:
|
||||||
|
tags: [Digest]
|
||||||
|
summary: Preview digest email
|
||||||
|
description: |
|
||||||
|
Returns an HTML preview of the scheduled certificate digest email.
|
||||||
|
This includes a summary of certificate status, pending jobs, and expiring certificates.
|
||||||
|
operationId: previewDigest
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: HTML digest email preview
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "<html>...</html>"
|
||||||
|
"503":
|
||||||
|
description: Digest service not configured
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/StatusMessageResponse"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/digest/send:
|
||||||
|
post:
|
||||||
|
tags: [Digest]
|
||||||
|
summary: Send digest email
|
||||||
|
description: |
|
||||||
|
Triggers immediate sending of the certificate digest email to configured recipients.
|
||||||
|
If no explicit recipients are configured, sends to certificate owners.
|
||||||
|
operationId: sendDigest
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Digest sent successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/StatusMessageResponse"
|
||||||
|
"503":
|
||||||
|
description: Digest service not configured
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/StatusMessageResponse"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
@@ -2712,8 +2842,15 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
allowed_ekus:
|
allowed_ekus:
|
||||||
type: array
|
type: array
|
||||||
|
description: Extended Key Usages to include in issued certificates
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
enum:
|
||||||
|
- serverAuth
|
||||||
|
- clientAuth
|
||||||
|
- codeSigning
|
||||||
|
- emailProtection
|
||||||
|
- timeStamping
|
||||||
required_san_patterns:
|
required_san_patterns:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -0,0 +1,830 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAgent_Heartbeat_Success tests that heartbeat sends correct metadata and handles 200 response.
|
||||||
|
func TestAgent_Heartbeat_Success(t *testing.T) {
|
||||||
|
// Create mock server to validate heartbeat request
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify correct endpoint and method
|
||||||
|
if r.URL.Path != "/api/v1/agents/a-test-agent/heartbeat" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("unexpected method: %s, expected POST", r.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify auth header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "Bearer test-key" {
|
||||||
|
t.Errorf("unexpected auth header: %s", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify request body contains required fields
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if _, ok := payload["version"]; !ok {
|
||||||
|
t.Error("missing version in heartbeat")
|
||||||
|
}
|
||||||
|
if _, ok := payload["hostname"]; !ok {
|
||||||
|
t.Error("missing hostname in heartbeat")
|
||||||
|
}
|
||||||
|
if _, ok := payload["os"]; !ok {
|
||||||
|
t.Error("missing os in heartbeat")
|
||||||
|
}
|
||||||
|
if _, ok := payload["architecture"]; !ok {
|
||||||
|
t.Error("missing architecture in heartbeat")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test-agent",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
agent.sendHeartbeat(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgent_Heartbeat_ServerError tests that heartbeat handles 500 response gracefully.
|
||||||
|
func TestAgent_Heartbeat_ServerError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test-agent",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
// Should increment consecutive failures
|
||||||
|
failureBefore := agent.consecutiveFailures
|
||||||
|
agent.sendHeartbeat(context.Background())
|
||||||
|
failureAfter := agent.consecutiveFailures
|
||||||
|
|
||||||
|
if failureAfter != failureBefore+1 {
|
||||||
|
t.Errorf("expected consecutive failures to increment, got %d, want %d", failureAfter, failureBefore+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgent_Heartbeat_ConnectionError tests that heartbeat handles connection error.
|
||||||
|
func TestAgent_Heartbeat_ConnectionError(t *testing.T) {
|
||||||
|
// Use an invalid address that will fail immediately
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://invalid-host-that-does-not-exist.local:9999",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test-agent",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
// Should fail due to connection error
|
||||||
|
agent.sendHeartbeat(context.Background())
|
||||||
|
|
||||||
|
if agent.consecutiveFailures != 1 {
|
||||||
|
t.Errorf("expected consecutive failures to be 1, got %d", agent.consecutiveFailures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgent_PollWork_NoWork tests that work polling handles empty work list.
|
||||||
|
func TestAgent_PollWork_NoWork(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/agents/a-test-agent/work" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("unexpected method: %s", r.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(WorkResponse{
|
||||||
|
Jobs: []JobItem{},
|
||||||
|
Count: 0,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test-agent",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
agent.pollForWork(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgent_PollWork_Success tests that work polling parses and returns jobs correctly.
|
||||||
|
func TestAgent_PollWork_Success(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
workResp := WorkResponse{
|
||||||
|
Count: 2,
|
||||||
|
Jobs: []JobItem{
|
||||||
|
{
|
||||||
|
ID: "j-csr-001",
|
||||||
|
Type: "Issuance",
|
||||||
|
CertificateID: "mc-001",
|
||||||
|
CommonName: "example.com",
|
||||||
|
SANs: []string{"www.example.com"},
|
||||||
|
Status: "AwaitingCSR",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "j-deploy-001",
|
||||||
|
Type: "Deployment",
|
||||||
|
CertificateID: "mc-001",
|
||||||
|
TargetID: strPtr("t-nginx-1"),
|
||||||
|
TargetType: "NGINX",
|
||||||
|
TargetConfig: json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`),
|
||||||
|
Status: "Pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(workResp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test-agent",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
// Should not panic; work items are processed in separate gorines in real usage
|
||||||
|
agent.pollForWork(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplitPEMChain tests PEM chain splitting into cert and chain.
|
||||||
|
func TestSplitPEMChain(t *testing.T) {
|
||||||
|
// Create two test certificates
|
||||||
|
cert1, _ := generateTestCertWithCN("cert1.example.com")
|
||||||
|
cert2, _ := generateTestCertWithCN("cert2.example.com")
|
||||||
|
|
||||||
|
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
||||||
|
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
||||||
|
|
||||||
|
cert1PEM := string(pem.EncodeToMemory(block1))
|
||||||
|
cert2PEM := string(pem.EncodeToMemory(block2))
|
||||||
|
|
||||||
|
chainPEM := cert1PEM + "\n" + cert2PEM
|
||||||
|
|
||||||
|
// Split
|
||||||
|
certOnly, chain := splitPEMChain(chainPEM)
|
||||||
|
|
||||||
|
// Verify cert part
|
||||||
|
if !bytes.Contains([]byte(certOnly), []byte("-----BEGIN CERTIFICATE-----")) {
|
||||||
|
t.Error("cert part missing BEGIN marker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify chain part
|
||||||
|
if !bytes.Contains([]byte(chain), []byte("-----BEGIN CERTIFICATE-----")) {
|
||||||
|
t.Error("chain part missing BEGIN marker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify they're different
|
||||||
|
if certOnly == chain {
|
||||||
|
t.Error("cert and chain should be different")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplitPEMChain_SingleCert tests PEM chain splitting with single certificate.
|
||||||
|
func TestSplitPEMChain_SingleCert(t *testing.T) {
|
||||||
|
cert, _ := generateTestCertWithCN("example.com")
|
||||||
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
||||||
|
certPEM := string(pem.EncodeToMemory(block))
|
||||||
|
|
||||||
|
certOnly, chain := splitPEMChain(certPEM)
|
||||||
|
|
||||||
|
if certOnly != certPEM {
|
||||||
|
t.Error("single cert should be returned as-is")
|
||||||
|
}
|
||||||
|
if chain != "" {
|
||||||
|
t.Error("chain should be empty for single cert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplitPEMChain_InvalidPEM tests PEM chain splitting with invalid PEM.
|
||||||
|
func TestSplitPEMChain_InvalidPEM(t *testing.T) {
|
||||||
|
invalidPEM := "not a valid pem"
|
||||||
|
|
||||||
|
certOnly, chain := splitPEMChain(invalidPEM)
|
||||||
|
|
||||||
|
if certOnly != invalidPEM {
|
||||||
|
t.Error("invalid PEM should be returned as-is in cert part")
|
||||||
|
}
|
||||||
|
if chain != "" {
|
||||||
|
t.Error("chain should be empty for invalid PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParsePEMFile tests parsing a PEM file with certificates.
|
||||||
|
func TestParsePEMFile(t *testing.T) {
|
||||||
|
// Create a temporary file with a PEM certificate
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
certPath := filepath.Join(tmpdir, "cert.pem")
|
||||||
|
|
||||||
|
cert, _ := generateTestCert()
|
||||||
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
||||||
|
certPEM := pem.EncodeToMemory(block)
|
||||||
|
|
||||||
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write test cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
// Parse the file
|
||||||
|
entries := agent.parsePEMFile(certPath)
|
||||||
|
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("expected 1 certificate, got %d", len(entries))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := entries[0]
|
||||||
|
if entry.CommonName != "test.example.com" {
|
||||||
|
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
|
||||||
|
}
|
||||||
|
if entry.SourceFormat != "PEM" {
|
||||||
|
t.Errorf("expected format 'PEM', got '%s'", entry.SourceFormat)
|
||||||
|
}
|
||||||
|
if entry.SourcePath != certPath {
|
||||||
|
t.Errorf("expected path '%s', got '%s'", certPath, entry.SourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify fingerprint is non-empty and correct length (SHA256 hex = 64 chars)
|
||||||
|
if len(entry.FingerprintSHA256) != 64 {
|
||||||
|
t.Errorf("expected 64-char fingerprint, got %d", len(entry.FingerprintSHA256))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParsePEMFile_MultipleCerts tests parsing a PEM file with multiple certificates.
|
||||||
|
func TestParsePEMFile_MultipleCerts(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
certPath := filepath.Join(tmpdir, "chain.pem")
|
||||||
|
|
||||||
|
cert1, _ := generateTestCertWithCN("cert1.example.com")
|
||||||
|
cert2, _ := generateTestCertWithCN("cert2.example.com")
|
||||||
|
|
||||||
|
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
||||||
|
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
||||||
|
|
||||||
|
certPEM := append(pem.EncodeToMemory(block1), pem.EncodeToMemory(block2)...)
|
||||||
|
|
||||||
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write test cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
entries := agent.parsePEMFile(certPath)
|
||||||
|
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Errorf("expected 2 certificates, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseDERFile tests parsing a DER-encoded certificate file.
|
||||||
|
func TestParseDERFile(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
derPath := filepath.Join(tmpdir, "cert.der")
|
||||||
|
|
||||||
|
cert, _ := generateTestCertWithCN("test.example.com")
|
||||||
|
if err := os.WriteFile(derPath, cert.Raw, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write test cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
entry, err := agent.parseDERFile(derPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.CommonName != "test.example.com" {
|
||||||
|
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
|
||||||
|
}
|
||||||
|
if entry.SourceFormat != "DER" {
|
||||||
|
t.Errorf("expected format 'DER', got '%s'", entry.SourceFormat)
|
||||||
|
}
|
||||||
|
if len(entry.FingerprintSHA256) != 64 {
|
||||||
|
t.Errorf("expected 64-char fingerprint, got %d", len(entry.FingerprintSHA256))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseDERFile_Invalid tests parsing an invalid DER file.
|
||||||
|
func TestParseDERFile_Invalid(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
derPath := filepath.Join(tmpdir, "invalid.der")
|
||||||
|
|
||||||
|
if err := os.WriteFile(derPath, []byte("not a valid der file"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
_, err := agent.parseDERFile(derPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid DER file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanDirectory tests scanning a directory for certificate files.
|
||||||
|
func TestScanDirectory(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
|
// Create subdirectory
|
||||||
|
subdir := filepath.Join(tmpdir, "subdir")
|
||||||
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create certificates with various extensions
|
||||||
|
cert1, _ := generateTestCertWithCN("cert1.example.com")
|
||||||
|
cert2, _ := generateTestCertWithCN("cert2.example.com")
|
||||||
|
|
||||||
|
// Write cert1.pem
|
||||||
|
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpdir, "cert1.pem"), pem.EncodeToMemory(block1), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write cert1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write cert2.crt in subdir
|
||||||
|
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
||||||
|
if err := os.WriteFile(filepath.Join(subdir, "cert2.crt"), pem.EncodeToMemory(block2), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write cert2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
DiscoveryDirs: []string{tmpdir},
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
// Simulate directory walk manually (as runDiscoveryScan does)
|
||||||
|
var certs []discoveredCertEntry
|
||||||
|
filepath.Walk(tmpdir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(path)
|
||||||
|
switch ext {
|
||||||
|
case ".pem", ".crt":
|
||||||
|
found := agent.parsePEMFile(path)
|
||||||
|
certs = append(certs, found...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(certs) != 2 {
|
||||||
|
t.Errorf("expected 2 certificates from directory scan, got %d", len(certs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateTargetConnector_NGINX tests connector creation for NGINX target.
|
||||||
|
func TestCreateTargetConnector_NGINX(t *testing.T) {
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
|
||||||
|
connector, err := agent.createTargetConnector("NGINX", configJSON)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if connector == nil {
|
||||||
|
t.Error("expected connector to be non-nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateTargetConnector_Unsupported tests connector creation for unsupported type.
|
||||||
|
func TestCreateTargetConnector_Unsupported(t *testing.T) {
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
_, err := agent.createTargetConnector("UnsupportedType", nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unsupported target type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchCertificate_Success tests fetching a certificate from the control plane.
|
||||||
|
func TestFetchCertificate_Success(t *testing.T) {
|
||||||
|
cert, _ := generateTestCertWithCN("test.example.com")
|
||||||
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
||||||
|
expectedCertPEM := string(pem.EncodeToMemory(block))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/agents/a-test/certificates/mc-001" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"certificate_pem": expectedCertPEM,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
certPEM, err := agent.fetchCertificate(context.Background(), "mc-001")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if certPEM != expectedCertPEM {
|
||||||
|
t.Error("certificate PEM mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchCertificate_NotFound tests fetching a non-existent certificate.
|
||||||
|
func TestFetchCertificate_NotFound(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("not found"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
_, err := agent.fetchCertificate(context.Background(), "mc-nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-existent certificate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReportJobStatus_Success tests reporting job status to the control plane.
|
||||||
|
func TestReportJobStatus_Success(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/agents/a-test/jobs/j-001/status" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("unexpected method: %s", r.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
|
||||||
|
if payload["status"] != "Completed" {
|
||||||
|
t.Errorf("expected status 'Completed', got '%s'", payload["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
err := agent.reportJobStatus(context.Background(), "j-001", "Completed", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReportJobStatus_WithError tests reporting job status with error message.
|
||||||
|
func TestReportJobStatus_WithError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
|
||||||
|
if payload["status"] != "Failed" {
|
||||||
|
t.Errorf("expected status 'Failed', got '%s'", payload["status"])
|
||||||
|
}
|
||||||
|
if payload["error"] != "deployment failed" {
|
||||||
|
t.Errorf("expected error 'deployment failed', got '%s'", payload["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
err := agent.reportJobStatus(context.Background(), "j-001", "Failed", "deployment failed")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMakeRequest_Success tests making an authenticated HTTP request.
|
||||||
|
func TestMakeRequest_Success(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify auth header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "Bearer test-key" {
|
||||||
|
t.Errorf("unexpected auth: %s", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content-type
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if ct != "application/json" {
|
||||||
|
t.Errorf("unexpected content-type: %s", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
resp, err := agent.makeRequest(context.Background(), http.MethodPost, "/test", map[string]string{"key": "value"})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("unexpected status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMakeRequest_InvalidURL tests making a request with invalid URL.
|
||||||
|
func TestMakeRequest_InvalidURL(t *testing.T) {
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://invalid-host-that-does-not-exist.local:9999",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
_, err := agent.makeRequest(context.Background(), http.MethodGet, "/test", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unreachable host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCertKeyInfo tests extraction of key algorithm and size from certificates.
|
||||||
|
func TestCertKeyInfo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
genKey func() interface{}
|
||||||
|
expectedAlg string
|
||||||
|
minBitSize int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ECDSA P-256",
|
||||||
|
genKey: func() interface{} {
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
return key.Public()
|
||||||
|
},
|
||||||
|
expectedAlg: "ECDSA",
|
||||||
|
minBitSize: 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RSA 2048",
|
||||||
|
genKey: func() interface{} {
|
||||||
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
return key.Public()
|
||||||
|
},
|
||||||
|
expectedAlg: "RSA",
|
||||||
|
minBitSize: 2048,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
pubKey := tt.genKey()
|
||||||
|
|
||||||
|
// Create certificate with this key
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: "test.com",
|
||||||
|
},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var privKey interface{}
|
||||||
|
if ecdsaPub, ok := pubKey.(*ecdsa.PublicKey); ok {
|
||||||
|
key, _ := ecdsa.GenerateKey(ecdsaPub.Curve, rand.Reader)
|
||||||
|
privKey = key
|
||||||
|
} else if rsaPub, ok := pubKey.(*rsa.PublicKey); ok {
|
||||||
|
key, _ := rsa.GenerateKey(rand.Reader, rsaPub.N.BitLen())
|
||||||
|
privKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, _ := x509.CreateCertificate(rand.Reader, template, template, pubKey, privKey)
|
||||||
|
cert, _ := x509.ParseCertificate(certDER)
|
||||||
|
|
||||||
|
alg, bitSize := certKeyInfo(cert)
|
||||||
|
if alg != tt.expectedAlg {
|
||||||
|
t.Errorf("expected algorithm %s, got %s", tt.expectedAlg, alg)
|
||||||
|
}
|
||||||
|
if bitSize < tt.minBitSize {
|
||||||
|
t.Errorf("expected bitsize >= %d, got %d", tt.minBitSize, bitSize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAgent tests agent initialization.
|
||||||
|
func TestNewAgent(t *testing.T) {
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
if agent.config != cfg {
|
||||||
|
t.Error("config not set correctly")
|
||||||
|
}
|
||||||
|
if agent.heartbeatInterval != 60*time.Second {
|
||||||
|
t.Errorf("expected heartbeat interval 60s, got %v", agent.heartbeatInterval)
|
||||||
|
}
|
||||||
|
if agent.pollInterval != 30*time.Second {
|
||||||
|
t.Errorf("expected poll interval 30s, got %v", agent.pollInterval)
|
||||||
|
}
|
||||||
|
if agent.client == nil {
|
||||||
|
t.Error("HTTP client not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAgent_WithLogger tests agent initialization with logger.
|
||||||
|
func TestNewAgent_WithLogger(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: "http://localhost:8443",
|
||||||
|
APIKey: "test-key",
|
||||||
|
AgentID: "a-test",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := NewAgent(cfg, logger)
|
||||||
|
|
||||||
|
if agent.logger != logger {
|
||||||
|
t.Error("logger not set correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create test certificates with specific CN
|
||||||
|
func generateTestCertWithCN(commonName string) (*x509.Certificate, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: commonName,
|
||||||
|
},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
DNSNames: []string{commonName},
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return x509.ParseCertificate(certDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create string pointer
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
@@ -28,10 +28,12 @@ import (
|
|||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target/traefik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentConfig represents the agent-side configuration.
|
// AgentConfig represents the agent-side configuration.
|
||||||
@@ -342,11 +344,23 @@ func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Create CSR with common name and SANs
|
// Step 3: Create CSR with common name and SANs
|
||||||
|
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||||
|
var dnsNames []string
|
||||||
|
var emailAddresses []string
|
||||||
|
for _, san := range job.SANs {
|
||||||
|
if strings.Contains(san, "@") {
|
||||||
|
emailAddresses = append(emailAddresses, san)
|
||||||
|
} else {
|
||||||
|
dnsNames = append(dnsNames, san)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
csrTemplate := &x509.CertificateRequest{
|
csrTemplate := &x509.CertificateRequest{
|
||||||
Subject: pkix.Name{
|
Subject: pkix.Name{
|
||||||
CommonName: job.CommonName,
|
CommonName: job.CommonName,
|
||||||
},
|
},
|
||||||
DNSNames: job.SANs,
|
DNSNames: dnsNames,
|
||||||
|
EmailAddresses: emailAddresses,
|
||||||
}
|
}
|
||||||
|
|
||||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||||
@@ -508,6 +522,16 @@ func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
|
|||||||
"target_type", job.TargetType,
|
"target_type", job.TargetType,
|
||||||
"success", result.Success,
|
"success", result.Success,
|
||||||
"message", result.Message)
|
"message", result.Message)
|
||||||
|
|
||||||
|
// If verification is enabled, verify the deployment by probing the live TLS endpoint
|
||||||
|
targetHost, targetPort, err := extractTargetHostAndPort(job.TargetConfig)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("could not extract target host/port for verification",
|
||||||
|
"job_id", job.ID,
|
||||||
|
"error", err)
|
||||||
|
} else {
|
||||||
|
a.verifyAndReportDeployment(ctx, job, targetHost, targetPort, certOnly)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
a.logger.Info("no target type specified, skipping connector invocation",
|
a.logger.Info("no target type specified, skipping connector invocation",
|
||||||
"job_id", job.ID)
|
"job_id", job.ID)
|
||||||
@@ -570,6 +594,24 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
|||||||
}
|
}
|
||||||
return iis.New(&cfg, a.logger), nil
|
return iis.New(&cfg, a.logger), nil
|
||||||
|
|
||||||
|
case "Traefik":
|
||||||
|
var cfg traefik.Config
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Traefik config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return traefik.New(&cfg, a.logger), nil
|
||||||
|
|
||||||
|
case "Caddy":
|
||||||
|
var cfg caddy.Config
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Caddy config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caddy.New(&cfg, a.logger), nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verifyDeployment probes the live TLS endpoint for a deployment target and verifies
|
||||||
|
// that the deployed certificate matches what we expect.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - targetHost: the hostname or IP of the target (extracted from target config)
|
||||||
|
// - targetPort: the TLS port of the target (e.g., 443)
|
||||||
|
// - expectedCertPEM: the PEM-encoded certificate that was deployed
|
||||||
|
// - delay: wait time before probing (e.g., 2 seconds for reload to take effect)
|
||||||
|
// - timeout: overall timeout for TLS connection attempt (e.g., 10 seconds)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A VerificationResult if probing succeeded (even if cert doesn't match)
|
||||||
|
// - An error if the probe itself failed (network error, timeout, etc.)
|
||||||
|
//
|
||||||
|
// The function compares the SHA-256 fingerprints of the expected and actual certificates.
|
||||||
|
// If the certificate served at the endpoint differs, Verified will be false but no error
|
||||||
|
// is returned — this is an expected verification failure, not a probe failure.
|
||||||
|
func verifyDeployment(
|
||||||
|
ctx context.Context,
|
||||||
|
targetHost string,
|
||||||
|
targetPort int,
|
||||||
|
expectedCertPEM string,
|
||||||
|
delay time.Duration,
|
||||||
|
timeout time.Duration,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) (*VerificationResult, error) {
|
||||||
|
// Wait for reload to take effect
|
||||||
|
if delay > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse expected certificate to compute its fingerprint
|
||||||
|
expectedFp, err := computeCertificateFingerprint(expectedCertPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse expected certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the target's TLS endpoint
|
||||||
|
address := fmt.Sprintf("%s:%d", targetHost, targetPort)
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug("probing TLS endpoint for verification",
|
||||||
|
"address", address,
|
||||||
|
"expected_fingerprint", expectedFp)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := &net.Dialer{Timeout: timeout}
|
||||||
|
conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
|
||||||
|
// SECURITY NOTE: InsecureSkipVerify is intentionally set to true here.
|
||||||
|
// Post-deployment verification must probe the live endpoint to extract and
|
||||||
|
// compare the served certificate fingerprint, regardless of its validity
|
||||||
|
// state (expired, self-signed, internal CA, etc.). This setting is scoped
|
||||||
|
// to verification probing only — it is NEVER used for control-plane API
|
||||||
|
// calls, issuer connector communication, or any operation that trusts the
|
||||||
|
// certificate. The verification result compares SHA-256 fingerprints only.
|
||||||
|
// See TICKET-016 for full security audit rationale.
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
ServerName: targetHost, // For SNI
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to %s: %w", address, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Extract the leaf certificate from the TLS connection
|
||||||
|
state := conn.ConnectionState()
|
||||||
|
if len(state.PeerCertificates) == 0 {
|
||||||
|
return nil, fmt.Errorf("no certificates presented by %s", address)
|
||||||
|
}
|
||||||
|
|
||||||
|
leafCert := state.PeerCertificates[0]
|
||||||
|
actualFp := fmt.Sprintf("%x", sha256.Sum256(leafCert.Raw))
|
||||||
|
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug("received certificate from endpoint",
|
||||||
|
"address", address,
|
||||||
|
"cn", leafCert.Subject.CommonName,
|
||||||
|
"actual_fingerprint", actualFp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fingerprints
|
||||||
|
verified := actualFp == expectedFp
|
||||||
|
if logger != nil {
|
||||||
|
if !verified {
|
||||||
|
logger.Warn("certificate fingerprint mismatch at endpoint",
|
||||||
|
"address", address,
|
||||||
|
"expected_fingerprint", expectedFp,
|
||||||
|
"actual_fingerprint", actualFp)
|
||||||
|
} else {
|
||||||
|
logger.Info("certificate verification succeeded",
|
||||||
|
"address", address,
|
||||||
|
"fingerprint", actualFp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &VerificationResult{
|
||||||
|
ExpectedFingerprint: expectedFp,
|
||||||
|
ActualFingerprint: actualFp,
|
||||||
|
Verified: verified,
|
||||||
|
VerifiedAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerificationResult represents the outcome of verifying a deployed certificate.
|
||||||
|
type VerificationResult struct {
|
||||||
|
ExpectedFingerprint string `json:"expected_fingerprint"`
|
||||||
|
ActualFingerprint string `json:"actual_fingerprint"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
VerifiedAt time.Time `json:"verified_at"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeCertificateFingerprint computes the SHA-256 fingerprint of a PEM-encoded certificate.
|
||||||
|
func computeCertificateFingerprint(certPEM string) (string, error) {
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
return "", fmt.Errorf("failed to decode PEM certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse x509 certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp := sha256.Sum256(cert.Raw)
|
||||||
|
return fmt.Sprintf("%x", fp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportVerificationResult submits the verification result back to the control plane.
|
||||||
|
// This is a best-effort operation — a failure to report doesn't block agent progress.
|
||||||
|
func (a *Agent) reportVerificationResult(
|
||||||
|
ctx context.Context,
|
||||||
|
jobID string,
|
||||||
|
targetID string,
|
||||||
|
result *VerificationResult,
|
||||||
|
) error {
|
||||||
|
if jobID == "" || targetID == "" || result == nil {
|
||||||
|
return fmt.Errorf("missing required fields for verification report")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the request payload
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"target_id": targetID,
|
||||||
|
"expected_fingerprint": result.ExpectedFingerprint,
|
||||||
|
"actual_fingerprint": result.ActualFingerprint,
|
||||||
|
"verified": result.Verified,
|
||||||
|
"error": result.Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal verification result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST to /api/v1/jobs/{id}/verify
|
||||||
|
url := fmt.Sprintf("%s/api/v1/jobs/%s/verify", a.config.ServerURL, jobID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create verification request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.config.APIKey))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send verification result: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check response status
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("verification reporting failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Debug("verification result reported to control plane",
|
||||||
|
"job_id", jobID,
|
||||||
|
"verified", result.Verified)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTargetHostAndPort extracts the host and port from target configuration.
|
||||||
|
// Common target configs include "host" or "hostname" and "port" fields.
|
||||||
|
func extractTargetHostAndPort(configJSON json.RawMessage) (string, int, error) {
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := json.Unmarshal(configJSON, &config); err != nil {
|
||||||
|
return "", 0, fmt.Errorf("invalid target config JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try common field names for hostname
|
||||||
|
var host string
|
||||||
|
for _, key := range []string{"host", "hostname", "target", "address"} {
|
||||||
|
if h, ok := config[key].(string); ok && h != "" {
|
||||||
|
host = h
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return "", 0, fmt.Errorf("target config missing host/hostname field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try common field names for port, default to 443
|
||||||
|
port := 443
|
||||||
|
if p, ok := config["port"].(float64); ok {
|
||||||
|
port = int(p)
|
||||||
|
}
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return "", 0, fmt.Errorf("invalid port: %d", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
return host, port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyAndReportDeployment performs TLS endpoint verification and reports the result.
|
||||||
|
// This is a best-effort operation — failures are logged but don't affect deployment status.
|
||||||
|
func (a *Agent) verifyAndReportDeployment(
|
||||||
|
ctx context.Context,
|
||||||
|
job JobItem,
|
||||||
|
targetHost string,
|
||||||
|
targetPort int,
|
||||||
|
certPEM string,
|
||||||
|
) {
|
||||||
|
// Perform verification with configured timeout and delay
|
||||||
|
result, err := verifyDeployment(ctx, targetHost, targetPort, certPEM,
|
||||||
|
2*time.Second, // delay before probing
|
||||||
|
10*time.Second, // timeout for TLS connection
|
||||||
|
a.logger)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Warn("verification probe failed",
|
||||||
|
"job_id", job.ID,
|
||||||
|
"target_host", targetHost,
|
||||||
|
"target_port", targetPort,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
// Probe failure: report error but continue
|
||||||
|
result = &VerificationResult{
|
||||||
|
Error: err.Error(),
|
||||||
|
VerifiedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report result to control plane
|
||||||
|
if job.TargetID == nil {
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Warn("cannot report verification: target_id is nil", "job_id", job.ID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.reportVerificationResult(ctx, job.ID, *job.TargetID, result); err != nil {
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Warn("failed to report verification result",
|
||||||
|
"job_id", job.ID,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
// Non-blocking: continue even if report fails
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeCertificateFingerprint(t *testing.T) {
|
||||||
|
// Generate a test certificate for fingerprint validation
|
||||||
|
cert, err := generateTestCert()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate test cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: cert.Raw,
|
||||||
|
}))
|
||||||
|
|
||||||
|
fp, err := computeCertificateFingerprint(certPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fp) != 64 { // SHA256 hex = 64 chars
|
||||||
|
t.Errorf("expected 64 char fingerprint, got %d", len(fp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeCertificateFingerprint_InvalidPEM(t *testing.T) {
|
||||||
|
_, err := computeCertificateFingerprint("not a valid pem")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeCertificateFingerprint_EmptyString(t *testing.T) {
|
||||||
|
_, err := computeCertificateFingerprint("")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTargetHostAndPort_ValidConfig(t *testing.T) {
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"host": "example.com",
|
||||||
|
"port": 443.0,
|
||||||
|
}
|
||||||
|
configJSON, _ := json.Marshal(config)
|
||||||
|
|
||||||
|
host, port, err := extractTargetHostAndPort(configJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if host != "example.com" {
|
||||||
|
t.Errorf("expected host example.com, got %s", host)
|
||||||
|
}
|
||||||
|
if port != 443 {
|
||||||
|
t.Errorf("expected port 443, got %d", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTargetHostAndPort_DefaultPort(t *testing.T) {
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"hostname": "test.local",
|
||||||
|
}
|
||||||
|
configJSON, _ := json.Marshal(config)
|
||||||
|
|
||||||
|
host, port, err := extractTargetHostAndPort(configJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if host != "test.local" {
|
||||||
|
t.Errorf("expected host test.local, got %s", host)
|
||||||
|
}
|
||||||
|
if port != 443 {
|
||||||
|
t.Errorf("expected default port 443, got %d", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTargetHostAndPort_MissingHost(t *testing.T) {
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"port": 443.0,
|
||||||
|
}
|
||||||
|
configJSON, _ := json.Marshal(config)
|
||||||
|
|
||||||
|
_, _, err := extractTargetHostAndPort(configJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTargetHostAndPort_InvalidJSON(t *testing.T) {
|
||||||
|
configJSON := []byte("invalid json{")
|
||||||
|
|
||||||
|
_, _, err := extractTargetHostAndPort(configJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTargetHostAndPort_AlternativeFieldNames(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config map[string]interface{}
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"host", map[string]interface{}{"host": "host1.com"}, "host1.com"},
|
||||||
|
{"hostname", map[string]interface{}{"hostname": "host2.com"}, "host2.com"},
|
||||||
|
{"target", map[string]interface{}{"target": "host3.com"}, "host3.com"},
|
||||||
|
{"address", map[string]interface{}{"address": "host4.com"}, "host4.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
configJSON, _ := json.Marshal(tt.config)
|
||||||
|
host, _, err := extractTargetHostAndPort(configJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if host != tt.expected {
|
||||||
|
t.Errorf("expected %s, got %s", tt.expected, host)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_Timeout(t *testing.T) {
|
||||||
|
cert, _ := generateTestCert()
|
||||||
|
certPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: cert.Raw,
|
||||||
|
}))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
result, err := verifyDeployment(ctx, "192.0.2.1", 443, certPEM, 0, 100*time.Millisecond, nil)
|
||||||
|
|
||||||
|
// Connection to reserved test IP should timeout or fail
|
||||||
|
if err == nil && result == nil {
|
||||||
|
t.Error("expected error or result for unreachable host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_InvalidCertPEM(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
result, err := verifyDeployment(ctx, "localhost", 443, "not a cert", 0, 5*time.Second, nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid certificate PEM")
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Error("expected no result on error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate a test certificate for testing
|
||||||
|
func generateTestCert() (*x509.Certificate, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
DNSNames: []string{"test.example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return x509.ParseCertificate(certDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportVerificationResult_Success(t *testing.T) {
|
||||||
|
// Create mock HTTP server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/jobs/j-test/verify" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Errorf("unexpected method: %s", r.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "Bearer test-api-key" {
|
||||||
|
t.Errorf("unexpected auth header: %s", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify request body
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["verified"] != true {
|
||||||
|
t.Error("expected verified to be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"job_id": "j-test",
|
||||||
|
"verified": true,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-api-key",
|
||||||
|
}
|
||||||
|
agent := NewAgent(cfg, nil)
|
||||||
|
|
||||||
|
result := &VerificationResult{
|
||||||
|
ExpectedFingerprint: "abc123",
|
||||||
|
ActualFingerprint: "abc123",
|
||||||
|
Verified: true,
|
||||||
|
VerifiedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := agent.reportVerificationResult(context.Background(), "j-test", "t-nginx1", result)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportVerificationResult_MissingFields(t *testing.T) {
|
||||||
|
agent := NewAgent(&AgentConfig{}, nil)
|
||||||
|
|
||||||
|
result := &VerificationResult{
|
||||||
|
Verified: true,
|
||||||
|
VerifiedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := agent.reportVerificationResult(context.Background(), "", "t-nginx1", result)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing job ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_ContextCancellation(t *testing.T) {
|
||||||
|
cert, _ := generateTestCert()
|
||||||
|
certPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: cert.Raw,
|
||||||
|
}))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
result, err := verifyDeployment(ctx, "localhost", 443, certPEM, 1*time.Second, 5*time.Second, nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for cancelled context")
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Error("expected no result on context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock TLS server for verification testing.
|
||||||
|
// Reserved for future use when real TLS verification integration tests are added.
|
||||||
|
var _ = func(t *testing.T, cert *x509.Certificate) (string, func()) {
|
||||||
|
// Create TLS listener with test certificate
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
address := listener.Addr().String()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
// Simple echo to keep connection alive
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
conn.Read(buf) //nolint:errcheck
|
||||||
|
}()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return address, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerificationResult_JSONMarshaling(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
result := &VerificationResult{
|
||||||
|
ExpectedFingerprint: "abc123",
|
||||||
|
ActualFingerprint: "def456",
|
||||||
|
Verified: false,
|
||||||
|
VerifiedAt: now,
|
||||||
|
Error: "fingerprint mismatch",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error marshaling: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled VerificationResult
|
||||||
|
err = json.Unmarshal(data, &unmarshaled)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error unmarshaling: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled.Error != "fingerprint mismatch" {
|
||||||
|
t.Errorf("error mismatch: got %s", unmarshaled.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportVerificationResult_ServerError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &AgentConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
APIKey: "test-api-key",
|
||||||
|
}
|
||||||
|
agent := NewAgent(cfg, nil)
|
||||||
|
|
||||||
|
result := &VerificationResult{
|
||||||
|
ExpectedFingerprint: "abc123",
|
||||||
|
ActualFingerprint: "abc123",
|
||||||
|
Verified: true,
|
||||||
|
VerifiedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := agent.reportVerificationResult(context.Background(), "j-test", "t-nginx1", result)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for server error response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTargetHostAndPort_InvalidPort(t *testing.T) {
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"host": "example.com",
|
||||||
|
"port": 99999.0,
|
||||||
|
}
|
||||||
|
configJSON, _ := json.Marshal(config)
|
||||||
|
|
||||||
|
_, _, err := extractTargetHostAndPort(configJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTargetHostAndPort_ZeroPort(t *testing.T) {
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"host": "example.com",
|
||||||
|
"port": 0.0,
|
||||||
|
}
|
||||||
|
configJSON, _ := json.Marshal(config)
|
||||||
|
|
||||||
|
_, _, err := extractTargetHostAndPort(configJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for zero port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_FingerprintComparison(t *testing.T) {
|
||||||
|
// Create a simple TLS server for testing
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Get the server's TLS certificate from TLS config
|
||||||
|
if len(server.TLS.Certificates) == 0 {
|
||||||
|
t.Skip("no TLS certificates configured on test server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the leaf certificate from the DER bytes
|
||||||
|
leafDER := server.TLS.Certificates[0].Certificate[0]
|
||||||
|
leafCert, err := x509.ParseCertificate(leafDER)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse test server certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: leafCert.Raw,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get host and port from the listener address
|
||||||
|
addr := server.Listener.Addr().String()
|
||||||
|
host, portStr, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse server address: %v", err)
|
||||||
|
}
|
||||||
|
port := 0
|
||||||
|
fmt.Sscanf(portStr, "%d", &port)
|
||||||
|
|
||||||
|
// Verify deployment against the live TLS server
|
||||||
|
ctx := context.Background()
|
||||||
|
result, _ := verifyDeployment(ctx, host, port, certPEM, 0, 5*time.Second, nil)
|
||||||
|
|
||||||
|
// This test may fail in some environments due to TLS setup complexity
|
||||||
|
// The key is testing the fingerprint comparison logic
|
||||||
|
if result != nil {
|
||||||
|
if result.Verified && result.ExpectedFingerprint != result.ActualFingerprint {
|
||||||
|
t.Error("fingerprint mismatch: expected and actual should match if Verified is true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||||
|
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||||
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"
|
||||||
@@ -44,7 +45,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
logger.Info("certctl server starting",
|
logger.Info("certctl server starting",
|
||||||
"version", "0.1.0",
|
"version", "2.0.9",
|
||||||
"server_host", cfg.Server.Host,
|
"server_host", cfg.Server.Host,
|
||||||
"server_port", cfg.Server.Port)
|
"server_port", cfg.Server.Port)
|
||||||
|
|
||||||
@@ -97,14 +98,18 @@ func main() {
|
|||||||
localCA := local.New(localCAConfig, logger)
|
localCA := local.New(localCAConfig, logger)
|
||||||
logger.Info("initialized Local CA issuer connector")
|
logger.Info("initialized Local CA issuer connector")
|
||||||
|
|
||||||
// Initialize ACME issuer connector (for Let's Encrypt, Sectigo, etc.)
|
// Initialize ACME issuer connector (for Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, etc.)
|
||||||
// Supports HTTP-01 (default) and DNS-01 (for wildcards) challenge types.
|
// Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types.
|
||||||
|
// EAB (External Account Binding) required by ZeroSSL, Google Trust Services, SSL.com.
|
||||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
||||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"),
|
||||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"),
|
||||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
||||||
|
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||||
|
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||||
|
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
||||||
}, logger)
|
}, logger)
|
||||||
logger.Info("initialized ACME issuer connector")
|
logger.Info("initialized ACME issuer connector")
|
||||||
|
|
||||||
@@ -185,19 +190,46 @@ func main() {
|
|||||||
logger.Info("OpsGenie notifier enabled")
|
logger.Info("OpsGenie notifier enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire email notifier if SMTP is configured
|
||||||
|
var emailAdapter *notifyemail.NotifierAdapter
|
||||||
|
if cfg.Notifiers.SMTPHost != "" && cfg.Notifiers.SMTPFromAddress != "" {
|
||||||
|
emailConnector := notifyemail.New(¬ifyemail.Config{
|
||||||
|
SMTPHost: cfg.Notifiers.SMTPHost,
|
||||||
|
SMTPPort: cfg.Notifiers.SMTPPort,
|
||||||
|
Username: cfg.Notifiers.SMTPUsername,
|
||||||
|
Password: cfg.Notifiers.SMTPPassword,
|
||||||
|
FromAddress: cfg.Notifiers.SMTPFromAddress,
|
||||||
|
UseTLS: cfg.Notifiers.SMTPUseTLS,
|
||||||
|
}, logger)
|
||||||
|
emailAdapter = notifyemail.NewNotifierAdapter(emailConnector)
|
||||||
|
notifierRegistry["Email"] = emailAdapter
|
||||||
|
logger.Info("Email notifier enabled",
|
||||||
|
"smtp_host", cfg.Notifiers.SMTPHost,
|
||||||
|
"smtp_port", cfg.Notifiers.SMTPPort,
|
||||||
|
"from", cfg.Notifiers.SMTPFromAddress)
|
||||||
|
}
|
||||||
|
|
||||||
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
|
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
|
||||||
notificationService.SetOwnerRepo(ownerRepo)
|
notificationService.SetOwnerRepo(ownerRepo)
|
||||||
|
|
||||||
// Wire revocation dependencies into CertificateService
|
// Create RevocationSvc with its dependencies
|
||||||
certificateService.SetRevocationRepo(revocationRepo)
|
revocationSvc := service.NewRevocationSvc(certificateRepo, revocationRepo, auditService)
|
||||||
certificateService.SetNotificationService(notificationService)
|
revocationSvc.SetIssuerRegistry(issuerRegistry)
|
||||||
certificateService.SetIssuerRegistry(issuerRegistry)
|
revocationSvc.SetNotificationService(notificationService)
|
||||||
certificateService.SetProfileRepo(profileRepo)
|
|
||||||
|
// Create CAOperationsSvc with its dependencies
|
||||||
|
caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certificateRepo, profileRepo)
|
||||||
|
caOperationsSvc.SetIssuerRegistry(issuerRegistry)
|
||||||
|
|
||||||
|
// Wire sub-services into CertificateService
|
||||||
|
certificateService.SetRevocationSvc(revocationSvc)
|
||||||
|
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||||
certificateService.SetTargetRepo(targetRepo)
|
certificateService.SetTargetRepo(targetRepo)
|
||||||
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)
|
||||||
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, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
|
agentService.SetProfileRepo(profileRepo)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||||
targetService := service.NewTargetService(targetRepo, auditService)
|
targetService := service.NewTargetService(targetRepo, auditService)
|
||||||
profileService := service.NewProfileService(profileRepo, auditService)
|
profileService := service.NewProfileService(profileRepo, auditService)
|
||||||
@@ -249,6 +281,30 @@ func main() {
|
|||||||
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
||||||
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
|
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
|
||||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||||
|
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
||||||
|
verificationHandler := handler.NewVerificationHandler(verificationService)
|
||||||
|
exportService := service.NewExportService(certificateRepo, auditService)
|
||||||
|
exportHandler := handler.NewExportHandler(exportService)
|
||||||
|
|
||||||
|
// Initialize digest service (requires email notifier)
|
||||||
|
var digestService *service.DigestService
|
||||||
|
var digestHandler *handler.DigestHandler
|
||||||
|
if cfg.Digest.Enabled && emailAdapter != nil {
|
||||||
|
digestService = service.NewDigestService(
|
||||||
|
statsService, certificateRepo, ownerRepo, emailAdapter, cfg.Digest.Recipients, logger,
|
||||||
|
)
|
||||||
|
digestHandler = handler.NewDigestHandler(digestService)
|
||||||
|
logger.Info("digest service enabled",
|
||||||
|
"interval", cfg.Digest.Interval.String(),
|
||||||
|
"recipients", len(cfg.Digest.Recipients))
|
||||||
|
} else {
|
||||||
|
// Create a no-op digest handler for route registration
|
||||||
|
digestHandler = handler.NewDigestHandler(nil)
|
||||||
|
if cfg.Digest.Enabled && emailAdapter == nil {
|
||||||
|
logger.Warn("digest enabled but SMTP not configured — digest emails will not be sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("initialized all handlers")
|
logger.Info("initialized all handlers")
|
||||||
|
|
||||||
// Create context with cancellation
|
// Create context with cancellation
|
||||||
@@ -274,6 +330,11 @@ func main() {
|
|||||||
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())
|
||||||
}
|
}
|
||||||
|
if digestService != nil {
|
||||||
|
sched.SetDigestService(digestService)
|
||||||
|
sched.SetDigestInterval(cfg.Digest.Interval)
|
||||||
|
logger.Info("digest scheduler enabled", "interval", cfg.Digest.Interval.String())
|
||||||
|
}
|
||||||
|
|
||||||
// Start scheduler
|
// Start scheduler
|
||||||
logger.Info("starting scheduler")
|
logger.Info("starting scheduler")
|
||||||
@@ -283,25 +344,28 @@ func main() {
|
|||||||
|
|
||||||
// Build the API router with all handlers
|
// Build the API router with all handlers
|
||||||
apiRouter := router.New()
|
apiRouter := router.New()
|
||||||
apiRouter.RegisterHandlers(
|
apiRouter.RegisterHandlers(router.HandlerRegistry{
|
||||||
certificateHandler,
|
Certificates: certificateHandler,
|
||||||
issuerHandler,
|
Issuers: issuerHandler,
|
||||||
targetHandler,
|
Targets: targetHandler,
|
||||||
agentHandler,
|
Agents: agentHandler,
|
||||||
jobHandler,
|
Jobs: jobHandler,
|
||||||
policyHandler,
|
Policies: policyHandler,
|
||||||
profileHandler,
|
Profiles: profileHandler,
|
||||||
teamHandler,
|
Teams: teamHandler,
|
||||||
ownerHandler,
|
Owners: ownerHandler,
|
||||||
agentGroupHandler,
|
AgentGroups: agentGroupHandler,
|
||||||
auditHandler,
|
Audit: auditHandler,
|
||||||
notificationHandler,
|
Notifications: notificationHandler,
|
||||||
statsHandler,
|
Stats: statsHandler,
|
||||||
metricsHandler,
|
Metrics: metricsHandler,
|
||||||
healthHandler,
|
Health: healthHandler,
|
||||||
discoveryHandler,
|
Discovery: discoveryHandler,
|
||||||
networkScanHandler,
|
NetworkScan: networkScanHandler,
|
||||||
)
|
Verification: verificationHandler,
|
||||||
|
Export: exportHandler,
|
||||||
|
Digest: *digestHandler,
|
||||||
|
})
|
||||||
// Register EST (RFC 7030) handlers if enabled
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
if cfg.EST.Enabled {
|
if cfg.EST.Enabled {
|
||||||
issuerConn, ok := issuerRegistry[cfg.EST.IssuerID]
|
issuerConn, ok := issuerRegistry[cfg.EST.IssuerID]
|
||||||
@@ -334,6 +398,12 @@ func main() {
|
|||||||
|
|
||||||
structuredLogger := middleware.NewLogging(logger)
|
structuredLogger := middleware.NewLogging(logger)
|
||||||
|
|
||||||
|
// Request body size limit middleware — prevents memory exhaustion attacks (CWE-400)
|
||||||
|
bodyLimitMiddleware := middleware.NewBodyLimit(middleware.BodyLimitConfig{
|
||||||
|
MaxBytes: cfg.Server.MaxBodySize,
|
||||||
|
})
|
||||||
|
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -350,6 +420,7 @@ func main() {
|
|||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
|
bodyLimitMiddleware,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
auditMiddleware,
|
auditMiddleware,
|
||||||
@@ -365,6 +436,7 @@ func main() {
|
|||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
|
bodyLimitMiddleware,
|
||||||
rateLimiter,
|
rateLimiter,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
@@ -423,11 +495,12 @@ func main() {
|
|||||||
// 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,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 15 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start HTTP server in background
|
// Start HTTP server in background
|
||||||
@@ -451,6 +524,12 @@ func main() {
|
|||||||
|
|
||||||
cancel() // Stop scheduler
|
cancel() // Stop scheduler
|
||||||
|
|
||||||
|
// Wait for in-flight scheduler work to complete (up to 30 seconds)
|
||||||
|
logger.Info("waiting for scheduler to complete in-flight work")
|
||||||
|
if err := sched.WaitForCompletion(30 * time.Second); err != nil {
|
||||||
|
logger.Warn("scheduler work did not complete in time", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("shutting down HTTP server")
|
logger.Info("shutting down HTTP 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("HTTP server shutdown error", "error", err)
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ services:
|
|||||||
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/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/002_seed.sql
|
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
||||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/003_seed_demo.sql
|
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
||||||
|
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
||||||
|
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
||||||
|
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||||
|
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||||
|
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||||
|
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
||||||
|
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
|
||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -39,6 +46,7 @@ services:
|
|||||||
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"
|
||||||
|
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets
|
||||||
ports:
|
ports:
|
||||||
- "8443:8443"
|
- "8443:8443"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
# Certctl Helm Chart - Complete Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A production-ready Helm chart for deploying certctl (self-hosted certificate lifecycle management platform) on Kubernetes. The chart provides:
|
||||||
|
|
||||||
|
- High availability support with multi-replica deployments
|
||||||
|
- Persistent PostgreSQL database with automatic schema migration
|
||||||
|
- DaemonSet or Deployment-based agent deployment
|
||||||
|
- Comprehensive security contexts and RBAC
|
||||||
|
- Multiple deployment scenarios (dev, prod, HA, external DB)
|
||||||
|
- Full documentation and examples
|
||||||
|
|
||||||
|
## Chart Metadata
|
||||||
|
|
||||||
|
- **Name**: certctl
|
||||||
|
- **Chart Version**: 0.1.0
|
||||||
|
- **App Version**: 2.1.0
|
||||||
|
- **Type**: application
|
||||||
|
- **License**: BSL-1.1 (converts to Apache 2.0 in 2033)
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy/helm/
|
||||||
|
├── README.md # Main Helm chart documentation
|
||||||
|
├── DEPLOYMENT_GUIDE.md # Step-by-step deployment guide
|
||||||
|
├── CHART_SUMMARY.md # This file
|
||||||
|
│
|
||||||
|
├── certctl/
|
||||||
|
│ ├── Chart.yaml # Chart metadata
|
||||||
|
│ ├── values.yaml # Default configuration values
|
||||||
|
│ ├── .helmignore # Files to ignore when building chart
|
||||||
|
│ │
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── _helpers.tpl # Helm template helper functions
|
||||||
|
│ ├── NOTES.txt # Post-deployment notes
|
||||||
|
│ │
|
||||||
|
│ ├── server-deployment.yaml # Certctl API server deployment
|
||||||
|
│ ├── server-service.yaml # Server Kubernetes service
|
||||||
|
│ ├── server-configmap.yaml # Server configuration
|
||||||
|
│ ├── server-secret.yaml # Server secrets (API key, DB password, etc)
|
||||||
|
│ │
|
||||||
|
│ ├── postgres-statefulset.yaml # PostgreSQL database statefulset
|
||||||
|
│ ├── postgres-service.yaml # PostgreSQL headless service
|
||||||
|
│ ├── postgres-secret.yaml # Database credentials secret
|
||||||
|
│ │
|
||||||
|
│ ├── agent-daemonset.yaml # Certctl agent daemonset/deployment
|
||||||
|
│ ├── agent-configmap.yaml # Agent configuration
|
||||||
|
│ │
|
||||||
|
│ ├── ingress.yaml # Optional ingress resource
|
||||||
|
│ └── serviceaccount.yaml # ServiceAccount and RBAC
|
||||||
|
│
|
||||||
|
└── examples/
|
||||||
|
├── values-dev.yaml # Development/testing configuration
|
||||||
|
├── values-prod-ha.yaml # Production HA configuration
|
||||||
|
├── values-external-db.yaml # External PostgreSQL (RDS, Cloud SQL)
|
||||||
|
└── values-acme-dns01.yaml # ACME with DNS-01 (Let's Encrypt)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### 1. Server Deployment
|
||||||
|
|
||||||
|
**File**: `templates/server-deployment.yaml`
|
||||||
|
|
||||||
|
- Manages certctl API server instances
|
||||||
|
- Configurable replicas (default: 1)
|
||||||
|
- Health checks (liveness & readiness probes)
|
||||||
|
- Security context: non-root user, read-only filesystem
|
||||||
|
- Resource limits (default: 500m CPU, 512Mi memory)
|
||||||
|
- Automatic restart on failure
|
||||||
|
|
||||||
|
**Values**:
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
replicas: 1
|
||||||
|
port: 8443
|
||||||
|
auth:
|
||||||
|
type: api-key
|
||||||
|
apiKey: "REQUIRED"
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 128Mi}
|
||||||
|
limits: {cpu: 500m, memory: 512Mi}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. PostgreSQL StatefulSet
|
||||||
|
|
||||||
|
**File**: `templates/postgres-statefulset.yaml`
|
||||||
|
|
||||||
|
- Persistent database storage
|
||||||
|
- Automatic schema migrations on startup
|
||||||
|
- Single replica (can be extended with external HA tools)
|
||||||
|
- Health checks via pg_isready
|
||||||
|
- Configurable storage size and class
|
||||||
|
- Security context: non-root user (UID 999)
|
||||||
|
|
||||||
|
**Values**:
|
||||||
|
```yaml
|
||||||
|
postgresql:
|
||||||
|
enabled: true
|
||||||
|
storage:
|
||||||
|
size: 10Gi
|
||||||
|
storageClass: "" # Use default
|
||||||
|
auth:
|
||||||
|
database: certctl
|
||||||
|
username: certctl
|
||||||
|
password: "REQUIRED"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Agent DaemonSet/Deployment
|
||||||
|
|
||||||
|
**File**: `templates/agent-daemonset.yaml`
|
||||||
|
|
||||||
|
- DaemonSet mode: one agent per Kubernetes node
|
||||||
|
- Deployment mode: custom number of agent replicas
|
||||||
|
- Local key storage with secure permissions (0600)
|
||||||
|
- Health checks and automatic restart
|
||||||
|
- Optional certificate discovery from filesystem
|
||||||
|
|
||||||
|
**Values**:
|
||||||
|
```yaml
|
||||||
|
agent:
|
||||||
|
enabled: true
|
||||||
|
kind: DaemonSet # or Deployment
|
||||||
|
replicas: 1 # for Deployment only
|
||||||
|
keyDir: /var/lib/certctl/keys
|
||||||
|
discoveryDirs: "/etc/ssl/certs" # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Ingress (Optional)
|
||||||
|
|
||||||
|
**File**: `templates/ingress.yaml`
|
||||||
|
|
||||||
|
- Optional HTTPS ingress
|
||||||
|
- cert-manager integration for automatic TLS
|
||||||
|
- Multiple host support
|
||||||
|
- Path-based routing
|
||||||
|
|
||||||
|
**Values**:
|
||||||
|
```yaml
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: certctl.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. ConfigMaps and Secrets
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `server-configmap.yaml` - Non-secret server configuration
|
||||||
|
- `server-secret.yaml` - API key, database URL, SMTP password
|
||||||
|
- `postgres-secret.yaml` - Database credentials
|
||||||
|
- `agent-configmap.yaml` - Agent configuration
|
||||||
|
|
||||||
|
All secrets are base64-encoded and stored in Kubernetes Secrets.
|
||||||
|
|
||||||
|
### 6. ServiceAccount and RBAC
|
||||||
|
|
||||||
|
**File**: `templates/serviceaccount.yaml`
|
||||||
|
|
||||||
|
- Optional ServiceAccount creation
|
||||||
|
- Optional RBAC (ClusterRole, ClusterRoleBinding)
|
||||||
|
- Namespace-scoped by default
|
||||||
|
|
||||||
|
## Deployment Scenarios
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
Use `examples/values-dev.yaml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-dev.yaml \
|
||||||
|
--set server.auth.apiKey="dev-key" \
|
||||||
|
--set postgresql.auth.password="dev-password"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Single server replica
|
||||||
|
- Demo auth (no API key required)
|
||||||
|
- Small database (5Gi)
|
||||||
|
- LoadBalancer service for easy access
|
||||||
|
- Debug logging level
|
||||||
|
|
||||||
|
### Production HA Setup
|
||||||
|
|
||||||
|
Use `examples/values-prod-ha.yaml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-prod-ha.yaml \
|
||||||
|
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||||
|
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- 3 server replicas with pod anti-affinity
|
||||||
|
- Large database storage (100Gi)
|
||||||
|
- Pod disruption budgets
|
||||||
|
- Prometheus monitoring enabled
|
||||||
|
- Production resource limits
|
||||||
|
|
||||||
|
### External PostgreSQL
|
||||||
|
|
||||||
|
Use `examples/values-external-db.yaml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-external-db.yaml \
|
||||||
|
--set postgresql.enabled=false \
|
||||||
|
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- AWS RDS
|
||||||
|
- Google Cloud SQL
|
||||||
|
- Azure Database for PostgreSQL
|
||||||
|
- External self-managed PostgreSQL
|
||||||
|
|
||||||
|
### ACME with DNS-01
|
||||||
|
|
||||||
|
Use `examples/values-acme-dns01.yaml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-acme-dns01.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enables**:
|
||||||
|
- Automatic certificate issuance from Let's Encrypt
|
||||||
|
- DNS-01 challenge (wildcard support)
|
||||||
|
- Custom DNS provider scripts
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### Server Configuration
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `server.replicas` | 1 | Number of server replicas |
|
||||||
|
| `server.port` | 8443 | Server port |
|
||||||
|
| `server.auth.type` | api-key | Authentication type |
|
||||||
|
| `server.auth.apiKey` | "" | API key (REQUIRED) |
|
||||||
|
| `server.logging.level` | info | Log level |
|
||||||
|
| `server.logging.format` | json | Log format |
|
||||||
|
|
||||||
|
### PostgreSQL Configuration
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `postgresql.enabled` | true | Enable internal PostgreSQL |
|
||||||
|
| `postgresql.storage.size` | 10Gi | Database storage size |
|
||||||
|
| `postgresql.storage.storageClass` | "" | Storage class name |
|
||||||
|
| `postgresql.auth.password` | "" | Database password (REQUIRED) |
|
||||||
|
|
||||||
|
### Agent Configuration
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `agent.enabled` | true | Deploy agents |
|
||||||
|
| `agent.kind` | DaemonSet | DaemonSet or Deployment |
|
||||||
|
| `agent.replicas` | 1 | Replicas (Deployment only) |
|
||||||
|
| `agent.keyDir` | /var/lib/certctl/keys | Key storage directory |
|
||||||
|
|
||||||
|
### Issuer Configuration
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `server.issuer.local.enabled` | true | Enable Local CA |
|
||||||
|
| `server.issuer.acme.enabled` | false | Enable ACME |
|
||||||
|
| `server.issuer.acme.directoryURL` | "" | ACME directory URL |
|
||||||
|
| `server.issuer.acme.email` | "" | ACME email |
|
||||||
|
| `server.issuer.acme.challengeType` | http-01 | Challenge type |
|
||||||
|
|
||||||
|
See `values.yaml` for complete configuration options.
|
||||||
|
|
||||||
|
## Helm Template Functions
|
||||||
|
|
||||||
|
Defined in `templates/_helpers.tpl`:
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `certctl.name` | Chart name |
|
||||||
|
| `certctl.fullname` | Full release name |
|
||||||
|
| `certctl.chart` | Chart name and version |
|
||||||
|
| `certctl.labels` | Common labels |
|
||||||
|
| `certctl.selectorLabels` | Selector labels |
|
||||||
|
| `certctl.serverSelectorLabels` | Server selector labels |
|
||||||
|
| `certctl.agentSelectorLabels` | Agent selector labels |
|
||||||
|
| `certctl.postgresSelectorLabels` | PostgreSQL selector labels |
|
||||||
|
| `certctl.serviceAccountName` | ServiceAccount name |
|
||||||
|
| `certctl.serverImage` | Server image URI |
|
||||||
|
| `certctl.agentImage` | Agent image URI |
|
||||||
|
| `certctl.postgresImage` | PostgreSQL image URI |
|
||||||
|
| `certctl.databaseURL` | Database connection string |
|
||||||
|
| `certctl.serverURL` | Server URL for agents |
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
### Pod Security
|
||||||
|
|
||||||
|
- Non-root users (UID 1000 for app, UID 999 for PostgreSQL)
|
||||||
|
- Read-only root filesystems
|
||||||
|
- No privilege escalation
|
||||||
|
- Dropped capabilities (ALL)
|
||||||
|
- Resource limits to prevent DoS
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
|
||||||
|
- All sensitive data in Kubernetes Secrets
|
||||||
|
- Base64 encoded at rest
|
||||||
|
- Can be integrated with:
|
||||||
|
- sealed-secrets
|
||||||
|
- external-secrets
|
||||||
|
- Vault
|
||||||
|
- AWS Secrets Manager
|
||||||
|
|
||||||
|
### RBAC
|
||||||
|
|
||||||
|
- ServiceAccount per release
|
||||||
|
- Optional ClusterRole/ClusterRoleBinding
|
||||||
|
- Extensible for custom permissions
|
||||||
|
|
||||||
|
### Network Security
|
||||||
|
|
||||||
|
- Support for Kubernetes NetworkPolicies
|
||||||
|
- Service-to-service communication via internal DNS
|
||||||
|
- Optional Ingress with TLS
|
||||||
|
|
||||||
|
## Monitoring and Observability
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
- Liveness probes (detect dead containers)
|
||||||
|
- Readiness probes (detect not-ready services)
|
||||||
|
- HTTP endpoints: `/health`, `/readyz`
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Structured JSON logging
|
||||||
|
- Request ID propagation
|
||||||
|
- Configurable log levels (debug, info, warn, error)
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
- Prometheus metrics endpoint: `/api/v1/metrics/prometheus`
|
||||||
|
- Optional ServiceMonitor for Prometheus Operator
|
||||||
|
- Built-in metrics:
|
||||||
|
- Certificate counts by status
|
||||||
|
- Agent counts and status
|
||||||
|
- Job completion/failure rates
|
||||||
|
- Server uptime
|
||||||
|
|
||||||
|
## Installation Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.auth.apiKey=dev \
|
||||||
|
--set postgresql.auth.password=dev
|
||||||
|
|
||||||
|
# Production HA
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-prod-ha.yaml \
|
||||||
|
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||||
|
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||||
|
|
||||||
|
# External database
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-external-db.yaml \
|
||||||
|
--set postgresql.enabled=false \
|
||||||
|
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
|
||||||
|
|
||||||
|
# ACME with Let's Encrypt
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.issuer.acme.enabled=true \
|
||||||
|
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||||
|
kubectl logs -l app.kubernetes.io/component=server -f
|
||||||
|
|
||||||
|
# Upgrade
|
||||||
|
helm upgrade certctl certctl/ -f new-values.yaml
|
||||||
|
|
||||||
|
# Uninstall
|
||||||
|
helm uninstall certctl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Secrets Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use sealed-secrets
|
||||||
|
kubectl create secret generic certctl-secrets \
|
||||||
|
--from-literal=api-key="$(openssl rand -base64 32)" \
|
||||||
|
--dry-run=client -o yaml | kubeseal -f - | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Resource Limits
|
||||||
|
|
||||||
|
Match limits to your cluster capacity:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 250m, memory: 256Mi}
|
||||||
|
limits: {cpu: 1000m, memory: 512Mi}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Enable HA for Production
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
replicas: 3
|
||||||
|
podAntiAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution: [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Persistent Storage
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
postgresql:
|
||||||
|
storage:
|
||||||
|
size: 100Gi
|
||||||
|
storageClass: fast-ssd
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Enable Monitoring
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
monitoring:
|
||||||
|
enabled: true
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **README.md** - Complete Helm chart documentation
|
||||||
|
- **DEPLOYMENT_GUIDE.md** - Step-by-step deployment instructions
|
||||||
|
- **values.yaml** - Commented configuration reference
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues, questions, or contributions:
|
||||||
|
- GitHub: https://github.com/shankar0123/certctl
|
||||||
|
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
BSL-1.1 (Business Source License)
|
||||||
|
Converts to Apache 2.0 on March 28, 2033
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
# Certctl Helm Deployment Guide
|
||||||
|
|
||||||
|
Complete guide for deploying certctl on Kubernetes with Helm.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [Installation Methods](#installation-methods)
|
||||||
|
3. [Production Deployment](#production-deployment)
|
||||||
|
4. [Configuration Examples](#configuration-examples)
|
||||||
|
5. [Post-Deployment Setup](#post-deployment-setup)
|
||||||
|
6. [Monitoring and Logging](#monitoring-and-logging)
|
||||||
|
7. [Maintenance](#maintenance)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Required Tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify Kubernetes cluster access
|
||||||
|
kubectl cluster-info
|
||||||
|
kubectl get nodes
|
||||||
|
|
||||||
|
# Install Helm (if not already installed)
|
||||||
|
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||||
|
helm version
|
||||||
|
|
||||||
|
# Verify Helm installation
|
||||||
|
helm repo list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes Requirements
|
||||||
|
|
||||||
|
- Kubernetes 1.19 or later
|
||||||
|
- At least 2GB available memory
|
||||||
|
- At least 10GB available storage (for PostgreSQL)
|
||||||
|
- Network policies support (optional, for security)
|
||||||
|
- Ingress controller (nginx, istio, etc.) - optional
|
||||||
|
|
||||||
|
### Create Namespace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create isolated namespace
|
||||||
|
kubectl create namespace certctl
|
||||||
|
|
||||||
|
# Set as default namespace
|
||||||
|
kubectl config set-context --current --namespace=certctl
|
||||||
|
|
||||||
|
# Label for network policies (optional)
|
||||||
|
kubectl label namespace certctl certctl-ns=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation Methods
|
||||||
|
|
||||||
|
### Method 1: Minimal Development Setup
|
||||||
|
|
||||||
|
Perfect for testing and development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with minimal configuration
|
||||||
|
helm install certctl certctl/certctl \
|
||||||
|
--namespace certctl \
|
||||||
|
--set server.auth.apiKey="dev-key-change-in-production" \
|
||||||
|
--set postgresql.auth.password="dev-password-change-in-production"
|
||||||
|
|
||||||
|
# Wait for deployment
|
||||||
|
kubectl rollout status deployment/certctl-server
|
||||||
|
kubectl rollout status statefulset/certctl-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Production HA Setup
|
||||||
|
|
||||||
|
For production workloads:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate secure credentials
|
||||||
|
API_KEY=$(openssl rand -base64 32)
|
||||||
|
DB_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
# Install with HA configuration
|
||||||
|
helm install certctl certctl/certctl \
|
||||||
|
--namespace certctl \
|
||||||
|
--values deploy/helm/examples/values-prod-ha.yaml \
|
||||||
|
--set server.auth.apiKey="$API_KEY" \
|
||||||
|
--set postgresql.auth.password="$DB_PASSWORD"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: External PostgreSQL
|
||||||
|
|
||||||
|
Using managed database service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with external database
|
||||||
|
helm install certctl certctl/certctl \
|
||||||
|
--namespace certctl \
|
||||||
|
--values deploy/helm/examples/values-external-db.yaml \
|
||||||
|
--set server.auth.apiKey="$API_KEY" \
|
||||||
|
--set 'server.env.CERTCTL_DATABASE_URL=postgres://user:pass@db.example.com:5432/certctl?sslmode=require'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 4: Using Custom values.yaml
|
||||||
|
|
||||||
|
Recommended for GitOps workflows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create values file with secrets management
|
||||||
|
cat > /tmp/certctl-values.yaml <<EOF
|
||||||
|
server:
|
||||||
|
auth:
|
||||||
|
apiKey: "$API_KEY"
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
auth:
|
||||||
|
password: "$DB_PASSWORD"
|
||||||
|
storage:
|
||||||
|
size: 50Gi
|
||||||
|
|
||||||
|
agent:
|
||||||
|
enabled: true
|
||||||
|
kind: DaemonSet
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: certctl.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Install using values file
|
||||||
|
helm install certctl certctl/certctl \
|
||||||
|
--namespace certctl \
|
||||||
|
--values /tmp/certctl-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Step 1: Prepare Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create namespace
|
||||||
|
kubectl create namespace certctl
|
||||||
|
cd deploy/helm
|
||||||
|
|
||||||
|
# Generate credentials
|
||||||
|
API_KEY=$(openssl rand -base64 32)
|
||||||
|
DB_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
echo "API Key: $API_KEY"
|
||||||
|
echo "DB Password: $DB_PASSWORD"
|
||||||
|
|
||||||
|
# Save credentials in secure location (e.g., 1Password, Vault, AWS Secrets Manager)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Prepare Storage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List available storage classes
|
||||||
|
kubectl get storageclass
|
||||||
|
|
||||||
|
# If needed, create a high-performance storage class for production
|
||||||
|
cat <<EOF | kubectl apply -f -
|
||||||
|
apiVersion: storage.k8s.io/v1
|
||||||
|
kind: StorageClass
|
||||||
|
metadata:
|
||||||
|
name: fast-ssd
|
||||||
|
provisioner: ebs.csi.aws.com # For AWS, adjust for your cloud provider
|
||||||
|
parameters:
|
||||||
|
type: gp3
|
||||||
|
iops: "3000"
|
||||||
|
throughput: "125"
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Set Up TLS with cert-manager
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install cert-manager (if not already installed)
|
||||||
|
helm repo add jetstack https://charts.jetstack.io
|
||||||
|
helm repo update
|
||||||
|
helm install cert-manager jetstack/cert-manager \
|
||||||
|
--namespace cert-manager \
|
||||||
|
--create-namespace \
|
||||||
|
--set installCRDs=true
|
||||||
|
|
||||||
|
# Create ClusterIssuer for Let's Encrypt
|
||||||
|
kubectl apply -f - <<EOF
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: ClusterIssuer
|
||||||
|
metadata:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
acme:
|
||||||
|
server: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
email: admin@example.com
|
||||||
|
privateKeySecretRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
solvers:
|
||||||
|
- http01:
|
||||||
|
ingress:
|
||||||
|
class: nginx
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Install Certctl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install using HA values
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--namespace certctl \
|
||||||
|
--values examples/values-prod-ha.yaml \
|
||||||
|
--set server.auth.apiKey="$API_KEY" \
|
||||||
|
--set postgresql.auth.password="$DB_PASSWORD" \
|
||||||
|
--set ingress.annotations."cert-manager\.io/cluster-issuer"=letsencrypt-prod \
|
||||||
|
--set ingress.hosts[0].host=certctl.example.com
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
kubectl get all -l app.kubernetes.io/instance=certctl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Verify Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check pod status
|
||||||
|
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||||
|
kubectl describe pods -l app.kubernetes.io/instance=certctl
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
kubectl get svc -l app.kubernetes.io/instance=certctl
|
||||||
|
|
||||||
|
# Check ingress status
|
||||||
|
kubectl get ingress
|
||||||
|
kubectl describe ingress certctl
|
||||||
|
|
||||||
|
# Test API connectivity
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Access the Dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Port forward to local machine
|
||||||
|
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||||
|
|
||||||
|
# Or if using Ingress:
|
||||||
|
# Open browser: https://certctl.example.com
|
||||||
|
# Login with API key: $API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Examples
|
||||||
|
|
||||||
|
### Example 1: ACME (Let's Encrypt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.issuer.acme.enabled=true \
|
||||||
|
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory \
|
||||||
|
--set server.issuer.acme.email=admin@example.com \
|
||||||
|
--set server.issuer.acme.challengeType=http-01
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: DNS-01 (Wildcard Certs)
|
||||||
|
|
||||||
|
Requires DNS scripts ConfigMap:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create DNS scripts ConfigMap
|
||||||
|
kubectl create configmap dns-scripts \
|
||||||
|
--from-file=dns-present.sh=./scripts/dns-present.sh \
|
||||||
|
--from-file=dns-cleanup.sh=./scripts/dns-cleanup.sh
|
||||||
|
|
||||||
|
# Install with DNS-01
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.issuer.acme.enabled=true \
|
||||||
|
--set server.issuer.acme.challengeType=dns-01 \
|
||||||
|
--values examples/values-acme-dns01.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: AWS RDS Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set postgresql.enabled=false \
|
||||||
|
--set 'server.env.CERTCTL_DATABASE_URL=postgres://user:password@mydb.c9akciq32.us-east-1.rds.amazonaws.com:5432/certctl?sslmode=require'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Multiple Issuers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.issuer.local.enabled=true \
|
||||||
|
--set server.issuer.acme.enabled=true \
|
||||||
|
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 5: Email Notifications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.smtp.enabled=true \
|
||||||
|
--set server.smtp.host=smtp.example.com \
|
||||||
|
--set server.smtp.port=587 \
|
||||||
|
--set server.smtp.username=alerts@example.com \
|
||||||
|
--set server.smtp.password="$SMTP_PASSWORD" \
|
||||||
|
--set server.smtp.fromAddress=certctl@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Deployment Setup
|
||||||
|
|
||||||
|
### 1. Initial Database Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database connection
|
||||||
|
POD=$(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}')
|
||||||
|
|
||||||
|
# Execute psql commands
|
||||||
|
kubectl exec -it $POD -- \
|
||||||
|
psql -U certctl -d certctl -c '\dt'
|
||||||
|
|
||||||
|
# View database status
|
||||||
|
kubectl logs $POD | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Default Certificates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Port forward to API
|
||||||
|
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||||
|
|
||||||
|
# Create a test certificate
|
||||||
|
API_KEY="your-api-key"
|
||||||
|
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||||
|
-H "Authorization: Bearer $API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"common_name": "test.example.com",
|
||||||
|
"sans": ["test.example.com", "*.example.com"],
|
||||||
|
"owner": "admin@example.com"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Agents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get agent names
|
||||||
|
kubectl get pods -l app.kubernetes.io/component=agent -o wide
|
||||||
|
|
||||||
|
# Check agent connectivity
|
||||||
|
POD=$(kubectl get pods -l app.kubernetes.io/component=agent -o jsonpath='{.items[0].metadata.name}')
|
||||||
|
kubectl logs $POD | grep -i heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Set Up HTTPS for Web Dashboard
|
||||||
|
|
||||||
|
The Ingress will handle TLS if configured properly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify ingress is ready
|
||||||
|
kubectl get ingress
|
||||||
|
kubectl describe ingress certctl
|
||||||
|
|
||||||
|
# Test HTTPS
|
||||||
|
curl https://certctl.example.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Logging
|
||||||
|
|
||||||
|
### 1. View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Server logs
|
||||||
|
kubectl logs -l app.kubernetes.io/component=server -f --all-containers=true
|
||||||
|
|
||||||
|
# PostgreSQL logs
|
||||||
|
kubectl logs -l app.kubernetes.io/component=postgres -f
|
||||||
|
|
||||||
|
# Agent logs
|
||||||
|
kubectl logs -l app.kubernetes.io/component=agent -f --all-containers=true
|
||||||
|
|
||||||
|
# Logs from all components
|
||||||
|
kubectl logs -l app.kubernetes.io/instance=certctl -f --all-containers=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Prometheus Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Prometheus operator (if not already installed)
|
||||||
|
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
helm install prometheus prometheus-community/kube-prometheus-stack \
|
||||||
|
--namespace monitoring \
|
||||||
|
--create-namespace
|
||||||
|
|
||||||
|
# Certctl will automatically expose metrics if monitoring.enabled=true
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set monitoring.enabled=true \
|
||||||
|
--set monitoring.serviceMonitor.enabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set Up Alerts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create Prometheus alerts
|
||||||
|
cat <<EOF | kubectl apply -f -
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: PrometheusRule
|
||||||
|
metadata:
|
||||||
|
name: certctl-alerts
|
||||||
|
spec:
|
||||||
|
groups:
|
||||||
|
- name: certctl
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
- alert: CertctlServerDown
|
||||||
|
expr: up{job="certctl-server"} == 0
|
||||||
|
for: 5m
|
||||||
|
annotations:
|
||||||
|
summary: "Certctl server is down"
|
||||||
|
|
||||||
|
- alert: CertificateExpiringSoon
|
||||||
|
expr: certctl_certificate_expiring_soon > 0
|
||||||
|
for: 1h
|
||||||
|
annotations:
|
||||||
|
summary: "{{ \$value }} certificates expiring soon"
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scale server replicas
|
||||||
|
helm upgrade certctl certctl/ \
|
||||||
|
--set server.replicas=5
|
||||||
|
|
||||||
|
# Scale agents (Deployment kind only)
|
||||||
|
helm upgrade certctl certctl/ \
|
||||||
|
--set agent.kind=Deployment \
|
||||||
|
--set agent.replicas=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update chart version
|
||||||
|
helm repo update
|
||||||
|
helm upgrade certctl certctl/certctl \
|
||||||
|
--namespace certctl \
|
||||||
|
-f values.yaml
|
||||||
|
|
||||||
|
# Verify update
|
||||||
|
kubectl rollout status deployment/certctl-server
|
||||||
|
kubectl rollout status statefulset/certctl-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup and Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup PostgreSQL data
|
||||||
|
kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
|
||||||
|
pg_dump -U certctl certctl | gzip > certctl-backup.sql.gz
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
zcat certctl-backup.sql.gz | kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
|
||||||
|
psql -U certctl certctl
|
||||||
|
|
||||||
|
# Backup PVC data
|
||||||
|
kubectl get pvc
|
||||||
|
kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
|
||||||
|
tar czf - /var/lib/postgresql/data | gzip > certctl-data-backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove Helm release (keeps PVCs by default)
|
||||||
|
helm uninstall certctl --namespace certctl
|
||||||
|
|
||||||
|
# Delete PVCs if needed
|
||||||
|
kubectl delete pvc --all -n certctl
|
||||||
|
|
||||||
|
# Delete namespace
|
||||||
|
kubectl delete namespace certctl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
See [README.md](README.md#troubleshooting) for detailed troubleshooting steps.
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all resources
|
||||||
|
kubectl get all -n certctl
|
||||||
|
|
||||||
|
# Describe pod for events
|
||||||
|
kubectl describe pod <pod-name> -n certctl
|
||||||
|
|
||||||
|
# Stream logs
|
||||||
|
kubectl logs -f <pod-name> -n certctl
|
||||||
|
|
||||||
|
# Execute commands in pod
|
||||||
|
kubectl exec -it <pod-name> -n certctl -- /bin/sh
|
||||||
|
|
||||||
|
# Check events
|
||||||
|
kubectl get events -n certctl --sort-by='.lastTimestamp'
|
||||||
|
```
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Certctl Helm Chart - Complete File Index
|
||||||
|
|
||||||
|
## Navigation Guide
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. **Start here**: `INSTALLATION.md` - Quick installation guide with one-liners
|
||||||
|
2. **Full reference**: `README.md` - Complete Helm chart documentation
|
||||||
|
3. **Detailed guide**: `DEPLOYMENT_GUIDE.md` - Step-by-step deployment walkthrough
|
||||||
|
4. **Architecture**: `CHART_SUMMARY.md` - Technical overview and design
|
||||||
|
|
||||||
|
### Chart Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy/helm/
|
||||||
|
│
|
||||||
|
├── README.md Main documentation (15 KB)
|
||||||
|
├── DEPLOYMENT_GUIDE.md Step-by-step guide (12 KB)
|
||||||
|
├── CHART_SUMMARY.md Architecture & design (13 KB)
|
||||||
|
├── INSTALLATION.md Quick start (2.2 KB)
|
||||||
|
├── INDEX.md This file
|
||||||
|
│
|
||||||
|
├── certctl/ Helm chart package
|
||||||
|
│ ├── Chart.yaml Chart metadata
|
||||||
|
│ ├── values.yaml Default configuration (11 KB)
|
||||||
|
│ ├── .helmignore Build ignore patterns
|
||||||
|
│ │
|
||||||
|
│ └── templates/ 15 Kubernetes resource templates
|
||||||
|
│ ├── _helpers.tpl Helper functions
|
||||||
|
│ ├── NOTES.txt Post-install notes
|
||||||
|
│ ├── server-deployment.yaml API server
|
||||||
|
│ ├── server-service.yaml Server networking
|
||||||
|
│ ├── server-configmap.yaml Server configuration
|
||||||
|
│ ├── server-secret.yaml Server secrets
|
||||||
|
│ ├── postgres-statefulset.yaml Database
|
||||||
|
│ ├── postgres-service.yaml Database networking
|
||||||
|
│ ├── postgres-secret.yaml Database secrets
|
||||||
|
│ ├── agent-daemonset.yaml Agents (DaemonSet/Deployment)
|
||||||
|
│ ├── agent-configmap.yaml Agent configuration
|
||||||
|
│ ├── ingress.yaml Optional HTTPS ingress
|
||||||
|
│ └── serviceaccount.yaml RBAC resources
|
||||||
|
│
|
||||||
|
└── examples/ Example configurations
|
||||||
|
├── values-dev.yaml Development setup
|
||||||
|
├── values-prod-ha.yaml Production HA setup
|
||||||
|
├── values-external-db.yaml External PostgreSQL
|
||||||
|
└── values-acme-dns01.yaml ACME DNS-01 configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Descriptions
|
||||||
|
|
||||||
|
### Documentation Files
|
||||||
|
|
||||||
|
| File | Purpose | Size |
|
||||||
|
|------|---------|------|
|
||||||
|
| `README.md` | Complete Helm chart documentation, configuration reference, security considerations | 15 KB |
|
||||||
|
| `DEPLOYMENT_GUIDE.md` | Step-by-step installation instructions, production setup, troubleshooting | 12 KB |
|
||||||
|
| `CHART_SUMMARY.md` | Technical overview, architecture, features, best practices | 13 KB |
|
||||||
|
| `INSTALLATION.md` | Quick start guide, one-liner commands, verification steps | 2.2 KB |
|
||||||
|
| `INDEX.md` | This file - complete file index and navigation | - |
|
||||||
|
|
||||||
|
### Chart Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Chart.yaml` | Helm chart metadata (name, version, appVersion, license) |
|
||||||
|
| `values.yaml` | Default configuration values with comprehensive comments |
|
||||||
|
| `.helmignore` | Files to ignore when building the chart |
|
||||||
|
|
||||||
|
### Template Files
|
||||||
|
|
||||||
|
| File | Components Created |
|
||||||
|
|------|-------------------|
|
||||||
|
| `_helpers.tpl` | 14 Helm template helper functions |
|
||||||
|
| `NOTES.txt` | Post-installation notes and instructions |
|
||||||
|
| `server-deployment.yaml` | Certctl API server deployment (1-N replicas) |
|
||||||
|
| `server-service.yaml` | Service exposing the server |
|
||||||
|
| `server-configmap.yaml` | Non-secret server configuration |
|
||||||
|
| `server-secret.yaml` | Secrets (API key, DB password, SMTP) |
|
||||||
|
| `postgres-statefulset.yaml` | PostgreSQL database with persistent storage |
|
||||||
|
| `postgres-service.yaml` | Headless service for PostgreSQL |
|
||||||
|
| `postgres-secret.yaml` | Database credentials |
|
||||||
|
| `agent-daemonset.yaml` | Certctl agents (DaemonSet or Deployment) |
|
||||||
|
| `agent-configmap.yaml` | Agent configuration |
|
||||||
|
| `ingress.yaml` | Optional HTTPS ingress resource |
|
||||||
|
| `serviceaccount.yaml` | ServiceAccount and RBAC resources |
|
||||||
|
|
||||||
|
### Example Configuration Files
|
||||||
|
|
||||||
|
| File | Use Case | Features |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `values-dev.yaml` | Development/testing | Single replica, debug logging, LoadBalancer, no auth |
|
||||||
|
| `values-prod-ha.yaml` | Production HA | 3 replicas, pod anti-affinity, monitoring, large storage |
|
||||||
|
| `values-external-db.yaml` | External PostgreSQL | AWS RDS, Cloud SQL, Azure Database, self-managed |
|
||||||
|
| `values-acme-dns01.yaml` | Let's Encrypt | DNS-01 challenges, wildcard certs, custom DNS scripts |
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
### Installation Commands
|
||||||
|
|
||||||
|
#### Development
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.auth.type=none \
|
||||||
|
--set postgresql.auth.password=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Production HA
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-prod-ha.yaml \
|
||||||
|
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||||
|
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### External Database
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-external-db.yaml \
|
||||||
|
--set postgresql.enabled=false \
|
||||||
|
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check chart syntax
|
||||||
|
helm lint certctl/
|
||||||
|
helm template certctl certctl/
|
||||||
|
|
||||||
|
# Install in cluster
|
||||||
|
helm install certctl certctl/
|
||||||
|
helm status certctl
|
||||||
|
|
||||||
|
# Check pod status
|
||||||
|
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
kubectl logs -l app.kubernetes.io/component=server -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Organization
|
||||||
|
|
||||||
|
### By User Role
|
||||||
|
|
||||||
|
**DevOps/Platform Engineers**
|
||||||
|
- Start: `INSTALLATION.md`
|
||||||
|
- Deep dive: `DEPLOYMENT_GUIDE.md`
|
||||||
|
- Configuration reference: `README.md`
|
||||||
|
|
||||||
|
**Kubernetes Developers**
|
||||||
|
- Architecture: `CHART_SUMMARY.md`
|
||||||
|
- Configuration: `values.yaml`
|
||||||
|
- Templates: `templates/`
|
||||||
|
|
||||||
|
**Security/SREs**
|
||||||
|
- Security section: `README.md#security-considerations`
|
||||||
|
- RBAC: `templates/serviceaccount.yaml`
|
||||||
|
- Network policies: `DEPLOYMENT_GUIDE.md#network-policies`
|
||||||
|
|
||||||
|
**Database Administrators**
|
||||||
|
- PostgreSQL config: `values.yaml` (postgresql section)
|
||||||
|
- External DB setup: `examples/values-external-db.yaml`
|
||||||
|
- Backup/restore: `DEPLOYMENT_GUIDE.md#backup-and-restore`
|
||||||
|
|
||||||
|
### By Task
|
||||||
|
|
||||||
|
**Getting Started**
|
||||||
|
1. Read: `INSTALLATION.md`
|
||||||
|
2. Install: `helm install certctl certctl/`
|
||||||
|
3. Verify: Run commands in `INSTALLATION.md`
|
||||||
|
|
||||||
|
**Production Deployment**
|
||||||
|
1. Read: `DEPLOYMENT_GUIDE.md`
|
||||||
|
2. Choose: `examples/values-prod-ha.yaml`
|
||||||
|
3. Deploy: Follow step-by-step guide
|
||||||
|
4. Reference: `README.md` for detailed options
|
||||||
|
|
||||||
|
**Troubleshooting**
|
||||||
|
- Common issues: `README.md#troubleshooting`
|
||||||
|
- Detailed guide: `DEPLOYMENT_GUIDE.md#troubleshooting`
|
||||||
|
- Error messages: kubectl logs and events
|
||||||
|
|
||||||
|
**Configuration**
|
||||||
|
- All options: `values.yaml`
|
||||||
|
- Examples: `examples/values-*.yaml`
|
||||||
|
- Detailed docs: `README.md#configuration`
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### High Availability
|
||||||
|
- Multi-replica server deployment
|
||||||
|
- Pod anti-affinity
|
||||||
|
- StatefulSet for database
|
||||||
|
- Pod disruption budgets
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Non-root containers
|
||||||
|
- Read-only filesystems
|
||||||
|
- RBAC support
|
||||||
|
- Kubernetes Secrets
|
||||||
|
- Network policies
|
||||||
|
|
||||||
|
### Flexibility
|
||||||
|
- Multiple issuers (Local CA, ACME, step-ca, OpenSSL)
|
||||||
|
- Internal or external PostgreSQL
|
||||||
|
- DaemonSet or Deployment agents
|
||||||
|
- Optional Ingress with TLS
|
||||||
|
- Email notifications
|
||||||
|
|
||||||
|
### Observability
|
||||||
|
- Health checks
|
||||||
|
- Structured logging
|
||||||
|
- Prometheus metrics
|
||||||
|
- ServiceMonitor support
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **GitHub**: https://github.com/shankar0123/certctl
|
||||||
|
- **Issues**: Report on GitHub issues
|
||||||
|
- **Documentation**: All docs are in `deploy/helm/`
|
||||||
|
|
||||||
|
## File Statistics
|
||||||
|
|
||||||
|
- **Total files**: 24
|
||||||
|
- **Documentation**: 4 files (42 KB)
|
||||||
|
- **Chart files**: 3 files
|
||||||
|
- **Templates**: 13 files
|
||||||
|
- **Examples**: 4 files
|
||||||
|
- **Total size**: 144 KB
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
All files are covered under the BSL-1.1 license (converts to Apache 2.0 in 2033).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Quick Installation Guide
|
||||||
|
|
||||||
|
## One-Liner Installation
|
||||||
|
|
||||||
|
### Development (no auth)
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.auth.type=none \
|
||||||
|
--set postgresql.auth.password=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (with API key)
|
||||||
|
```bash
|
||||||
|
API_KEY=$(openssl rand -base64 32)
|
||||||
|
DB_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--values examples/values-prod-ha.yaml \
|
||||||
|
--set server.auth.apiKey="$API_KEY" \
|
||||||
|
--set postgresql.auth.password="$DB_PASSWORD"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Wait for pods to be ready
|
||||||
|
kubectl rollout status deployment/certctl-server
|
||||||
|
kubectl rollout status statefulset/certctl-postgres
|
||||||
|
|
||||||
|
# Check all components
|
||||||
|
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||||
|
|
||||||
|
# View server logs
|
||||||
|
kubectl logs -l app.kubernetes.io/component=server -f
|
||||||
|
|
||||||
|
# Access the API
|
||||||
|
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||||
|
curl http://localhost:8443/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Read Documentation**
|
||||||
|
- `README.md` - Complete reference
|
||||||
|
- `DEPLOYMENT_GUIDE.md` - Step-by-step guide
|
||||||
|
- `CHART_SUMMARY.md` - Architecture overview
|
||||||
|
|
||||||
|
2. **Configure for Your Environment**
|
||||||
|
- Review `examples/` for your deployment scenario
|
||||||
|
- Customize `values.yaml` as needed
|
||||||
|
- Use `helm upgrade` to apply changes
|
||||||
|
|
||||||
|
3. **Set Up Monitoring**
|
||||||
|
- Install Prometheus (optional)
|
||||||
|
- Enable Ingress with HTTPS
|
||||||
|
- Configure email notifications
|
||||||
|
|
||||||
|
4. **Deploy Agents**
|
||||||
|
- Agents deploy automatically as DaemonSet
|
||||||
|
- Verify with: `kubectl get pods -l app.kubernetes.io/component=agent`
|
||||||
|
|
||||||
|
5. **Create Certificates**
|
||||||
|
- Configure issuer connectors (Local CA, ACME, etc.)
|
||||||
|
- Access web dashboard at ingress or port-forward
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List installations
|
||||||
|
helm list
|
||||||
|
|
||||||
|
# View chart values
|
||||||
|
helm values certctl
|
||||||
|
|
||||||
|
# Upgrade chart
|
||||||
|
helm upgrade certctl certctl/ -f new-values.yaml
|
||||||
|
|
||||||
|
# Rollback to previous version
|
||||||
|
helm rollback certctl 1
|
||||||
|
|
||||||
|
# Uninstall chart
|
||||||
|
helm uninstall certctl
|
||||||
|
|
||||||
|
# View deployment history
|
||||||
|
helm history certctl
|
||||||
|
|
||||||
|
# Dry-run installation to see generated YAML
|
||||||
|
helm install certctl certctl/ --dry-run --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- Full documentation in `README.md`
|
||||||
|
- Troubleshooting in `DEPLOYMENT_GUIDE.md`
|
||||||
|
- Issues: https://github.com/shankar0123/certctl
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
# Certctl Helm Chart
|
||||||
|
|
||||||
|
Production-ready Helm chart for deploying certctl (self-hosted certificate lifecycle management platform) on Kubernetes.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Quick Start](#quick-start)
|
||||||
|
2. [Chart Features](#chart-features)
|
||||||
|
3. [Prerequisites](#prerequisites)
|
||||||
|
4. [Installation](#installation)
|
||||||
|
5. [Configuration](#configuration)
|
||||||
|
6. [Usage Examples](#usage-examples)
|
||||||
|
7. [Upgrading](#upgrading)
|
||||||
|
8. [Uninstalling](#uninstalling)
|
||||||
|
9. [Architecture](#architecture)
|
||||||
|
10. [Security Considerations](#security-considerations)
|
||||||
|
11. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add the chart repository (when available)
|
||||||
|
helm repo add certctl https://charts.example.com
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
# Install with default values
|
||||||
|
helm install certctl certctl/certctl \
|
||||||
|
--set server.auth.apiKey="your-secure-api-key" \
|
||||||
|
--set postgresql.auth.password="your-secure-password"
|
||||||
|
|
||||||
|
# Check installation status
|
||||||
|
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chart Features
|
||||||
|
|
||||||
|
- **Server Deployment** — certctl control plane with configurable replicas
|
||||||
|
- **PostgreSQL StatefulSet** — Persistent database with automatic schema migration
|
||||||
|
- **Agent DaemonSet or Deployment** — Flexible agent deployment (per-node or custom replicas)
|
||||||
|
- **Ingress Support** — Optional HTTPS ingress with cert-manager integration
|
||||||
|
- **Security Contexts** — Non-root containers, read-only filesystems, minimal capabilities
|
||||||
|
- **Resource Limits** — Configurable CPU and memory requests/limits
|
||||||
|
- **Health Checks** — Liveness and readiness probes on all containers
|
||||||
|
- **ConfigMaps and Secrets** — Centralized configuration management
|
||||||
|
- **Service Account and RBAC** — Optional cluster role bindings
|
||||||
|
- **Pod Disruption Budgets** — HA-ready with configurable disruption budgets
|
||||||
|
- **Monitoring** — Optional Prometheus ServiceMonitor support
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes 1.19 or later
|
||||||
|
- Helm 3.0 or later
|
||||||
|
- Optional: cert-manager (for automatic TLS certificate provisioning)
|
||||||
|
- Optional: Prometheus (for metrics scraping)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Using Chart from Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add certctl https://charts.example.com
|
||||||
|
helm repo update
|
||||||
|
helm install certctl certctl/certctl -f my-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Using Local Chart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/helm
|
||||||
|
helm install certctl certctl/ \
|
||||||
|
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||||
|
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Minimal Production Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/certctl \
|
||||||
|
--namespace certctl \
|
||||||
|
--create-namespace \
|
||||||
|
--set server.auth.apiKey="change-me" \
|
||||||
|
--set postgresql.auth.password="change-me" \
|
||||||
|
--set server.replicas=2 \
|
||||||
|
--set server.resources.requests.cpu=200m \
|
||||||
|
--set server.resources.requests.memory=256Mi \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.className=nginx \
|
||||||
|
--set ingress.hosts[0].host=certctl.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Server Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
replicas: 1 # Number of server replicas
|
||||||
|
port: 8443 # Service port
|
||||||
|
auth:
|
||||||
|
type: api-key # Authentication type
|
||||||
|
apiKey: "your-api-key" # REQUIRED for production
|
||||||
|
logging:
|
||||||
|
level: info # Log level (debug, info, warn, error)
|
||||||
|
format: json # Output format
|
||||||
|
issuer:
|
||||||
|
local:
|
||||||
|
enabled: true # Enable local CA issuer
|
||||||
|
acme:
|
||||||
|
enabled: false # Enable ACME issuer
|
||||||
|
directoryURL: "" # ACME directory URL
|
||||||
|
email: "" # ACME registration email
|
||||||
|
challengeType: "http-01" # Challenge type (http-01, dns-01, dns-persist-01)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
postgresql:
|
||||||
|
enabled: true # Use managed PostgreSQL
|
||||||
|
auth:
|
||||||
|
database: certctl
|
||||||
|
username: certctl
|
||||||
|
password: "your-password" # REQUIRED
|
||||||
|
storage:
|
||||||
|
size: 10Gi # PVC size
|
||||||
|
storageClass: "" # Use default StorageClass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agent:
|
||||||
|
enabled: true # Deploy agents
|
||||||
|
kind: DaemonSet # DaemonSet (one per node) or Deployment
|
||||||
|
replicas: 1 # For Deployment kind only
|
||||||
|
discoveryDirs: "" # Comma-separated cert discovery paths
|
||||||
|
nodeSelector: {} # Node affinity for DaemonSet
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: certctl.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: certctl-tls
|
||||||
|
hosts:
|
||||||
|
- certctl.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
See `values.yaml` for all available configuration options.
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Example 1: High Availability Setup
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ha-values.yaml
|
||||||
|
server:
|
||||||
|
replicas: 3
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
storage:
|
||||||
|
size: 50Gi
|
||||||
|
|
||||||
|
podAntiAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- labelSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app.kubernetes.io/component
|
||||||
|
operator: In
|
||||||
|
values: [server]
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy with:
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/certctl -f ha-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: External PostgreSQL Database
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# external-db-values.yaml
|
||||||
|
postgresql:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
env:
|
||||||
|
CERTCTL_DATABASE_URL: "postgres://user:password@rds.example.com:5432/certctl?sslmode=require"
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy with:
|
||||||
|
```bash
|
||||||
|
helm install certctl certctl/certctl -f external-db-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: ACME + Let's Encrypt
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# acme-values.yaml
|
||||||
|
server:
|
||||||
|
issuer:
|
||||||
|
acme:
|
||||||
|
enabled: true
|
||||||
|
directoryURL: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
email: admin@example.com
|
||||||
|
challengeType: dns-01
|
||||||
|
dnsPresentScript: /scripts/dns-present.sh
|
||||||
|
dnsCleanupScript: /scripts/dns-cleanup.sh
|
||||||
|
dnsPropagationWait: 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Email Notifications via Slack + SMTP
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# notifications-values.yaml
|
||||||
|
server:
|
||||||
|
smtp:
|
||||||
|
enabled: true
|
||||||
|
host: smtp.example.com
|
||||||
|
port: 587
|
||||||
|
username: certctl@example.com
|
||||||
|
password: "smtp-password"
|
||||||
|
fromAddress: certctl@example.com
|
||||||
|
useTLS: true
|
||||||
|
|
||||||
|
notifiers:
|
||||||
|
slack:
|
||||||
|
enabled: true
|
||||||
|
webhookUrl: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||||
|
channel: "#certificates"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update chart repository
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
# Upgrade release
|
||||||
|
helm upgrade certctl certctl/certctl -f values.yaml
|
||||||
|
|
||||||
|
# View upgrade history
|
||||||
|
helm history certctl
|
||||||
|
|
||||||
|
# Rollback to previous version
|
||||||
|
helm rollback certctl 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Delete the release (keeps data by default)
|
||||||
|
helm uninstall certctl
|
||||||
|
|
||||||
|
# Also delete persistent data
|
||||||
|
kubectl delete pvc --all -l app.kubernetes.io/instance=certctl
|
||||||
|
|
||||||
|
# Delete namespace
|
||||||
|
kubectl delete namespace certctl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Kubernetes Cluster │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Ingress/LB │ │ Agent Pod 1 │ │
|
||||||
|
│ │ (optional) │ │ (DaemonSet) │ │
|
||||||
|
│ └────────┬────────┘ └──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ┌──────────────────┐ │
|
||||||
|
│ ┌─────────────────────────┐ │ Agent Pod 2 │ │
|
||||||
|
│ │ Server Deployment │ │ (DaemonSet) │ │
|
||||||
|
│ │ (1 to N replicas) │ └──────────────────┘ │
|
||||||
|
│ │ - REST API │ │
|
||||||
|
│ │ - Scheduler │ ┌──────────────────┐ │
|
||||||
|
│ │ - UI Dashboard │ │ Agent Pod N │ │
|
||||||
|
│ └────────┬────────────────┘ │ (DaemonSet) │ │
|
||||||
|
│ │ └──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────────────┐ │
|
||||||
|
│ │ PostgreSQL StatefulSet │ │
|
||||||
|
│ │ - Database │ │
|
||||||
|
│ │ - PVC (persistent) │ │
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Communication
|
||||||
|
|
||||||
|
- **Server → PostgreSQL**: Internal cluster DNS (`certctl-postgres:5432`)
|
||||||
|
- **Agent → Server**: Internal cluster DNS (`certctl-server:8443`)
|
||||||
|
- **External → Server**: Via Ingress or Service (ClusterIP/LoadBalancer/NodePort)
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### 1. Secrets Management
|
||||||
|
|
||||||
|
All sensitive data is stored in Kubernetes Secrets:
|
||||||
|
- PostgreSQL credentials
|
||||||
|
- API keys
|
||||||
|
- SMTP passwords
|
||||||
|
- ACME account secrets
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
- Use sealed-secrets or external-secrets operator
|
||||||
|
- Enable encryption at rest in etcd
|
||||||
|
- Rotate secrets regularly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Using sealed-secrets
|
||||||
|
kubectl create secret generic certctl-api-key --from-literal=api-key="$(openssl rand -base64 32)" --dry-run=client -o yaml | kubeseal -f - | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. RBAC
|
||||||
|
|
||||||
|
The chart creates minimal RBAC by default:
|
||||||
|
- ServiceAccount per release
|
||||||
|
- ClusterRole (empty, extensible)
|
||||||
|
- ClusterRoleBinding
|
||||||
|
|
||||||
|
**To restrict further:**
|
||||||
|
```yaml
|
||||||
|
rbac:
|
||||||
|
create: true
|
||||||
|
# Add specific rules here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Pod Security
|
||||||
|
|
||||||
|
All containers run with:
|
||||||
|
- Non-root user (UID 1000)
|
||||||
|
- Read-only root filesystem
|
||||||
|
- No privilege escalation
|
||||||
|
- Dropped capabilities (ALL)
|
||||||
|
|
||||||
|
### 4. Network Policies
|
||||||
|
|
||||||
|
Restrict pod-to-pod communication:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: certctl-default-deny
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/instance: certctl
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
- Egress
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
name: certctl
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
name: certctl
|
||||||
|
- to:
|
||||||
|
- podSelector: {}
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 53 # DNS
|
||||||
|
- protocol: UDP
|
||||||
|
port: 53
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. TLS/HTTPS
|
||||||
|
|
||||||
|
Enable HTTPS with cert-manager:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install cert-manager jetstack/cert-manager \
|
||||||
|
--namespace cert-manager \
|
||||||
|
--create-namespace \
|
||||||
|
--set installCRDs=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure Ingress with TLS.
|
||||||
|
|
||||||
|
### 6. API Key Security
|
||||||
|
|
||||||
|
For production:
|
||||||
|
1. Generate a strong API key: `openssl rand -base64 32`
|
||||||
|
2. Store securely (Vault, sealed-secrets, etc.)
|
||||||
|
3. Never commit to Git
|
||||||
|
4. Rotate periodically
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate and deploy API key
|
||||||
|
NEW_KEY=$(openssl rand -base64 32)
|
||||||
|
kubectl patch secret certctl-server -p "{\"data\":{\"api-key\":\"$(echo -n $NEW_KEY | base64)\"}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### 1. Pods Not Starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check pod status
|
||||||
|
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||||
|
kubectl describe pod <pod-name>
|
||||||
|
kubectl logs <pod-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Database Connection Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify PostgreSQL is running
|
||||||
|
kubectl get pods -l app.kubernetes.io/component=postgres
|
||||||
|
kubectl logs -l app.kubernetes.io/component=postgres
|
||||||
|
|
||||||
|
# Test connection from server pod
|
||||||
|
kubectl exec -it <server-pod> -- \
|
||||||
|
psql postgres://certctl:password@certctl-postgres:5432/certctl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Agent Not Connecting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check agent logs
|
||||||
|
kubectl logs -l app.kubernetes.io/component=agent
|
||||||
|
|
||||||
|
# Verify server is reachable
|
||||||
|
kubectl exec -it <agent-pod> -- \
|
||||||
|
wget -q -O - http://certctl-server:8443/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Persistent Data Loss
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check PVC status
|
||||||
|
kubectl get pvc
|
||||||
|
|
||||||
|
# Verify data is being stored
|
||||||
|
kubectl exec -it <postgres-pod> -- \
|
||||||
|
ls -lah /var/lib/postgresql/data/postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Permission Denied Errors
|
||||||
|
|
||||||
|
The chart runs containers as non-root (UID 1000). If you see permission errors:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Temporarily allow root for debugging
|
||||||
|
server:
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0 # NOT FOR PRODUCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Out of Memory
|
||||||
|
|
||||||
|
Increase resource limits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade certctl certctl/certctl \
|
||||||
|
--set server.resources.limits.memory=1Gi \
|
||||||
|
--set postgresql.resources.limits.memory=2Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Certificate Validation Issues
|
||||||
|
|
||||||
|
For self-signed certificates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -it <pod> -- \
|
||||||
|
CERTCTL_TLS_INSECURE_SKIP_VERIFY=true <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues and Solutions
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| `ImagePullBackOff` | Update `server.image.repository` to your registry |
|
||||||
|
| `CrashLoopBackOff` | Check logs with `kubectl logs <pod>` |
|
||||||
|
| `Pending` PVC | Check storage class availability |
|
||||||
|
| Connection timeout | Verify network policies and service DNS |
|
||||||
|
| High memory usage | Adjust `postgresql.resources.limits` and `server.resources.limits` |
|
||||||
|
|
||||||
|
## Support and Contributing
|
||||||
|
|
||||||
|
For issues, questions, or contributions, visit:
|
||||||
|
- GitHub: https://github.com/shankar0123/certctl
|
||||||
|
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
BSL-1.1 (converts to Apache 2.0 in 2033)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Patterns to ignore when building packages.
|
||||||
|
# This supports shell glob patterns, relative path patterns, and negated
|
||||||
|
# patterns. Only one pattern per line.
|
||||||
|
.DS_Store
|
||||||
|
# Common VCS dirs
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
# Common backup files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.pyo
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
# Helm
|
||||||
|
Chart.lock
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: certctl
|
||||||
|
description: Self-hosted certificate lifecycle management platform
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "2.1.0"
|
||||||
|
keywords:
|
||||||
|
- certificate
|
||||||
|
- tls
|
||||||
|
- ssl
|
||||||
|
- pki
|
||||||
|
- acme
|
||||||
|
- lifecycle
|
||||||
|
- kubernetes
|
||||||
|
maintainers:
|
||||||
|
- name: certctl
|
||||||
|
home: https://github.com/shankar0123/certctl
|
||||||
|
sources:
|
||||||
|
- https://github.com/shankar0123/certctl
|
||||||
|
license: BSL-1.1
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
1. Get the certctl Server URL by running:
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
https://{{ index .Values.ingress.hosts 0 "host" }}
|
||||||
|
{{- 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
|
||||||
|
{{- 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 }}
|
||||||
|
{{- 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
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
2. 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:
|
||||||
|
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:
|
||||||
|
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
|
||||||
|
|
||||||
|
5. 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:
|
||||||
|
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=agent -f
|
||||||
|
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
IMPORTANT NOTES FOR PRODUCTION:
|
||||||
|
|
||||||
|
1. Update the API key for security:
|
||||||
|
kubectl patch secret {{ include "certctl.fullname" . }}-server -n {{ .Release.Namespace }} \
|
||||||
|
-p '{"data":{"api-key":"'$(echo -n "YOUR_NEW_API_KEY" | base64)'"}}'
|
||||||
|
|
||||||
|
2. Update PostgreSQL password:
|
||||||
|
kubectl patch secret {{ include "certctl.fullname" . }}-postgres -n {{ .Release.Namespace }} \
|
||||||
|
-p '{"data":{"password":"'$(echo -n "YOUR_NEW_PASSWORD" | base64)'"}}'
|
||||||
|
|
||||||
|
3. Configure certificate issuers (ACME, step-ca, etc.) via values.yaml:
|
||||||
|
helm upgrade {{ .Release.Name }} certctl/certctl \
|
||||||
|
--set server.issuer.acme.enabled=true \
|
||||||
|
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory \
|
||||||
|
--set server.issuer.acme.email=admin@example.com
|
||||||
|
|
||||||
|
4. For production with persistent databases and backups:
|
||||||
|
- 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:
|
||||||
|
- All containers run as non-root
|
||||||
|
- Implement network policies to restrict traffic between components
|
||||||
|
- Consider pod security policies or security standards for your cluster
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create chart name and version as used by the chart label.
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "certctl.chart" . }}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- with .Values.commonLabels }}
|
||||||
|
{{ toYaml . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels for the main service (server, agent, postgres)
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Server selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serverSelectorLabels" -}}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Agent selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.agentSelectorLabels" -}}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
PostgreSQL selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.postgresSelectorLabels" -}}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Service account name
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "certctl.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Server image
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serverImage" -}}
|
||||||
|
{{- $image := .Values.server.image }}
|
||||||
|
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Agent image
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.agentImage" -}}
|
||||||
|
{{- $image := .Values.agent.image }}
|
||||||
|
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
PostgreSQL image
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.postgresImage" -}}
|
||||||
|
{{- $image := .Values.postgresql.image }}
|
||||||
|
{{- printf "%s:%s" $image.repository $image.tag }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Database connection string
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.databaseURL" -}}
|
||||||
|
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Server URL (for agents)
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serverURL" -}}
|
||||||
|
http://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if .Values.agent.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
data:
|
||||||
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
|
discovery-dirs: {{ .Values.agent.discoveryDirs | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{{- if .Values.agent.enabled }}
|
||||||
|
{{- if eq .Values.agent.kind "DaemonSet" }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.agent.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: agent
|
||||||
|
image: {{ include "certctl.agentImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||||
|
env:
|
||||||
|
- name: CERTCTL_SERVER_URL
|
||||||
|
value: {{ include "certctl.serverURL" . }}
|
||||||
|
- name: CERTCTL_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: api-key
|
||||||
|
- name: CERTCTL_AGENT_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: CERTCTL_KEY_DIR
|
||||||
|
value: {{ .Values.agent.keyDir }}
|
||||||
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
|
- name: CERTCTL_DISCOVERY_DIRS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
key: discovery-dirs
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.env }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.agent.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: agent-keys
|
||||||
|
mountPath: {{ .Values.agent.keyDir }}
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
volumes:
|
||||||
|
- name: agent-keys
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 1Gi
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- else if eq .Values.agent.kind "Deployment" }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.agent.replicas }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.agent.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: agent
|
||||||
|
image: {{ include "certctl.agentImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||||
|
env:
|
||||||
|
- name: CERTCTL_SERVER_URL
|
||||||
|
value: {{ include "certctl.serverURL" . }}
|
||||||
|
- name: CERTCTL_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: api-key
|
||||||
|
- name: CERTCTL_AGENT_NAME
|
||||||
|
{{- if .Values.agent.name }}
|
||||||
|
value: {{ .Values.agent.name | quote }}
|
||||||
|
{{- else }}
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
{{- end }}
|
||||||
|
- name: CERTCTL_KEY_DIR
|
||||||
|
value: {{ .Values.agent.keyDir }}
|
||||||
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
|
- name: CERTCTL_DISCOVERY_DIRS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
key: discovery-dirs
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.env }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.agent.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: agent-keys
|
||||||
|
mountPath: {{ .Values.agent.keyDir }}
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
volumes:
|
||||||
|
- name: agent-keys
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 1Gi
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- range .Values.ingress.tls }}
|
||||||
|
- hosts:
|
||||||
|
{{- range .hosts }}
|
||||||
|
- {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
secretName: {{ .secretName }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.server.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
password: {{ .Values.postgresql.auth.password | default "changeme" | quote }}
|
||||||
|
username: {{ .Values.postgresql.auth.username | quote }}
|
||||||
|
database: {{ .Values.postgresql.auth.database | quote }}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{- if .Values.postgresql.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.postgresql.service.port }}
|
||||||
|
targetPort: postgres
|
||||||
|
protocol: TCP
|
||||||
|
name: postgres
|
||||||
|
selector:
|
||||||
|
{{- include "certctl.postgresSelectorLabels" . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{{- if .Values.postgresql.enabled }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
spec:
|
||||||
|
serviceName: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.postgresSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.postgresSelectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.postgresql.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: {{ include "certctl.postgresImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
containerPort: 5432
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: database
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: username
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: password
|
||||||
|
- name: POSTGRES_INITDB_ARGS
|
||||||
|
value: "--encoding=UTF8"
|
||||||
|
livenessProbe:
|
||||||
|
{{- toYaml .Values.postgresql.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- toYaml .Values.postgresql.readinessProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.postgresql.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
subPath: postgres
|
||||||
|
- name: postgres-init
|
||||||
|
mountPath: /docker-entrypoint-initdb.d
|
||||||
|
volumes:
|
||||||
|
- name: postgres-init
|
||||||
|
emptyDir: {}
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: postgres-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
{{- if .Values.postgresql.storage.storageClass }}
|
||||||
|
storageClassName: {{ .Values.postgresql.storage.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.postgresql.storage.size }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
data:
|
||||||
|
log-level: {{ .Values.server.logging.level | quote }}
|
||||||
|
auth-type: {{ .Values.server.auth.type | quote }}
|
||||||
|
keygen-mode: {{ .Values.server.keygen.mode | quote }}
|
||||||
|
rate-limit-rps: {{ .Values.server.rateLimiting.rps | quote }}
|
||||||
|
rate-limit-burst: {{ .Values.server.rateLimiting.burst | quote }}
|
||||||
|
{{- if .Values.server.cors.origins }}
|
||||||
|
cors-origins: {{ .Values.server.cors.origins | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.networkScan.enabled }}
|
||||||
|
network-scan-interval: {{ .Values.server.networkScan.interval | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.est.enabled }}
|
||||||
|
est-issuer-id: {{ .Values.server.est.issuerID | quote }}
|
||||||
|
{{- if .Values.server.est.profileID }}
|
||||||
|
est-profile-id: {{ .Values.server.est.profileID | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.smtp.enabled }}
|
||||||
|
smtp-host: {{ .Values.server.smtp.host | quote }}
|
||||||
|
smtp-port: {{ .Values.server.smtp.port | quote }}
|
||||||
|
smtp-username: {{ .Values.server.smtp.username | quote }}
|
||||||
|
smtp-from-address: {{ .Values.server.smtp.fromAddress | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.issuer.acme.enabled }}
|
||||||
|
acme-directory-url: {{ .Values.server.issuer.acme.directoryURL | quote }}
|
||||||
|
acme-email: {{ .Values.server.issuer.acme.email | quote }}
|
||||||
|
acme-challenge-type: {{ .Values.server.issuer.acme.challengeType | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
spec:
|
||||||
|
{{- if gt (int .Values.server.replicas) 1 }}
|
||||||
|
replicas: {{ .Values.server.replicas }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.serverSelectorLabels" . | nindent 8 }}
|
||||||
|
annotations:
|
||||||
|
checksum/config: {{ include (print $.Template.BasePath "/server-configmap.yaml") . | sha256sum }}
|
||||||
|
checksum/secret: {{ include (print $.Template.BasePath "/server-secret.yaml") . | sha256sum }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.server.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: server
|
||||||
|
image: {{ include "certctl.serverImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.server.port }}
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: CERTCTL_SERVER_HOST
|
||||||
|
value: "0.0.0.0"
|
||||||
|
- name: CERTCTL_SERVER_PORT
|
||||||
|
value: "{{ .Values.server.port }}"
|
||||||
|
- name: CERTCTL_DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: database-url
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: password
|
||||||
|
- name: CERTCTL_LOG_LEVEL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: log-level
|
||||||
|
- name: CERTCTL_LOG_FORMAT
|
||||||
|
value: "json"
|
||||||
|
- name: CERTCTL_AUTH_TYPE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: auth-type
|
||||||
|
{{- if eq .Values.server.auth.type "api-key" }}
|
||||||
|
- name: CERTCTL_AUTH_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: api-key
|
||||||
|
{{- end }}
|
||||||
|
- name: CERTCTL_KEYGEN_MODE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: keygen-mode
|
||||||
|
- name: CERTCTL_RATE_LIMIT_RPS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: rate-limit-rps
|
||||||
|
- name: CERTCTL_RATE_LIMIT_BURST
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: rate-limit-burst
|
||||||
|
{{- if .Values.server.cors.origins }}
|
||||||
|
- name: CERTCTL_CORS_ORIGINS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: cors-origins
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.networkScan.enabled }}
|
||||||
|
- name: CERTCTL_NETWORK_SCAN_ENABLED
|
||||||
|
value: "true"
|
||||||
|
- name: CERTCTL_NETWORK_SCAN_INTERVAL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: network-scan-interval
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.est.enabled }}
|
||||||
|
- name: CERTCTL_EST_ENABLED
|
||||||
|
value: "true"
|
||||||
|
- name: CERTCTL_EST_ISSUER_ID
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: est-issuer-id
|
||||||
|
{{- if .Values.server.est.profileID }}
|
||||||
|
- name: CERTCTL_EST_PROFILE_ID
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: est-profile-id
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.smtp.enabled }}
|
||||||
|
- name: CERTCTL_SMTP_HOST
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-host
|
||||||
|
- name: CERTCTL_SMTP_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-port
|
||||||
|
- name: CERTCTL_SMTP_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-username
|
||||||
|
- name: CERTCTL_SMTP_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-password
|
||||||
|
- name: CERTCTL_SMTP_FROM_ADDRESS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-from-address
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.issuer.acme.enabled }}
|
||||||
|
- name: CERTCTL_ACME_DIRECTORY_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: acme-directory-url
|
||||||
|
- name: CERTCTL_ACME_EMAIL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: acme-email
|
||||||
|
- name: CERTCTL_ACME_CHALLENGE_TYPE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: acme-challenge-type
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.server.env }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
livenessProbe:
|
||||||
|
{{- toYaml .Values.server.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- toYaml .Values.server.readinessProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.server.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
{{- if .Values.server.volumeMounts }}
|
||||||
|
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
volumes:
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- if .Values.server.volumes }}
|
||||||
|
{{- toYaml .Values.server.volumes | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.nodeAffinity }}
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
{{- toYaml .Values.nodeAffinity | nindent 10 }}
|
||||||
|
{{- else if .Values.podAntiAffinity }}
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
{{- toYaml .Values.podAntiAffinity | nindent 10 }}
|
||||||
|
{{- else if .Values.podAffinity }}
|
||||||
|
affinity:
|
||||||
|
podAffinity:
|
||||||
|
{{- toYaml .Values.podAffinity | nindent 10 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
database-url: postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
||||||
|
{{- if and (eq .Values.server.auth.type "api-key") .Values.server.auth.apiKey }}
|
||||||
|
api-key: {{ .Values.server.auth.apiKey | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.smtp.enabled }}
|
||||||
|
smtp-password: {{ .Values.server.smtp.password | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
{{- with .Values.server.service.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.server.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.server.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.rbac.create }}
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
rules: []
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
# Default values for certctl Helm chart
|
||||||
|
# This is a YAML-formatted file.
|
||||||
|
# Declare variables to be passed into your templates.
|
||||||
|
|
||||||
|
# Namespace override (optional)
|
||||||
|
namespace: ""
|
||||||
|
|
||||||
|
# Global configuration
|
||||||
|
commonLabels: {}
|
||||||
|
imagePullSecrets: []
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Certctl Server Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
server:
|
||||||
|
# Number of replicas (for HA deployments)
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
# Image configuration
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/shankar0123/certctl
|
||||||
|
tag: "" # defaults to Chart.appVersion
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Server port
|
||||||
|
port: 8443
|
||||||
|
|
||||||
|
# Resource requests and limits
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
# Pod security context
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
fsGroup: 1000
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
# Liveness and readiness probes
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 2
|
||||||
|
|
||||||
|
# Service type (ClusterIP, LoadBalancer, NodePort)
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8443
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
# Authentication configuration
|
||||||
|
auth:
|
||||||
|
type: api-key # Options: api-key, none (for demo only)
|
||||||
|
apiKey: "" # REQUIRED in production - set via --set or values override
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
logging:
|
||||||
|
level: info # debug, info, warn, error
|
||||||
|
format: json # json or text
|
||||||
|
|
||||||
|
# SMTP configuration for email notifications (optional)
|
||||||
|
smtp:
|
||||||
|
enabled: false
|
||||||
|
host: ""
|
||||||
|
port: 587
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
fromAddress: ""
|
||||||
|
useTLS: true
|
||||||
|
|
||||||
|
# Certificate digest digest (periodic email summary)
|
||||||
|
digest:
|
||||||
|
enabled: false
|
||||||
|
interval: "24h"
|
||||||
|
recipients: []
|
||||||
|
# Example:
|
||||||
|
# - admin@example.com
|
||||||
|
# - ops@example.com
|
||||||
|
|
||||||
|
# Enrollment over Secure Transport (EST) configuration
|
||||||
|
est:
|
||||||
|
enabled: false
|
||||||
|
issuerID: "iss-local"
|
||||||
|
profileID: ""
|
||||||
|
|
||||||
|
# Rate limiting configuration
|
||||||
|
rateLimiting:
|
||||||
|
rps: 100 # Requests per second
|
||||||
|
burst: 200 # Burst capacity
|
||||||
|
|
||||||
|
# Network scanning configuration
|
||||||
|
networkScan:
|
||||||
|
enabled: false
|
||||||
|
interval: "6h"
|
||||||
|
|
||||||
|
# Certificate key generation mode
|
||||||
|
keygen:
|
||||||
|
mode: agent # Options: agent (production), server (demo with warning)
|
||||||
|
|
||||||
|
# CORS configuration
|
||||||
|
cors:
|
||||||
|
origins: "" # Comma-separated list, empty means deny all cross-origin requests
|
||||||
|
|
||||||
|
# Issuer connectors configuration
|
||||||
|
issuer:
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
# For sub-CA mode, provide these paths:
|
||||||
|
# caCertPath: /path/to/ca.crt
|
||||||
|
# caKeyPath: /path/to/ca.key
|
||||||
|
|
||||||
|
acme:
|
||||||
|
enabled: false
|
||||||
|
directoryURL: ""
|
||||||
|
email: ""
|
||||||
|
challengeType: "http-01" # Options: http-01, dns-01, dns-persist-01
|
||||||
|
# DNS configuration (for dns-01 or dns-persist-01)
|
||||||
|
# dnsPresentScript: /path/to/dns-present.sh
|
||||||
|
# dnsCleanupScript: /path/to/dns-cleanup.sh
|
||||||
|
# dnsPropagationWait: "30s"
|
||||||
|
# dnsPersistIssuerDomain: "validation.example.com"
|
||||||
|
# EAB configuration (for ZeroSSL, Google Trust Services, etc.)
|
||||||
|
# eabKid: ""
|
||||||
|
# eabHmac: ""
|
||||||
|
|
||||||
|
stepca:
|
||||||
|
enabled: false
|
||||||
|
# rootCAPath: /path/to/root_ca.crt
|
||||||
|
# intermediateCAPath: /path/to/intermediate_ca.crt
|
||||||
|
# provisionerName: ""
|
||||||
|
# provisionerPassword: ""
|
||||||
|
|
||||||
|
openssl:
|
||||||
|
enabled: false
|
||||||
|
# signScript: /path/to/sign.sh
|
||||||
|
# revokeScript: /path/to/revoke.sh
|
||||||
|
# crlScript: /path/to/crl.sh
|
||||||
|
# timeoutSeconds: 30
|
||||||
|
|
||||||
|
# Notifier connectors configuration
|
||||||
|
notifiers:
|
||||||
|
slack:
|
||||||
|
enabled: false
|
||||||
|
# webhookUrl: ""
|
||||||
|
# channel: ""
|
||||||
|
# username: ""
|
||||||
|
# iconEmoji: ""
|
||||||
|
|
||||||
|
teams:
|
||||||
|
enabled: false
|
||||||
|
# webhookUrl: ""
|
||||||
|
|
||||||
|
pagerduty:
|
||||||
|
enabled: false
|
||||||
|
# routingKey: ""
|
||||||
|
# severity: warning
|
||||||
|
|
||||||
|
opsgenie:
|
||||||
|
enabled: false
|
||||||
|
# apiKey: ""
|
||||||
|
# priority: P3
|
||||||
|
|
||||||
|
# Additional environment variables
|
||||||
|
# Will be passed as-is to the server container
|
||||||
|
env: {}
|
||||||
|
# Example:
|
||||||
|
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL: "1h"
|
||||||
|
# CERTCTL_DATABASE_MAX_CONNS: "25"
|
||||||
|
|
||||||
|
# Additional volume mounts for custom configurations
|
||||||
|
# volumeMounts: []
|
||||||
|
# - name: ca-cert
|
||||||
|
# mountPath: /etc/ssl/certs/ca.crt
|
||||||
|
# subPath: ca.crt
|
||||||
|
|
||||||
|
# Additional volumes
|
||||||
|
# volumes: []
|
||||||
|
# - name: ca-cert
|
||||||
|
# secret:
|
||||||
|
# secretName: ca-cert
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# PostgreSQL Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
postgresql:
|
||||||
|
# Enable/disable PostgreSQL (set to false if using external database)
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Image configuration
|
||||||
|
image:
|
||||||
|
repository: postgres
|
||||||
|
tag: "16-alpine"
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
auth:
|
||||||
|
database: certctl
|
||||||
|
username: certctl
|
||||||
|
password: "" # REQUIRED - set via --set or values override
|
||||||
|
|
||||||
|
# Storage configuration
|
||||||
|
storage:
|
||||||
|
size: 10Gi
|
||||||
|
storageClass: "" # Uses default StorageClass if empty
|
||||||
|
# deleteOnTermination: false # Keep data on Helm uninstall
|
||||||
|
|
||||||
|
# Resource requests and limits
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
# Pod security context
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 999
|
||||||
|
runAsGroup: 999
|
||||||
|
fsGroup: 999
|
||||||
|
|
||||||
|
# Liveness and readiness probes
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- pg_isready -U certctl -d certctl
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- pg_isready -U certctl -d certctl
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 2
|
||||||
|
|
||||||
|
# Service configuration
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 5432
|
||||||
|
|
||||||
|
# PostgreSQL-specific settings
|
||||||
|
postgresqlConfig: {}
|
||||||
|
# Example:
|
||||||
|
# max_connections: "200"
|
||||||
|
# shared_buffers: "256MB"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Certctl Agent Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
agent:
|
||||||
|
# Enable/disable agent deployment
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Deployment strategy: DaemonSet (recommended) or Deployment
|
||||||
|
kind: DaemonSet # Options: DaemonSet, Deployment
|
||||||
|
|
||||||
|
# Image configuration
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/shankar0123/certctl-agent
|
||||||
|
tag: "" # defaults to Chart.appVersion
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Number of replicas (for Deployment kind; ignored for DaemonSet)
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
# Resource requests and limits
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
|
||||||
|
# Pod security context
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
fsGroup: 1000
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
# Agent name (can be overridden per pod via StatefulSet ordinals)
|
||||||
|
name: "" # If empty, uses release name
|
||||||
|
|
||||||
|
# Key storage directory
|
||||||
|
keyDir: /var/lib/certctl/keys
|
||||||
|
|
||||||
|
# Certificate discovery directories (comma-separated)
|
||||||
|
discoveryDirs: ""
|
||||||
|
# Example: "/etc/ssl/certs,/etc/pki/tls"
|
||||||
|
|
||||||
|
# Node selector for agent pods (for DaemonSet)
|
||||||
|
nodeSelector: {}
|
||||||
|
# Example:
|
||||||
|
# node-role.kubernetes.io/worker: "true"
|
||||||
|
|
||||||
|
# Tolerations for agent pods
|
||||||
|
tolerations: []
|
||||||
|
# Example:
|
||||||
|
# - key: node-role
|
||||||
|
# operator: Equal
|
||||||
|
# value: worker
|
||||||
|
# effect: NoSchedule
|
||||||
|
|
||||||
|
# Affinity rules
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
# Additional environment variables
|
||||||
|
env: {}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Ingress Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
annotations: {}
|
||||||
|
# kubernetes.io/ingress.class: nginx
|
||||||
|
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: certctl.local
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls: []
|
||||||
|
# - secretName: certctl-tls
|
||||||
|
# hosts:
|
||||||
|
# - certctl.local
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Service Account Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
serviceAccount:
|
||||||
|
create: true
|
||||||
|
annotations: {}
|
||||||
|
name: "" # defaults to release name if empty
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# RBAC Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
rbac:
|
||||||
|
create: true
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Pod Disruption Budget (for HA deployments)
|
||||||
|
# ==============================================================================
|
||||||
|
podDisruptionBudget:
|
||||||
|
enabled: false
|
||||||
|
minAvailable: 1
|
||||||
|
# maxUnavailable: 1
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Monitoring Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
monitoring:
|
||||||
|
enabled: false
|
||||||
|
# Prometheus ServiceMonitor
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: false
|
||||||
|
interval: 30s
|
||||||
|
scrapeTimeout: 10s
|
||||||
|
# labels: {}
|
||||||
|
# selector: {}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Advanced Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Node affinity for server pods
|
||||||
|
nodeAffinity: {}
|
||||||
|
|
||||||
|
# Pod affinity for server pods
|
||||||
|
podAffinity: {}
|
||||||
|
|
||||||
|
# Pod anti-affinity for server pods (for HA)
|
||||||
|
podAntiAffinity: {}
|
||||||
|
# Example:
|
||||||
|
# podAntiAffinity:
|
||||||
|
# preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
# - weight: 100
|
||||||
|
# podAffinityTerm:
|
||||||
|
# labelSelector:
|
||||||
|
# matchExpressions:
|
||||||
|
# - key: app.kubernetes.io/name
|
||||||
|
# operator: In
|
||||||
|
# values:
|
||||||
|
# - certctl
|
||||||
|
# topologyKey: kubernetes.io/hostname
|
||||||
|
|
||||||
|
# Custom labels for all resources
|
||||||
|
customLabels: {}
|
||||||
|
|
||||||
|
# Custom annotations for all resources
|
||||||
|
customAnnotations: {}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Certctl with ACME DNS-01 Challenge (Let's Encrypt)
|
||||||
|
# Enables automatic certificate issuance from Let's Encrypt
|
||||||
|
# using DNS-01 verification (wildcard-capable)
|
||||||
|
|
||||||
|
server:
|
||||||
|
auth:
|
||||||
|
type: api-key
|
||||||
|
apiKey: "CHANGE_ME"
|
||||||
|
|
||||||
|
issuer:
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
acme:
|
||||||
|
enabled: true
|
||||||
|
directoryURL: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
email: admin@example.com
|
||||||
|
challengeType: dns-01
|
||||||
|
dnsPresentScript: /scripts/dns-present.sh
|
||||||
|
dnsCleanupScript: /scripts/dns-cleanup.sh
|
||||||
|
dnsPropagationWait: 30s
|
||||||
|
# For DNS-PERSIST-01 (standing validation record, no per-renewal updates):
|
||||||
|
# challengeType: dns-persist-01
|
||||||
|
# dnsPersistIssuerDomain: validation.example.com
|
||||||
|
|
||||||
|
# Mount DNS scripts as ConfigMap
|
||||||
|
volumes:
|
||||||
|
- name: dns-scripts
|
||||||
|
configMap:
|
||||||
|
name: dns-scripts
|
||||||
|
defaultMode: 0755
|
||||||
|
|
||||||
|
volumeMounts:
|
||||||
|
- name: dns-scripts
|
||||||
|
mountPath: /scripts
|
||||||
|
readOnly: true
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
enabled: true
|
||||||
|
storage:
|
||||||
|
size: 20Gi
|
||||||
|
|
||||||
|
agent:
|
||||||
|
enabled: true
|
||||||
|
kind: DaemonSet
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: certctl.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
|
||||||
|
---
|
||||||
|
# You'll need to create the DNS scripts ConfigMap separately:
|
||||||
|
#
|
||||||
|
# kubectl create configmap dns-scripts \
|
||||||
|
# --from-file=dns-present.sh=./scripts/dns-present.sh \
|
||||||
|
# --from-file=dns-cleanup.sh=./scripts/dns-cleanup.sh
|
||||||
|
#
|
||||||
|
# Example dns-present.sh (Cloudflare):
|
||||||
|
# #!/bin/bash
|
||||||
|
# DOMAIN=$1
|
||||||
|
# TOKEN=$2
|
||||||
|
#
|
||||||
|
# curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
|
||||||
|
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||||
|
# -d "{\"type\":\"TXT\",\"name\":\"_acme-challenge.${DOMAIN}\",\"content\":\"${TOKEN}\"}"
|
||||||
|
#
|
||||||
|
# Example dns-cleanup.sh (Cloudflare):
|
||||||
|
# #!/bin/bash
|
||||||
|
# DOMAIN=$1
|
||||||
|
#
|
||||||
|
# curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
|
||||||
|
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Certctl Development Configuration
|
||||||
|
# Lightweight setup for development and testing
|
||||||
|
# - Single server replica
|
||||||
|
# - Small PostgreSQL storage
|
||||||
|
# - Minimal resource limits
|
||||||
|
# - No ingress or monitoring
|
||||||
|
# - Demo auth mode (no API key required)
|
||||||
|
|
||||||
|
server:
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/shankar0123/certctl
|
||||||
|
pullPolicy: IfNotPresent # Use latest tag
|
||||||
|
|
||||||
|
port: 8443
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
|
||||||
|
auth:
|
||||||
|
type: none # Demo mode - no authentication
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: debug
|
||||||
|
format: json
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: LoadBalancer # Easy external access for dev
|
||||||
|
|
||||||
|
issuer:
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
rateLimiting:
|
||||||
|
rps: 100
|
||||||
|
burst: 200
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: postgres
|
||||||
|
tag: "16-alpine"
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
auth:
|
||||||
|
database: certctl
|
||||||
|
username: certctl
|
||||||
|
password: "dev-password-change-me"
|
||||||
|
|
||||||
|
storage:
|
||||||
|
size: 5Gi
|
||||||
|
storageClass: "" # Use default storage class
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
|
||||||
|
agent:
|
||||||
|
enabled: true
|
||||||
|
kind: Deployment
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/shankar0123/certctl-agent
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
serviceAccount:
|
||||||
|
create: true
|
||||||
|
|
||||||
|
rbac:
|
||||||
|
create: true
|
||||||
|
|
||||||
|
monitoring:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
customLabels:
|
||||||
|
environment: development
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Certctl with External PostgreSQL Database
|
||||||
|
# Use this when PostgreSQL is managed externally:
|
||||||
|
# - AWS RDS
|
||||||
|
# - Cloud SQL (Google Cloud)
|
||||||
|
# - Azure Database for PostgreSQL
|
||||||
|
# - Self-managed PostgreSQL server
|
||||||
|
|
||||||
|
server:
|
||||||
|
replicas: 2
|
||||||
|
|
||||||
|
auth:
|
||||||
|
type: api-key
|
||||||
|
apiKey: "CHANGE_ME"
|
||||||
|
|
||||||
|
issuer:
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Pass external database URL via environment variable
|
||||||
|
env:
|
||||||
|
CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@postgres.example.com:5432/certctl?sslmode=require"
|
||||||
|
|
||||||
|
# Disable internal PostgreSQL
|
||||||
|
postgresql:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
agent:
|
||||||
|
enabled: true
|
||||||
|
kind: DaemonSet
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: certctl.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
|
||||||
|
# For AWS RDS with IAM authentication:
|
||||||
|
# env:
|
||||||
|
# CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@mydb.123456789.us-east-1.rds.amazonaws.com:5432/certctl?sslmode=require"
|
||||||
|
|
||||||
|
# For Google Cloud SQL:
|
||||||
|
# env:
|
||||||
|
# CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@/certctl?host=/cloudsql/PROJECT:REGION:INSTANCE&sslmode=require"
|
||||||
|
|
||||||
|
# For Azure Database:
|
||||||
|
# env:
|
||||||
|
# CERTCTL_DATABASE_URL: "postgres://certctl@servername:CHANGE_ME@servername.postgres.database.azure.com:5432/certctl?sslmode=require"
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Certctl Production HA Configuration
|
||||||
|
# High availability deployment with:
|
||||||
|
# - 3 server replicas with pod anti-affinity
|
||||||
|
# - Large PostgreSQL storage
|
||||||
|
# - Resource limits for production
|
||||||
|
# - Prometheus monitoring
|
||||||
|
# - Network policies enforcement
|
||||||
|
|
||||||
|
namespace: certctl
|
||||||
|
|
||||||
|
server:
|
||||||
|
replicas: 3
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/shankar0123/certctl
|
||||||
|
tag: "2.1.0"
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
port: 8443
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
auth:
|
||||||
|
type: api-key
|
||||||
|
apiKey: "CHANGE_ME_IN_PRODUCTION" # Use --set or sealed-secrets
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
format: json
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
annotations:
|
||||||
|
prometheus.io/scrape: "true"
|
||||||
|
prometheus.io/port: "8443"
|
||||||
|
prometheus.io/path: "/api/v1/metrics/prometheus"
|
||||||
|
|
||||||
|
issuer:
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
acme:
|
||||||
|
enabled: true
|
||||||
|
directoryURL: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
email: admin@example.com
|
||||||
|
challengeType: dns-01
|
||||||
|
|
||||||
|
rateLimiting:
|
||||||
|
rps: 500
|
||||||
|
burst: 1000
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: postgres
|
||||||
|
tag: "16-alpine"
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
auth:
|
||||||
|
database: certctl
|
||||||
|
username: certctl
|
||||||
|
password: "CHANGE_ME_IN_PRODUCTION" # Use --set or sealed-secrets
|
||||||
|
|
||||||
|
storage:
|
||||||
|
size: 100Gi
|
||||||
|
storageClass: "fast-ssd" # Use your high-performance storage class
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 2000m
|
||||||
|
memory: 2Gi
|
||||||
|
|
||||||
|
agent:
|
||||||
|
enabled: true
|
||||||
|
kind: DaemonSet
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/shankar0123/certctl-agent
|
||||||
|
tag: "2.1.0"
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
|
||||||
|
discoveryDirs: "/etc/ssl/certs,/etc/pki/tls,/etc/ssl"
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
hosts:
|
||||||
|
- host: certctl.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: certctl-tls
|
||||||
|
hosts:
|
||||||
|
- certctl.example.com
|
||||||
|
|
||||||
|
serviceAccount:
|
||||||
|
create: true
|
||||||
|
annotations:
|
||||||
|
eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT:role/certctl-role # For IRSA on AWS
|
||||||
|
|
||||||
|
rbac:
|
||||||
|
create: true
|
||||||
|
|
||||||
|
podDisruptionBudget:
|
||||||
|
enabled: true
|
||||||
|
minAvailable: 2
|
||||||
|
|
||||||
|
monitoring:
|
||||||
|
enabled: true
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: true
|
||||||
|
interval: 30s
|
||||||
|
scrapeTimeout: 10s
|
||||||
|
|
||||||
|
# Pod anti-affinity for HA
|
||||||
|
podAntiAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- labelSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app.kubernetes.io/name
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- certctl
|
||||||
|
- key: app.kubernetes.io/component
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- server
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
|
||||||
|
customLabels:
|
||||||
|
environment: production
|
||||||
|
team: platform
|
||||||
|
cost-center: ops
|
||||||
|
|
||||||
|
customAnnotations:
|
||||||
|
slack-alerts: "#ops"
|
||||||
|
backup-policy: daily
|
||||||
@@ -1,5 +1,41 @@
|
|||||||
# Architecture Guide
|
# Architecture Guide
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [System Components](#system-components)
|
||||||
|
- [Control Plane (Server)](#control-plane-server)
|
||||||
|
- [Agents](#agents)
|
||||||
|
- [Web Dashboard](#web-dashboard)
|
||||||
|
- [PostgreSQL Database](#postgresql-database)
|
||||||
|
3. [Data Flow: Certificate Lifecycle](#data-flow-certificate-lifecycle)
|
||||||
|
- [Create Managed Certificate](#1-create-managed-certificate)
|
||||||
|
- [Certificate Issuance](#2-certificate-issuance)
|
||||||
|
- [Deploy Certificate to Target](#3-deploy-certificate-to-target)
|
||||||
|
- [Revoke a Certificate](#35-revoke-a-certificate)
|
||||||
|
- [Automatic Renewal](#4-automatic-renewal)
|
||||||
|
4. [Connector Architecture](#connector-architecture)
|
||||||
|
- [IssuerConnectorAdapter (Dependency Inversion)](#issuerconnectoradapter-dependency-inversion)
|
||||||
|
- [Issuer Connector](#issuer-connector)
|
||||||
|
- [Target Connector](#target-connector)
|
||||||
|
- [Notifier Connector](#notifier-connector)
|
||||||
|
- [EST Server (RFC 7030)](#est-server-rfc-7030)
|
||||||
|
5. [Security Model](#security-model)
|
||||||
|
- [Private Key Management](#private-key-management)
|
||||||
|
- [Authentication](#authentication)
|
||||||
|
- [Audit Trail](#audit-trail)
|
||||||
|
- [API Audit Log](#api-audit-log)
|
||||||
|
- [Logging](#logging)
|
||||||
|
6. [API Design](#api-design)
|
||||||
|
7. [MCP Server](#mcp-server)
|
||||||
|
8. [CLI Tool](#cli-tool)
|
||||||
|
9. [Deployment Topologies](#deployment-topologies)
|
||||||
|
- [Docker Compose (Development / Small Deployments)](#docker-compose-development--small-deployments)
|
||||||
|
- [Production (Kubernetes)](#production-kubernetes)
|
||||||
|
10. [Discovery Data Flow (M18b + M21)](#discovery-data-flow-m18b--m21)
|
||||||
|
11. [Testing Strategy](#testing-strategy)
|
||||||
|
12. [What's Next](#whats-next)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while agents deployed across your infrastructure handle key generation, certificate deployment, and local validation — private keys never leave the infrastructure they were generated on.
|
Certctl is a certificate management platform with a **decoupled control-plane and agent architecture**. The control plane orchestrates certificate issuance and renewal, while agents deployed across your infrastructure handle key generation, certificate deployment, and local validation — private keys never leave the infrastructure they were generated on.
|
||||||
@@ -41,7 +77,7 @@ flowchart TB
|
|||||||
|
|
||||||
subgraph "Issuer Backends"
|
subgraph "Issuer Backends"
|
||||||
CA1["Local CA\n(crypto/x509, sub-CA)"]
|
CA1["Local CA\n(crypto/x509, sub-CA)"]
|
||||||
CA2["ACME\n(HTTP-01 + DNS-01)"]
|
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
||||||
CA3["step-ca\n(/sign API)"]
|
CA3["step-ca\n(/sign API)"]
|
||||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||||
CA6["Vault PKI\n(planned)"]
|
CA6["Vault PKI\n(planned)"]
|
||||||
@@ -92,14 +128,14 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it
|
|||||||
|
|
||||||
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).
|
||||||
|
|
||||||
**Current views**: certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info + OS/architecture grouping with charts), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), summary dashboard with charts (expiration heatmap, renewal success rate, status distribution, issuance rate), and login page.
|
**Current views** (21 pages): certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info + OS/architecture grouping with charts), job queue (status, retry, cancel, approve/reject for AwaitingApproval jobs), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), discovered certificates triage (claim/dismiss unmanaged certs discovered by agents or network scans), network scan targets management (CRUD for network scan targets + Scan Now button), summary dashboard with charts (expiration heatmap, renewal success rate, status distribution, issuance rate), and login page.
|
||||||
|
|
||||||
The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
|
The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
|
||||||
|
|
||||||
**Tech decisions**:
|
**Tech decisions**:
|
||||||
- Vite for fast builds and HMR during development
|
- Vite for fast builds and HMR during development
|
||||||
- TanStack Query over manual fetch/useEffect for automatic cache invalidation and refetching
|
- TanStack Query over manual fetch/useEffect for automatic cache invalidation and refetching
|
||||||
- Dark theme default (ops teams live in dark mode)
|
- 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
|
||||||
|
|
||||||
### PostgreSQL Database
|
### PostgreSQL Database
|
||||||
@@ -414,7 +450,7 @@ Short-lived certificates (those with profile TTL < 1 hour) return "good" from OC
|
|||||||
|
|
||||||
### 4. Automatic Renewal
|
### 4. Automatic Renewal
|
||||||
|
|
||||||
The control plane runs a scheduler with six background loops:
|
The control plane runs a scheduler with seven background loops:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -425,6 +461,7 @@ flowchart LR
|
|||||||
N["Notification Processor\n⏱ every 1m"]
|
N["Notification Processor\n⏱ every 1m"]
|
||||||
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"]
|
||||||
end
|
end
|
||||||
|
|
||||||
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
|
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
|
||||||
@@ -433,6 +470,7 @@ flowchart LR
|
|||||||
N -->|"Send pending notifications\nEmail / Webhook / Slack"| DB
|
N -->|"Send pending notifications\nEmail / Webhook / Slack"| 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
|
||||||
```
|
```
|
||||||
|
|
||||||
| Loop | Interval | Timeout | Purpose |
|
| Loop | Interval | Timeout | Purpose |
|
||||||
@@ -442,7 +480,10 @@ flowchart LR
|
|||||||
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
|
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
|
||||||
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
|
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
|
||||||
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
||||||
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`) |
|
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`). CIDR size validated at API level — max /20 (4096 IPs) per range. |
|
||||||
|
| Certificate digest | 24 hours | 5 minutes | Generates HTML email with certificate stats, expiration timeline, job health, agent count. Does NOT run on startup — waits for first scheduled tick. Configurable interval and recipients via `CERTCTL_DIGEST_INTERVAL` and `CERTCTL_DIGEST_RECIPIENTS`. Falls back to certificate owner emails if no explicit recipients configured. |
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
@@ -474,6 +515,8 @@ flowchart TB
|
|||||||
TI --> NG["NGINX"]
|
TI --> NG["NGINX"]
|
||||||
TI --> AP["Apache httpd"]
|
TI --> AP["Apache httpd"]
|
||||||
TI --> HP["HAProxy"]
|
TI --> HP["HAProxy"]
|
||||||
|
TI --> TF["Traefik"]
|
||||||
|
TI --> CD["Caddy"]
|
||||||
TI --> F5["F5 BIG-IP (interface only)"]
|
TI --> F5["F5 BIG-IP (interface only)"]
|
||||||
TI --> IIS["IIS (interface only)"]
|
TI --> IIS["IIS (interface only)"]
|
||||||
end
|
end
|
||||||
@@ -527,7 +570,11 @@ type Connector interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01 and DNS-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks), order finalization, and DER-to-PEM chain conversion. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||||
|
|
||||||
|
**ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
|
||||||
|
|
||||||
|
The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
||||||
|
|
||||||
### Target Connector
|
### Target Connector
|
||||||
|
|
||||||
@@ -543,7 +590,9 @@ type Connector interface {
|
|||||||
|
|
||||||
The `DeploymentRequest` struct carries the full material needed by the target system: the signed certificate, the CA chain, the agent-generated private key, target-specific configuration, and arbitrary metadata. The key field is populated by the agent from its local key store (`CERTCTL_KEY_DIR`) — it never originates from the control plane.
|
The `DeploymentRequest` struct carries the full material needed by the target system: the signed certificate, the CA chain, the agent-generated private key, target-specific configuration, and arbitrary metadata. The key field is populated by the agent from its local key store (`CERTCTL_KEY_DIR`) — it never originates from the control plane.
|
||||||
|
|
||||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **F5 BIG-IP** (interface only — proxy agent + iControl REST, implementation planned), **IIS** (interface only — dual-mode: agent-local PowerShell primary + proxy agent WinRM for agentless targets, implementation planned).
|
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **Traefik** (file provider — writes cert/key to watched directory, Traefik auto-reloads), **Caddy** (dual-mode: admin API hot-reload or file-based), **F5 BIG-IP** (interface only — proxy agent + iControl REST, implementation planned), **IIS** (interface only — dual-mode: agent-local PowerShell primary + proxy agent WinRM for agentless targets, implementation planned).
|
||||||
|
|
||||||
|
After deployment, agents can perform **post-deployment TLS verification**: the agent probes the live TLS endpoint using `crypto/tls.DialWithDialer` and compares the SHA-256 fingerprint of the served certificate against what was deployed. Results are reported via `POST /api/v1/jobs/{id}/verify` and stored on the job record. Verification is best-effort — failures don't block or rollback deployments.
|
||||||
|
|
||||||
Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
||||||
|
|
||||||
@@ -669,10 +718,41 @@ Audit events cannot be modified or deleted. They support filtering by actor, act
|
|||||||
|
|
||||||
### API Audit Log
|
### API Audit Log
|
||||||
|
|
||||||
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, path, actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
|
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, URL path (excluding query parameters — see security note below), actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
|
||||||
|
|
||||||
|
**Security: Query Parameter Exclusion** — The audit middleware intentionally records `r.URL.Path` only (not `r.URL.String()` or `r.RequestURI`). Query strings may contain cursor tokens, API keys passed as params, or other sensitive filter values. Since the audit trail is append-only with no deletion capability, any sensitive data recorded would persist permanently.
|
||||||
|
|
||||||
Audit recording is async (via goroutine) so it never blocks the HTTP response. If audit persistence fails, the error is logged immediately — the API call still succeeds. The middleware sits after the auth middleware in the stack so the actor identity is available from context.
|
Audit recording is async (via goroutine) so it never blocks the HTTP response. If audit persistence fails, the error is logged immediately — the API call still succeeds. The middleware sits after the auth middleware in the stack so the actor identity is available from context.
|
||||||
|
|
||||||
|
### Input Validation and SSRF Protection
|
||||||
|
|
||||||
|
All shell-facing inputs (connector scripts, domain names, ACME tokens) are validated through `internal/validation/command.go` before reaching shell execution. `ValidateShellCommand()` denies all shell metacharacters. `ValidateDomainName()` enforces RFC 1123. `ValidateACMEToken()` restricts to base64url characters. The network scanner filters reserved IP ranges (loopback, link-local including cloud metadata 169.254.169.254, multicast, broadcast) to prevent SSRF, while preserving RFC 1918 private ranges for legitimate internal scanning.
|
||||||
|
|
||||||
|
### Request Body Size Limits
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Middleware Chain Order
|
||||||
|
|
||||||
|
The HTTP middleware stack processes requests in the following order (see `cmd/server/main.go`):
|
||||||
|
|
||||||
|
1. **RequestID** - assigns unique request ID for correlation
|
||||||
|
2. **Logging** - structured slog middleware with request ID propagation
|
||||||
|
3. **Recovery** - panic recovery (catches panics in downstream middleware/handlers)
|
||||||
|
4. **BodyLimit** - request body size cap via `http.MaxBytesReader`
|
||||||
|
5. **RateLimiter** - token bucket rate limiting (optional, when enabled)
|
||||||
|
6. **CORS** - cross-origin request handling (deny-by-default)
|
||||||
|
7. **Auth** - API key or JWT validation
|
||||||
|
8. **AuditLog** - records every API call to the audit trail (requires auth context for actor)
|
||||||
|
|
||||||
|
### Concurrency Safety
|
||||||
|
|
||||||
|
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 6 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.
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
All logging throughout the service layer uses Go's `log/slog` package for structured, queryable logs. This replaces ad-hoc `fmt.Printf` statements with consistent key-value logging that includes request context, operation names, and error details. Agents also implement exponential backoff on network failures to gracefully handle temporary connectivity issues with the control plane.
|
All logging throughout the service layer uses Go's `log/slog` package for structured, queryable logs. This replaces ad-hoc `fmt.Printf` statements with consistent key-value logging that includes request context, operation names, and error details. Agents also implement exponential backoff on network failures to gracefully handle temporary connectivity issues with the control plane.
|
||||||
@@ -690,7 +770,7 @@ 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 endpoints across 20 resource domains (95 under `/api/v1/` + `/.well-known/est/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, Prometheus metrics from M22, and 4 EST enrollment endpoints from M23), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
|
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 99 endpoints across 23 resource domains (97 under `/api/v1/` + `/.well-known/est/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, Prometheus metrics from M22, 4 EST enrollment endpoints from M23, 2 digest endpoints from M29), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
@@ -705,6 +785,8 @@ Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST
|
|||||||
|
|
||||||
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. 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 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.
|
||||||
|
|
||||||
Health checks live outside the API prefix: `GET /health` and `GET /ready`.
|
Health checks live outside the API prefix: `GET /health` and `GET /ready`.
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server
|
||||||
@@ -760,7 +842,9 @@ flowchart TB
|
|||||||
**Credentials & Configuration:**
|
**Credentials & Configuration:**
|
||||||
Database and API credentials are managed via environment variables defined in a `.env` file. Copy `deploy/.env.example` to `deploy/.env` for local development and customize credentials for production. The agent key directory (`CERTCTL_KEY_DIR`) is persisted as a named Docker volume (`agent_keys`) at `/var/lib/certctl/keys` for reliable key storage across container restarts.
|
Database and API credentials are managed via environment variables defined in a `.env` file. Copy `deploy/.env.example` to `deploy/.env` for local development and customize credentials for production. The agent key directory (`CERTCTL_KEY_DIR`) is persisted as a named Docker volume (`agent_keys`) at `/var/lib/certctl/keys` for reliable key storage across container restarts.
|
||||||
|
|
||||||
### Production (Kubernetes)
|
### Production (Kubernetes with Helm)
|
||||||
|
|
||||||
|
A production-ready Helm chart is available under `deploy/helm/certctl/` with full support for multi-replica deployments, persistent PostgreSQL, agent DaemonSet, optional Ingress, and security best practices.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
@@ -786,6 +870,21 @@ flowchart TB
|
|||||||
DS --> DEP
|
DS --> DEP
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Helm Installation:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add the chart (if published) or install from local directory
|
||||||
|
helm install certctl deploy/helm/certctl/ \
|
||||||
|
--set server.auth.apiKey="your-secure-key" \
|
||||||
|
--set postgresql.auth.password="your-db-password" \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.hosts[0].host="certctl.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
The Helm chart includes: server Deployment with configurable replicas, liveness/readiness probes, security context (non-root, read-only rootfs), PostgreSQL StatefulSet with persistent volumes, optional Ingress with TLS, ServiceAccount with configurable RBAC, and agent DaemonSet running one agent per node. All certctl configuration options are exposed in `values.yaml` — issuers, targets, notifiers, scheduler intervals, discovery settings, and SMTP for digest emails.
|
||||||
|
|
||||||
|
See `deploy/helm/certctl/values.yaml` for the full configuration reference and `deploy/helm/certctl/Chart.yaml` for version and appVersion details.
|
||||||
|
|
||||||
For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.).
|
For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.).
|
||||||
|
|
||||||
## Discovery Data Flow (M18b + M21)
|
## Discovery Data Flow (M18b + M21)
|
||||||
@@ -855,7 +954,7 @@ This data flow is pull-based and non-blocking. Agents discover at their own pace
|
|||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 900+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
|
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 1050+ tests across six layers (service, handler, integration, connector, frontend, and scheduler). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
|
||||||
|
|
||||||
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
|
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
|
||||||
|
|
||||||
@@ -867,11 +966,15 @@ certctl uses a layered testing approach aligned with the handler → service →
|
|||||||
|
|
||||||
**CLI tests** (`internal/cli/client_test.go`) — 14 tests covering all 10 CLI subcommands with httptest mock servers, PEM parsing for bulk import, auth header verification, and JSON/table output formatting.
|
**CLI tests** (`internal/cli/client_test.go`) — 14 tests covering all 10 CLI subcommands with httptest mock servers, PEM parsing for bulk import, auth header verification, and JSON/table output formatting.
|
||||||
|
|
||||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, and HAProxy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, race detection, static analysis, vulnerability scanning, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs `go test -race` on service, handler, middleware, and scheduler packages to catch data races. It runs `golangci-lint` with 11 linters (errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx) configured in `.golangci.yml`. It runs `govulncheck ./...` to scan dependencies for known CVEs. Coverage thresholds are enforced per-layer: service 60%, handler 60%, domain 40%, middleware 50%. These thresholds act as regression floors — they can only go up. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, HAProxy, Traefik, and Caddy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
||||||
|
|
||||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 6 tests for script-based DNS-01 challenges. The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 10 tests for script-based DNS-01 and DNS-PERSIST-01 challenges (6 DNS-01 tests + 4 DNS-PERSIST-01 tests covering `PresentPersist` success, no-script error, script failure, and wildcard domain handling). The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Traefik and Caddy connectors have tests covering file-based deployment and (for Caddy) dual-mode API/file configuration. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
||||||
|
|
||||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for a future release). Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Tests for idempotency guards (`sync/atomic.Bool` CompareAndSwap prevents concurrent loop ticks), `WaitForCompletion` success and timeout paths, and multi-loop idempotency.
|
||||||
|
|
||||||
|
**Fuzz tests** (`internal/validation/command_fuzz_test.go`, `internal/domain/revocation_fuzz_test.go`) — Go native fuzz tests (`testing/fuzz`) for command validation functions and revocation domain parsing. These exercise `ValidateShellCommand`, `ValidateDomainName`, and `ValidateACMEToken` with random inputs to discover edge cases.
|
||||||
|
|
||||||
|
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests — a `testcontainers-go` scaffolding for isolated PostgreSQL instances is planned. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for V3). The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
NIST SP 800-57 Part 1 Rev 5 (May 2020) is the authoritative US government guidance on cryptographic key management. This document maps certctl's implementation to its recommendations. certctl follows NIST guidance where applicable; this guide documents the alignment and identifies gaps for future roadmap planning.
|
NIST SP 800-57 Part 1 Rev 5 (May 2020) is the authoritative US government guidance on cryptographic key management. This document maps certctl's implementation to its recommendations. certctl follows NIST guidance where applicable; this guide documents the alignment and identifies gaps for future roadmap planning.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [Key Generation (Section 6.1)](#key-generation-section-61)
|
||||||
|
2. [Key Storage and Protection (Sections 6.3, 6.4)](#key-storage-and-protection-sections-63-64)
|
||||||
|
3. [Cryptoperiods (Section 5.3, Table 1)](#cryptoperiods-section-53-table-1)
|
||||||
|
4. [Key States and Transitions (Section 5.2)](#key-states-and-transitions-section-52)
|
||||||
|
5. [Algorithm Recommendations (Section 5.1, SP 800-131A)](#algorithm-recommendations-section-51-sp-800-131a)
|
||||||
|
6. [Key Distribution and Transport (Section 6.2)](#key-distribution-and-transport-section-62)
|
||||||
|
7. [Revocation and Compromise (NIST SP 800-57 Part 3)](#revocation-and-compromise-nist-sp-800-57-part-3)
|
||||||
|
8. [Alignment Summary Table](#alignment-summary-table)
|
||||||
|
9. [Gaps and Remediation Roadmap](#gaps-and-remediation-roadmap)
|
||||||
|
- [V2 (Current)](#v2-current)
|
||||||
|
- [V3 (Planned: 2026)](#v3-planned-2026)
|
||||||
|
- [V5 (Planned: 2027+)](#v5-planned-2027)
|
||||||
|
- [Post-Quantum (2027+)](#post-quantum-2027)
|
||||||
|
10. [References](#references)
|
||||||
|
11. [Questions or Corrections?](#questions-or-corrections)
|
||||||
|
|
||||||
## Key Generation (Section 6.1)
|
## Key Generation (Section 6.1)
|
||||||
|
|
||||||
certctl generates certificate keys on agent infrastructure using Go's `crypto/rand` for entropy, backed by `/dev/urandom` on Linux and `CryptGenRandom` on Windows. Key generation happens as follows:
|
certctl generates certificate keys on agent infrastructure using Go's `crypto/rand` for entropy, backed by `/dev/urandom` on Linux and `CryptGenRandom` on Windows. Key generation happens as follows:
|
||||||
|
|||||||
@@ -4,6 +4,34 @@ This guide maps certctl's existing capabilities to PCI-DSS 4.0 requirements rele
|
|||||||
|
|
||||||
Organizations subject to PCI-DSS typically need to demonstrate control over certificate issuance, renewal, rotation, revocation, and key management. Certctl automates the technical controls for certificate lifecycle; compliance depends on how you deploy, monitor, and audit it.
|
Organizations subject to PCI-DSS typically need to demonstrate control over certificate issuance, renewal, rotation, revocation, and key management. Certctl automates the technical controls for certificate lifecycle; compliance depends on how you deploy, monitor, and audit it.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [How to Use This Guide](#how-to-use-this-guide)
|
||||||
|
2. [Requirement 4: Protect Data in Transit](#requirement-4-protect-data-in-transit)
|
||||||
|
- [4.2.1 — Strong Cryptography for Transmission](#421--strong-cryptography-for-transmission)
|
||||||
|
- [4.2.2 — Certificate Inventory and Validation](#422--certificate-inventory-and-validation)
|
||||||
|
3. [Requirement 3: Protect Stored Cardholder Data (Key Management)](#requirement-3-protect-stored-cardholder-data-key-management)
|
||||||
|
- [3.6 — Cryptographic Key Documentation](#36--cryptographic-key-documentation)
|
||||||
|
- [3.7 — Key Lifecycle Procedures](#37--key-lifecycle-procedures)
|
||||||
|
4. [Requirement 8: Identify and Authenticate](#requirement-8-identify-and-authenticate)
|
||||||
|
- [8.3 — Strong Authentication](#83--strong-authentication)
|
||||||
|
- [8.6 — Application Account Management](#86--application-account-management)
|
||||||
|
5. [Requirement 10: Log and Monitor](#requirement-10-log-and-monitor)
|
||||||
|
- [10.2 — Implement Automated Audit Logging](#102--implement-automated-audit-logging)
|
||||||
|
- [10.3 — Protect Audit Trail](#103--protect-audit-trail)
|
||||||
|
- [10.4 — Promptly Review and Address Audit Trail Exceptions](#104--promptly-review-and-address-audit-trail-exceptions)
|
||||||
|
- [10.7 — Retain and Protect Audit Trail History](#107--retain-and-protect-audit-trail-history)
|
||||||
|
6. [Requirement 6: Develop and Maintain Secure Systems and Applications](#requirement-6-develop-and-maintain-secure-systems-and-applications)
|
||||||
|
- [6.3.1 — Security Coding Practices](#631--security-coding-practices)
|
||||||
|
- [6.5.10 — Broken Authentication and Cryptography Prevention](#6510--broken-authentication-and-cryptography-prevention)
|
||||||
|
7. [Requirement 7: Restrict Access by Business Need-to-Know](#requirement-7-restrict-access-by-business-need-to-know)
|
||||||
|
- [7.2 — Implement Access Control](#72--implement-access-control)
|
||||||
|
8. [Evidence Summary Table](#evidence-summary-table)
|
||||||
|
9. [Operator Responsibilities](#operator-responsibilities)
|
||||||
|
10. [V3 Enhancements for PCI-DSS](#v3-enhancements-for-pci-dss)
|
||||||
|
11. [Next Steps for Compliance](#next-steps-for-compliance)
|
||||||
|
12. [Questions?](#questions)
|
||||||
|
|
||||||
## How to Use This Guide
|
## How to Use This Guide
|
||||||
|
|
||||||
Your QSA will request evidence that your certificate and key management systems meet specific PCI-DSS 4.0 requirements. For each applicable requirement, this guide identifies:
|
Your QSA will request evidence that your certificate and key management systems meet specific PCI-DSS 4.0 requirements. For each applicable requirement, this guide identifies:
|
||||||
@@ -365,7 +393,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
|
|
||||||
**Operator Responsibility**:
|
**Operator Responsibility**:
|
||||||
- **Issue API keys to users/systems** requiring API access (outside certctl; you maintain key registry).
|
- **Issue API keys to users/systems** requiring API access (outside certctl; you maintain key registry).
|
||||||
- **Rotate API keys periodically** (recommendation: annually, or when personnel changes).
|
- **Rotate API keys using zero-downtime rotation** — `CERTCTL_AUTH_SECRET` supports comma-separated keys (e.g., `new-key,old-key`). Add the new key, migrate clients, then remove the old key. Recommendation: rotate at least annually, or immediately when personnel changes.
|
||||||
- **Revoke API keys immediately** when user leaves or token is compromised (set `enabled=false` in API key management — not yet implemented in v1, owner must track manually).
|
- **Revoke API keys immediately** when user leaves or token is compromised (set `enabled=false` in API key management — not yet implemented in v1, owner must track manually).
|
||||||
- **Enforce strong TLS** on control plane: TLS 1.2+, modern ciphers (configure on reverse proxy or `CERTCTL_TLS_*` env vars if operator-controlled).
|
- **Enforce strong TLS** on control plane: TLS 1.2+, modern ciphers (configure on reverse proxy or `CERTCTL_TLS_*` env vars if operator-controlled).
|
||||||
- **Protect `.env` and credential files** where API key is defined (restrict file system access, no version control).
|
- **Protect `.env` and credential files** where API key is defined (restrict file system access, no version control).
|
||||||
@@ -424,7 +452,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
|||||||
- **Immutable API Audit Log** (M19) — Middleware captures every API call:
|
- **Immutable API Audit Log** (M19) — Middleware captures every API call:
|
||||||
- `audit_events` table (append-only, no UPDATE/DELETE):
|
- `audit_events` table (append-only, no UPDATE/DELETE):
|
||||||
- `method`: HTTP method (GET, POST, PUT, DELETE)
|
- `method`: HTTP method (GET, POST, PUT, DELETE)
|
||||||
- `path`: API endpoint path (e.g., `/api/v1/certificates`)
|
- `path`: API endpoint path only, excluding query parameters (e.g., `/api/v1/certificates` — query strings intentionally omitted to prevent sensitive data persistence in the append-only audit trail)
|
||||||
- `actor`: authenticated user/service (extracted from API key or context)
|
- `actor`: authenticated user/service (extracted from API key or context)
|
||||||
- `body_hash`: SHA-256 hash of request body (truncated to 16 chars, first 8 chars shown in logs)
|
- `body_hash`: SHA-256 hash of request body (truncated to 16 chars, first 8 chars shown in logs)
|
||||||
- `status_code`: HTTP response status (200, 201, 400, 401, 404, 500, etc.)
|
- `status_code`: HTTP response status (200, 201, 400, 401, 404, 500, etc.)
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ Each section includes:
|
|||||||
- **V2 vs V3 status** — whether feature is in the free community edition (V2) or paid Pro edition (V3)
|
- **V2 vs V3 status** — whether feature is in the free community edition (V2) or paid Pro edition (V3)
|
||||||
- **Operator responsibility** — aspects your organization must handle outside of certctl
|
- **Operator responsibility** — aspects your organization must handle outside of certctl
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [How to Use This Guide](#how-to-use-this-guide)
|
||||||
|
2. [CC6: Logical and Physical Access Controls](#cc6-logical-and-physical-access-controls)
|
||||||
|
- [CC6.1 — Logical Access Security](#cc61--logical-access-security)
|
||||||
|
- [CC6.2 — Prior to Issuing System Credentials](#cc62--prior-to-issuing-system-credentials)
|
||||||
|
- [CC6.3 — Authentication Policies](#cc63--authentication-policies)
|
||||||
|
- [CC6.7 — Information Transmission Protection](#cc67--information-transmission-protection)
|
||||||
|
3. [CC7: System Operations](#cc7-system-operations)
|
||||||
|
- [CC7.1 — System Monitoring](#cc71--system-monitoring)
|
||||||
|
- [CC7.2 — Anomaly Detection](#cc72--anomaly-detection)
|
||||||
|
- [CC7.3 — Incident Response](#cc73--incident-response)
|
||||||
|
- [CC7.4 — Identify and Develop Risk Mitigation Activities](#cc74--identify-and-develop-risk-mitigation-activities)
|
||||||
|
4. [A1: Availability](#a1-availability)
|
||||||
|
- [A1.1/A1.2 — Availability and Recovery](#a11a12--availability-and-recovery)
|
||||||
|
5. [CC8: Change Management](#cc8-change-management)
|
||||||
|
- [CC8.1 — Change Control](#cc81--change-control)
|
||||||
|
6. [Evidence Summary Table](#evidence-summary-table)
|
||||||
|
7. [What Requires Operator Action](#what-requires-operator-action)
|
||||||
|
8. [V3 Enhancements](#v3-enhancements)
|
||||||
|
9. [Conclusion](#conclusion)
|
||||||
|
|
||||||
## CC6: Logical and Physical Access Controls
|
## CC6: Logical and Physical Access Controls
|
||||||
|
|
||||||
### CC6.1 — Logical Access Security
|
### CC6.1 — Logical Access Security
|
||||||
@@ -27,6 +49,7 @@ Each section includes:
|
|||||||
- **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.
|
||||||
- **No Password Storage** — certctl does not store user passwords. API keys are the sole authentication mechanism. Your API key generation, distribution, and rotation policies are your responsibility (see "Operator Responsibility" below).
|
- **No Password Storage** — certctl does not store user passwords. API keys are the sole authentication mechanism. Your API key generation, distribution, and rotation policies are your responsibility (see "Operator Responsibility" below).
|
||||||
|
- **Zero-Downtime Key Rotation** — `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `new-key,old-key`). All listed keys are validated with constant-time comparison. Operators can add a new key, migrate clients, then remove the old key — no service restart required for the client migration phase. A single-key warning is logged at startup to encourage rotation configuration.
|
||||||
|
|
||||||
**Evidence Locations**:
|
**Evidence Locations**:
|
||||||
|
|
||||||
@@ -210,7 +233,7 @@ Each section includes:
|
|||||||
|
|
||||||
**certctl Implementation** (V2):
|
**certctl Implementation** (V2):
|
||||||
|
|
||||||
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, path, query parameters, actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp.
|
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, URL path (query parameters intentionally excluded — see security note), actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp. **Security: Query parameters are excluded from the audit path** because they may contain cursor tokens, API keys, or sensitive filter values; since the audit trail is append-only with no deletion, any sensitive data recorded would persist permanently.
|
||||||
- **Audit Trail API** — `GET /api/v1/audit?actor=...&action=...&resource_id=...&created_after=...&created_before=...` allows searching for anomalous patterns (e.g., "who accessed certificate XYZ and when?", "did anyone revoke certs at 2 AM?").
|
- **Audit Trail API** — `GET /api/v1/audit?actor=...&action=...&resource_id=...&created_after=...&created_before=...` allows searching for anomalous patterns (e.g., "who accessed certificate XYZ and when?", "did anyone revoke certs at 2 AM?").
|
||||||
- **Expiration Threshold Alerting** — Certificate renewal policies define alert thresholds (days before expiry): default `[30, 14, 7, 0]`. When a certificate approaches a threshold, a notification is enqueued. Deduplication prevents duplicate alerts for the same cert at the same threshold. Auto status transition: cert moves to `Expiring` status at 30 days, `Expired` at 0 days.
|
- **Expiration Threshold Alerting** — Certificate renewal policies define alert thresholds (days before expiry): default `[30, 14, 7, 0]`. When a certificate approaches a threshold, a notification is enqueued. Deduplication prevents duplicate alerts for the same cert at the same threshold. Auto status transition: cert moves to `Expiring` status at 30 days, `Expired` at 0 days.
|
||||||
- **Certificate Status Auto-Transitions** — When a cert is issued, it's `Active`. As expiry approaches, status auto-transitions to `Expiring` (at 30d threshold). At expiry, status becomes `Expired`. Revoked certs move to `Revoked`. These transitions are recorded in the audit trail.
|
- **Certificate Status Auto-Transitions** — When a cert is issued, it's `Active`. As expiry approaches, status auto-transitions to `Expiring` (at 30d threshold). At expiry, status becomes `Expired`. Revoked certs move to `Revoked`. These transitions are recorded in the audit trail.
|
||||||
|
|||||||
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why the industry's move toward shorter certificate lifespans — down to 47 days by 2029 — makes automated lifecycle management essential.
|
If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why the industry's move toward shorter certificate lifespans — down to 47 days by 2029 — makes automated lifecycle management essential.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [What Is a TLS Certificate?](#what-is-a-tls-certificate)
|
||||||
|
2. [Why Do Certificates Expire?](#why-do-certificates-expire)
|
||||||
|
3. [The Cast of Characters](#the-cast-of-characters)
|
||||||
|
- [Certificate Authority (CA)](#certificate-authority-ca)
|
||||||
|
- [ACME Protocol](#acme-protocol)
|
||||||
|
- [EST Protocol (Enrollment over Secure Transport)](#est-protocol-enrollment-over-secure-transport)
|
||||||
|
- [Private Key](#private-key)
|
||||||
|
- [Subject Alternative Names (SANs)](#subject-alternative-names-sans)
|
||||||
|
- [Certificate Chain](#certificate-chain)
|
||||||
|
4. [How certctl Works](#how-certctl-works)
|
||||||
|
- [The Control Plane (Server)](#the-control-plane-server)
|
||||||
|
- [Agents](#agents)
|
||||||
|
- [Deployment Targets](#deployment-targets)
|
||||||
|
5. [The Certificate Lifecycle](#the-certificate-lifecycle)
|
||||||
|
6. [Why Not Just Use Certbot?](#why-not-just-use-certbot)
|
||||||
|
7. [Key Concepts in certctl](#key-concepts-in-certctl)
|
||||||
|
- [Teams and Owners](#teams-and-owners)
|
||||||
|
- [Agent Groups](#agent-groups)
|
||||||
|
- [Certificate Profiles](#certificate-profiles)
|
||||||
|
- [Interactive Renewal Approval](#interactive-renewal-approval)
|
||||||
|
- [Certificate Revocation](#certificate-revocation)
|
||||||
|
- [Short-Lived Certificates](#short-lived-certificates)
|
||||||
|
- [Policies](#policies)
|
||||||
|
- [Jobs](#jobs)
|
||||||
|
- [Audit Trail](#audit-trail)
|
||||||
|
- [Notifications](#notifications)
|
||||||
|
- [CLI](#cli)
|
||||||
|
- [MCP Server (AI Integration)](#mcp-server-ai-integration)
|
||||||
|
- [EST Enrollment (Device Certificates)](#est-enrollment-device-certificates)
|
||||||
|
- [Certificate Discovery](#certificate-discovery)
|
||||||
|
- [Observability](#observability)
|
||||||
|
8. [What's Next](#whats-next)
|
||||||
|
|
||||||
## What Is a TLS Certificate?
|
## What Is a TLS Certificate?
|
||||||
|
|
||||||
When you visit `https://yourbank.com`, your browser checks a digital document called a **TLS certificate** before sending any data. That certificate proves two things: (1) you're really talking to yourbank.com and not an imposter, and (2) everything sent between you and the server is encrypted.
|
When you visit `https://yourbank.com`, your browser checks a digital document called a **TLS certificate** before sending any data. That certificate proves two things: (1) you're really talking to yourbank.com and not an imposter, and (2) everything sent between you and the server is encrypted.
|
||||||
@@ -34,9 +69,9 @@ certctl includes a built-in **Local CA** that can operate in two modes: self-sig
|
|||||||
|
|
||||||
### ACME Protocol
|
### ACME Protocol
|
||||||
|
|
||||||
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01) or creating a DNS record (DNS-01).
|
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01), creating a DNS record (DNS-01), or maintaining a standing DNS record that persists across renewals (DNS-PERSIST-01).
|
||||||
|
|
||||||
certctl speaks ACME natively with both HTTP-01 and DNS-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
certctl speaks ACME natively with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.); DNS-PERSIST-01 creates a standing `_validation-persist` TXT record once (containing the CA domain and account URI) that the CA revalidates on every renewal — no per-renewal DNS updates needed. If the CA doesn't yet support DNS-PERSIST-01, certctl automatically falls back to DNS-01.
|
||||||
|
|
||||||
### EST Protocol (Enrollment over Secure Transport)
|
### EST Protocol (Enrollment over Secure Transport)
|
||||||
|
|
||||||
@@ -148,6 +183,19 @@ Profiles are managed via the API (`/api/v1/profiles`) and the GUI, and can be as
|
|||||||
|
|
||||||
For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal.
|
For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal.
|
||||||
|
|
||||||
|
### Renewal Timing: Thresholds vs. ARI (RFC 9702)
|
||||||
|
|
||||||
|
**Traditional approach (thresholds):** By default, certctl uses static renewal thresholds — renew a certificate at a fixed number of days before expiry (default: 30 days). This simple, predictable model works for most use cases: it avoids unnecessary renewals near expiry and gives you a predictable window to catch failures.
|
||||||
|
|
||||||
|
**Advanced approach (ACME ARI):** Some Certificate Authorities support ACME Renewal Information (RFC 9702), which allows the CA to tell certctl the optimal time to renew. Instead of guessing "renew 30 days before expiry," the CA responds with a precise `suggestedWindow` containing start and end times. This is useful when:
|
||||||
|
- The CA is performing maintenance and wants to batch renewals in a specific window
|
||||||
|
- The CA is coordinating a mass revocation (e.g., due to a compromise) and needs to control renewal timing
|
||||||
|
- You want to avoid thundering herd renewal spikes by accepting the CA's suggested timing
|
||||||
|
|
||||||
|
**How it works:** Enable with `CERTCTL_ACME_ARI_ENABLED=true` on your ACME issuer. When a certificate approaches expiry, certctl queries the ARI endpoint with the certificate's DER encoding. The CA responds with a suggested renewal window. If the current time is within the window or past the start time, certctl renews immediately. Otherwise, it waits until the window opens.
|
||||||
|
|
||||||
|
**Graceful degradation:** If your CA doesn't support ARI (returns 404 from the ARI endpoint), certctl automatically falls back to the traditional threshold-based renewal. No configuration change needed — the fallback is transparent. Errors from the CA are logged as warnings and don't block the renewal process.
|
||||||
|
|
||||||
### Certificate Revocation
|
### Certificate Revocation
|
||||||
|
|
||||||
When a private key is compromised, a certificate is superseded, or a service is decommissioned, you need to revoke the certificate immediately — not wait for it to expire. Revocation tells clients "stop trusting this certificate right now."
|
When a private key is compromised, a certificate is superseded, or a service is decommissioned, you need to revoke the certificate immediately — not wait for it to expire. Revocation tells clients "stop trusting this certificate right now."
|
||||||
@@ -211,10 +259,12 @@ Certificate discovery is the process of automatically finding existing certifica
|
|||||||
**How it works:** There are two discovery modes. *Filesystem discovery* — agents scan configured directories (configured via `CERTCTL_DISCOVERY_DIRS`) for certificate files. On startup and every 6 hours, the agent walks directories recursively, parses PEM and DER files, extracts metadata, and reports findings to the control plane. *Network discovery* — the control plane itself probes TLS endpoints across configured CIDR ranges and ports (enabled via `CERTCTL_NETWORK_SCAN_ENABLED=true`). It connects to each endpoint, extracts certificates from the TLS handshake, and feeds results into the same discovery pipeline. This finds certificates on services you may not have agents on. In both cases, the server deduplicates by fingerprint and stores discovered certs with a status: **Unmanaged** (discovered but not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage it).
|
**How it works:** There are two discovery modes. *Filesystem discovery* — agents scan configured directories (configured via `CERTCTL_DISCOVERY_DIRS`) for certificate files. On startup and every 6 hours, the agent walks directories recursively, parses PEM and DER files, extracts metadata, and reports findings to the control plane. *Network discovery* — the control plane itself probes TLS endpoints across configured CIDR ranges and ports (enabled via `CERTCTL_NETWORK_SCAN_ENABLED=true`). It connects to each endpoint, extracts certificates from the TLS handshake, and feeds results into the same discovery pipeline. This finds certificates on services you may not have agents on. In both cases, the server deduplicates by fingerprint and stores discovered certs with a status: **Unmanaged** (discovered but not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage it).
|
||||||
|
|
||||||
This gives you a three-step triage workflow:
|
This gives you a three-step triage workflow:
|
||||||
1. **Discover** — Agents find all existing certs on your infrastructure
|
1. **Discover** — Agents scan filesystems and the server probes network endpoints to find all existing certs
|
||||||
2. **Triage** — Operators review discoveries and decide: claim it (enroll for management), or dismiss it (not worth managing)
|
2. **Triage** — Operators review discoveries in the **Discovery** dashboard page and decide: claim it (link to a managed certificate) or dismiss it (not worth managing). The dashboard shows a summary stats bar (Unmanaged/Managed/Dismissed counts), filters by status and agent, and provides one-click claim and dismiss actions.
|
||||||
3. **Baseline** — Once triaged, you have a complete baseline of what's deployed, what you're managing, and what's unmanaged
|
3. **Baseline** — Once triaged, you have a complete baseline of what's deployed, what you're managing, and what's unmanaged
|
||||||
|
|
||||||
|
Network scan targets are managed from the **Network Scans** dashboard page — create CIDR ranges and ports to probe, enable/disable targets, trigger on-demand scans, and view results. Discovered certificates from network scans appear in the same Discovery triage page alongside filesystem discoveries.
|
||||||
|
|
||||||
This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter.
|
This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter.
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|||||||
@@ -2,12 +2,57 @@
|
|||||||
|
|
||||||
Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers the connector interfaces, built-in implementations, and how to build your own.
|
Connectors extend certctl to integrate with external systems for certificate issuance, deployment, and notifications. This guide covers the connector interfaces, built-in implementations, and how to build your own.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Issuer Connector](#issuer-connector)
|
||||||
|
- [Interface](#interface)
|
||||||
|
- [Built-in: Local CA](#built-in-local-ca)
|
||||||
|
- [Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)](#built-in-acme-v2-lets-encrypt-sectigo-zerossl)
|
||||||
|
- [Built-in: step-ca (Smallstep Private CA)](#built-in-step-ca-smallstep-private-ca)
|
||||||
|
- [OpenSSL / Custom CA](#openssl--custom-ca)
|
||||||
|
- [Revocation Across Issuers](#revocation-across-issuers)
|
||||||
|
- [EST Integration (GetCACertPEM)](#est-integration-getcacertpem)
|
||||||
|
- [Planned Issuers](#planned-issuers)
|
||||||
|
- [Building a Custom Issuer](#building-a-custom-issuer)
|
||||||
|
3. [Target Connector](#target-connector)
|
||||||
|
- [Interface](#interface-1)
|
||||||
|
- [Built-in: NGINX](#built-in-nginx)
|
||||||
|
- [Built-in: Apache httpd](#built-in-apache-httpd)
|
||||||
|
- [Built-in: HAProxy](#built-in-haproxy)
|
||||||
|
- [Built-in: Traefik](#built-in-traefik)
|
||||||
|
- [Built-in: Caddy](#built-in-caddy)
|
||||||
|
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
||||||
|
- [IIS (Interface Only, Dual-Mode)](#iis-interface-only-dual-mode)
|
||||||
|
4. [Notifier Connector](#notifier-connector)
|
||||||
|
- [Interface](#interface-2)
|
||||||
|
5. [Registering a Connector](#registering-a-connector)
|
||||||
|
- [IssuerConnectorAdapter](#issuerconnectoradapter)
|
||||||
|
- [Notifier Registration](#notifier-registration)
|
||||||
|
6. [Testing Connectors](#testing-connectors)
|
||||||
|
- [Unit Tests](#unit-tests)
|
||||||
|
- [Integration Tests](#integration-tests)
|
||||||
|
7. [Best Practices](#best-practices)
|
||||||
|
8. [Agent Discovery Scanner](#agent-discovery-scanner)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [How It Works](#how-it-works)
|
||||||
|
- [API Endpoints](#api-endpoints)
|
||||||
|
- [Use Cases](#use-cases)
|
||||||
|
9. [Network Certificate Scanner (M21)](#network-certificate-scanner-m21)
|
||||||
|
- [Configuration](#configuration-1)
|
||||||
|
- [Creating Scan Targets](#creating-scan-targets)
|
||||||
|
- [How It Works](#how-it-works-1)
|
||||||
|
- [API Endpoints](#api-endpoints-1)
|
||||||
|
- [Scheduler Integration](#scheduler-integration)
|
||||||
|
- [Use Cases](#use-cases-1)
|
||||||
|
10. [What's Next](#whats-next)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Three types of connectors:
|
Three types of connectors:
|
||||||
|
|
||||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
||||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
||||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
||||||
|
|
||||||
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
||||||
@@ -102,6 +147,8 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
|
|||||||
|
|
||||||
**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 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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -116,12 +163,16 @@ Location: `internal/connector/issuer/local/local.go`
|
|||||||
|
|
||||||
### Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)
|
### Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)
|
||||||
|
|
||||||
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports two challenge methods:
|
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports three challenge methods:
|
||||||
|
|
||||||
**HTTP-01 (default):** A built-in temporary HTTP server starts on demand during certificate issuance. The domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet.
|
**HTTP-01 (default):** A built-in temporary HTTP server starts on demand during certificate issuance. The domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet.
|
||||||
|
|
||||||
**DNS-01 (for wildcards):** Creates DNS TXT records via user-provided scripts. Required for wildcard certificates (`*.example.com`) and hosts that can't serve HTTP on port 80. The connector invokes external scripts to create and clean up `_acme-challenge` TXT records, making it compatible with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
**DNS-01 (for wildcards):** Creates DNS TXT records via user-provided scripts. Required for wildcard certificates (`*.example.com`) and hosts that can't serve HTTP on port 80. The connector invokes external scripts to create and clean up `_acme-challenge` TXT records, making it compatible with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
||||||
|
|
||||||
|
**DNS-PERSIST-01 (standing record):** Creates a one-time persistent TXT record at `_validation-persist.<domain>` containing the CA's issuer domain and your ACME account URI. Once set, this record authorizes unlimited future certificate issuances without per-renewal DNS updates. Based on [draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) and CA/Browser Forum ballot SC-088v3. If the CA doesn't offer dns-persist-01 yet, the connector falls back to dns-01 automatically.
|
||||||
|
|
||||||
|
**ACME Renewal Information (ARI, RFC 9702):** Instead of using fixed renewal thresholds (e.g., renew 30 days before expiry), certctl can ask the CA when it should renew. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. The ARI protocol lets the CA specify a `suggestedWindow` (start and end times) for when you should renew — useful for distributing load during maintenance windows or coordinating mass revocation scenarios. Cert ID is computed as `base64url(SHA-256(DER cert))`. If the CA doesn't support ARI (404 response), certctl automatically falls back to threshold-based renewal with no operator intervention required.
|
||||||
|
|
||||||
HTTP-01 configuration:
|
HTTP-01 configuration:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -143,14 +194,53 @@ DNS-01 configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name, e.g., `_acme-challenge.example.com`), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it.
|
DNS-PERSIST-01 configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"challenge_type": "dns-persist-01",
|
||||||
|
"dns_present_script": "/etc/certctl/dns/create-record.sh",
|
||||||
|
"dns_persist_issuer_domain": "letsencrypt.org",
|
||||||
|
"dns_propagation_wait": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The present script creates a TXT record at `_validation-persist.<domain>` with the value `letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/<your-id>`. This record is permanent — no cleanup script is needed.
|
||||||
|
|
||||||
|
ZeroSSL configuration (requires External Account Binding):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"directory_url": "https://acme.zerossl.com/v2/DV90",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"eab_kid": "your-zerossl-eab-kid",
|
||||||
|
"eab_hmac": "your-zerossl-eab-hmac-base64url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ZeroSSL, Google Trust Services, and SSL.com require External Account Binding (EAB) for ACME account registration. For most CAs, get your EAB credentials from the CA's dashboard and provide them via `eab_kid` and `eab_hmac`. The HMAC key must be base64url-encoded (no padding). CAs that don't require EAB (Let's Encrypt, Buypass) ignore these fields.
|
||||||
|
|
||||||
|
**ZeroSSL auto-EAB:** When the directory URL points to ZeroSSL and no EAB credentials are provided, certctl automatically fetches them from ZeroSSL's public API (`api.zerossl.com/acme/eab-credentials-email`) using your configured email address. No dashboard visit required — just set the directory URL and email, and it works. This is the same approach used by Caddy and acme.sh.
|
||||||
|
|
||||||
|
Minimal ZeroSSL configuration (auto-EAB):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"directory_url": "https://acme.zerossl.com/v2/DV90",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name — `_acme-challenge.<domain>` for dns-01, `_validation-persist.<domain>` for dns-persist-01), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it (dns-01 only).
|
||||||
|
|
||||||
Environment variables for the default ACME connector:
|
Environment variables for the default ACME connector:
|
||||||
- `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL
|
- `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL
|
||||||
- `CERTCTL_ACME_EMAIL` — Contact email for account registration
|
- `CERTCTL_ACME_EMAIL` — Contact email for account registration
|
||||||
- `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default) or `dns-01`
|
- `CERTCTL_ACME_EAB_KID` — External Account Binding Key ID (required by ZeroSSL, Google Trust Services, SSL.com)
|
||||||
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 only)
|
- `CERTCTL_ACME_EAB_HMAC` — External Account Binding HMAC key (base64url-encoded)
|
||||||
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only)
|
- `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default), `dns-01`, or `dns-persist-01`
|
||||||
|
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and dns-persist-01)
|
||||||
|
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01)
|
||||||
|
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` — CA issuer domain for persistent record (dns-persist-01 only, e.g., `letsencrypt.org`)
|
||||||
|
|
||||||
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
||||||
|
|
||||||
@@ -198,7 +288,7 @@ Script-based issuer connector for organizations with existing CA tooling. Delega
|
|||||||
| `CERTCTL_OPENSSL_CRL_SCRIPT` | No | Script that outputs DER-encoded CRL on stdout |
|
| `CERTCTL_OPENSSL_CRL_SCRIPT` | No | Script that outputs DER-encoded CRL on stdout |
|
||||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | No | Script execution timeout (default: 30s) |
|
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | No | Script execution timeout (default: 30s) |
|
||||||
|
|
||||||
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information.
|
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information. Before shell execution, serial numbers are validated as hex-only (`^[0-9a-fA-F]+$`) and revocation reason codes are validated against the RFC 5280 specification to prevent command injection.
|
||||||
|
|
||||||
### Revocation Across Issuers
|
### Revocation Across Issuers
|
||||||
|
|
||||||
@@ -227,7 +317,7 @@ Note: EST (Enrollment over Secure Transport) is not a connector — it's a proto
|
|||||||
The following issuer connectors are planned for future milestones:
|
The following issuer connectors are planned for future milestones:
|
||||||
|
|
||||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA (planned for V4.0+).
|
- **Vault PKI** — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA (planned for V4.0+).
|
||||||
- **DigiCert** — Commercial CA integration via DigiCert's REST API (planned for V3 paid release).
|
- **DigiCert** — Commercial CA integration via DigiCert's REST API (planned).
|
||||||
|
|
||||||
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
||||||
|
|
||||||
@@ -417,11 +507,51 @@ The combined PEM is built in this order: server certificate, intermediate/chain
|
|||||||
|
|
||||||
Location: `internal/connector/target/haproxy/haproxy.go`
|
Location: `internal/connector/target/haproxy/haproxy.go`
|
||||||
|
|
||||||
### V3 (Paid): F5 BIG-IP (Interface Only)
|
### Built-in: Traefik
|
||||||
|
|
||||||
The F5 BIG-IP target connector interface is built with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it. Implementation is planned for the paid V3 release.
|
The Traefik connector uses Traefik's file provider — it writes certificate and key files to a watched directory, and Traefik automatically picks up the changes without any explicit reload command. This is the simplest deployment model: write the files, and Traefik does the rest.
|
||||||
|
|
||||||
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status. Implementation is planned for a future release.
|
Configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cert_dir": "/etc/traefik/certs",
|
||||||
|
"cert_file": "site.crt",
|
||||||
|
"key_file": "site.key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cert_dir` is the directory Traefik is configured to watch via its file provider (e.g., `providers.file.directory` in Traefik's static config). The connector writes `cert_file` and `key_file` into this directory with appropriate permissions. Traefik's file watcher detects the change and reloads the TLS configuration automatically.
|
||||||
|
|
||||||
|
Location: `internal/connector/target/traefik/traefik.go`
|
||||||
|
|
||||||
|
### Built-in: Caddy
|
||||||
|
|
||||||
|
The Caddy connector supports two deployment modes — choose based on your Caddy setup:
|
||||||
|
|
||||||
|
**API mode (recommended):** Posts the certificate directly to Caddy's admin API (`POST /load` or certificate-specific endpoints) for zero-downtime hot reload. Requires Caddy's admin API to be enabled and accessible from the agent.
|
||||||
|
|
||||||
|
**File mode (fallback):** Writes cert and key files to disk, relying on Caddy's built-in file watcher or a manual reload. Use this when the admin API isn't available or when Caddy is configured to read certificates from disk.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "api",
|
||||||
|
"admin_api": "http://localhost:2019",
|
||||||
|
"cert_dir": "/etc/caddy/certs",
|
||||||
|
"cert_file": "site.crt",
|
||||||
|
"key_file": "site.key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `mode` is `"api"`, the connector posts the certificate to the admin API endpoint. When `mode` is `"file"`, it writes files to `cert_dir` (same pattern as Traefik). The `admin_api` field is ignored in file mode.
|
||||||
|
|
||||||
|
Location: `internal/connector/target/caddy/caddy.go`
|
||||||
|
|
||||||
|
### F5 BIG-IP (Interface Only)
|
||||||
|
|
||||||
|
The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it.
|
||||||
|
|
||||||
|
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status.
|
||||||
|
|
||||||
Configuration (defined, not yet functional):
|
Configuration (defined, not yet functional):
|
||||||
```json
|
```json
|
||||||
@@ -438,9 +568,9 @@ Note: F5 credentials are stored on the proxy agent, not on the control plane ser
|
|||||||
|
|
||||||
Location: `internal/connector/target/f5/f5.go`
|
Location: `internal/connector/target/f5/f5.go`
|
||||||
|
|
||||||
### V3 (Paid): IIS (Interface Only, Dual-Mode)
|
### IIS (Interface Only, Dual-Mode)
|
||||||
|
|
||||||
The IIS target connector supports two deployment modes planned for the paid V3 release:
|
The IIS target connector supports two planned deployment modes:
|
||||||
|
|
||||||
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
||||||
|
|
||||||
@@ -494,11 +624,69 @@ type Connector interface {
|
|||||||
|
|
||||||
Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incoming webhook), **Microsoft Teams** (MessageCard webhook), **PagerDuty** (Events API v2), and **OpsGenie** (Alert API v2).
|
Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incoming webhook), **Microsoft Teams** (MessageCard webhook), **PagerDuty** (Events API v2), and **OpsGenie** (Alert API v2).
|
||||||
|
|
||||||
|
### Email (SMTP) Notifier
|
||||||
|
|
||||||
|
The Email notifier sends transactional alerts and scheduled digests via SMTP. It bridges the connector-layer SMTP connector to the service-layer `Notifier` interface via the `NotifierAdapter`. Supports both plain text and HTML emails.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SMTP_HOST` | — | SMTP server hostname (required to enable) |
|
||||||
|
| `CERTCTL_SMTP_PORT` | 587 | SMTP port (TLS) |
|
||||||
|
| `CERTCTL_SMTP_USERNAME` | — | SMTP authentication username (optional) |
|
||||||
|
| `CERTCTL_SMTP_PASSWORD` | — | SMTP authentication password (optional) |
|
||||||
|
| `CERTCTL_SMTP_FROM_ADDRESS` | — | Email from address (required) |
|
||||||
|
| `CERTCTL_SMTP_USE_TLS` | true | Enable TLS encryption |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
export CERTCTL_SMTP_HOST=smtp.gmail.com
|
||||||
|
export CERTCTL_SMTP_PORT=587
|
||||||
|
export CERTCTL_SMTP_USERNAME=admin@example.com
|
||||||
|
export CERTCTL_SMTP_PASSWORD=app-password-123
|
||||||
|
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheduled Certificate Digest
|
||||||
|
|
||||||
|
The `DigestService` generates aggregated certificate digest emails and sends them on a configurable schedule. This is useful for periodic briefings on certificate inventory health — expiring certs, status summary, active agents, job trends.
|
||||||
|
|
||||||
|
The digest HTML template includes:
|
||||||
|
- Total certificates, expiring soon, expired, active agents (stats grid)
|
||||||
|
- Jobs completed/failed summary (30 days)
|
||||||
|
- Expiring certificates table (color-coded by urgency: 7d, 14d, 30d)
|
||||||
|
- Auto-refresh and responsive email layout
|
||||||
|
|
||||||
|
**Scheduler Integration:** The 7th scheduler loop runs on configurable interval (default 24 hours). It does NOT run on startup — waits for first scheduled tick. Operation timeout is 5 minutes. Each loop execution is guarded by `sync/atomic.Bool` idempotency.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_DIGEST_ENABLED` | false | Enable scheduled digest emails |
|
||||||
|
| `CERTCTL_DIGEST_INTERVAL` | 24h | How often to send digest (any duration, e.g. 12h, 7d) |
|
||||||
|
| `CERTCTL_DIGEST_RECIPIENTS` | — | Comma-separated email addresses. Falls back to certificate owner emails if empty |
|
||||||
|
|
||||||
|
API Endpoints:
|
||||||
|
|
||||||
|
- **`GET /api/v1/digest/preview`** — Render digest HTML for preview (no email sent)
|
||||||
|
- **`POST /api/v1/digest/send`** — Trigger digest send immediately (outside of schedule)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
# Preview digest
|
||||||
|
curl http://localhost:8443/api/v1/digest/preview | jq '.html'
|
||||||
|
|
||||||
|
# Send digest immediately
|
||||||
|
curl -X POST http://localhost:8443/api/v1/digest/send
|
||||||
|
```
|
||||||
|
|
||||||
Each notifier is enabled by its configuration env var:
|
Each notifier is enabled by its configuration env var:
|
||||||
|
|
||||||
| Notifier | Env Var | Description |
|
| Notifier | Env Var | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| Email | `CERTCTL_EMAIL_SMTP_HOST`, `CERTCTL_EMAIL_SMTP_PORT`, `CERTCTL_EMAIL_FROM` | SMTP email delivery. Optional: `CERTCTL_EMAIL_SMTP_USERNAME`, `CERTCTL_EMAIL_SMTP_PASSWORD` |
|
| Email | `CERTCTL_SMTP_HOST` | SMTP email delivery. See Email Notifier section above |
|
||||||
| Webhook | `CERTCTL_WEBHOOK_URL` | HTTP POST to any endpoint. Optional: `CERTCTL_WEBHOOK_SECRET` for HMAC signing |
|
| Webhook | `CERTCTL_WEBHOOK_URL` | HTTP POST to any endpoint. Optional: `CERTCTL_WEBHOOK_SECRET` for HMAC signing |
|
||||||
| Slack | `CERTCTL_SLACK_WEBHOOK_URL` | Incoming webhook URL. Optional: `CERTCTL_SLACK_CHANNEL`, `CERTCTL_SLACK_USERNAME` |
|
| Slack | `CERTCTL_SLACK_WEBHOOK_URL` | Incoming webhook URL. Optional: `CERTCTL_SLACK_CHANNEL`, `CERTCTL_SLACK_USERNAME` |
|
||||||
| Teams | `CERTCTL_TEAMS_WEBHOOK_URL` | Incoming webhook URL (MessageCard format) |
|
| Teams | `CERTCTL_TEAMS_WEBHOOK_URL` | Incoming webhook URL (MessageCard format) |
|
||||||
@@ -632,7 +820,7 @@ The agent scans these directories on startup and every 6 hours, looking for cert
|
|||||||
1. **Scan**: Agent recursively walks directories, extracts certificates
|
1. **Scan**: Agent recursively walks directories, extracts certificates
|
||||||
2. **Deduplicate**: Control plane deduplicates by SHA-256 fingerprint (same cert in multiple locations is one discovery)
|
2. **Deduplicate**: Control plane deduplicates by SHA-256 fingerprint (same cert in multiple locations is one discovery)
|
||||||
3. **Store**: Discovered certificates stored with metadata (agent ID, file path, found date, fingerprint)
|
3. **Store**: Discovered certificates stored with metadata (agent ID, file path, found date, fingerprint)
|
||||||
4. **Triage**: Operators query discovered certs via API, claim to link to managed certificates, or dismiss false positives
|
4. **Triage**: Operators review discovered certs in the **Discovery** dashboard page (or via API) — claim to link to managed certificates, or dismiss false positives. The dashboard shows summary stats, filters by status and agent, and provides one-click claim/dismiss actions.
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
@@ -680,10 +868,10 @@ export CERTCTL_NETWORK_SCAN_INTERVAL=6h # default
|
|||||||
|
|
||||||
### Creating Scan Targets
|
### Creating Scan Targets
|
||||||
|
|
||||||
Network scan targets define which CIDR ranges and ports to probe:
|
Network scan targets can be managed from the **Network Scans** dashboard page (create, edit, enable/disable, trigger on-demand scans) or via the API. Targets define which CIDR ranges and ports to probe:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a scan target for your internal network
|
# 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 -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
@@ -703,7 +891,7 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
|||||||
3. **Extract**: Certificate metadata extracted from TLS handshake (CN, SANs, serial, issuer, key info, fingerprint)
|
3. **Extract**: Certificate metadata extracted from TLS handshake (CN, SANs, serial, issuer, key info, fingerprint)
|
||||||
4. **Pipeline**: Results fed into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery
|
4. **Pipeline**: Results fed into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery
|
||||||
5. **Deduplicate**: Sentinel agent ID (`server-scanner`) with source_path as `ip:port` ensures proper dedup
|
5. **Deduplicate**: Sentinel agent ID (`server-scanner`) with source_path as `ip:port` ensures proper dedup
|
||||||
6. **Triage**: Discovered certs appear in `GET /api/v1/discovered-certificates` with `agent_id=server-scanner`
|
6. **Triage**: Discovered certs appear in the **Discovery** dashboard page (and via `GET /api/v1/discovered-certificates`) with `agent_id=server-scanner`
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,41 @@ This demo goes beyond browsing pre-loaded data. You'll create a team, register a
|
|||||||
**Time**: 15-20 minutes
|
**Time**: 15-20 minutes
|
||||||
**Prerequisites**: certctl running via Docker Compose (see [Quick Start](quickstart.md))
|
**Prerequisites**: certctl running via Docker Compose (see [Quick Start](quickstart.md))
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [Setup](#setup)
|
||||||
|
2. [How the pieces fit together](#how-the-pieces-fit-together)
|
||||||
|
3. [Alternative Issuers Reference](#alternative-issuers-reference)
|
||||||
|
- [Sub-CA Mode](#sub-ca-mode-local-ca-chained-to-enterprise-root)
|
||||||
|
- [ACME with ZeroSSL](#acme-with-zerossl-auto-eab)
|
||||||
|
- [ACME with DNS-01 Challenges](#acme-with-dns-01-challenges-wildcard-certificates)
|
||||||
|
- [ACME with DNS-PERSIST-01](#acme-with-dns-persist-01-zero-touch-renewals)
|
||||||
|
- [step-ca (Smallstep Private CA)](#step-ca-smallstep-private-ca)
|
||||||
|
- [OpenSSL / Custom CA](#openssl--custom-ca-script-based)
|
||||||
|
4. [Part 1: Build the Organization Structure](#part-1-build-the-organization-structure)
|
||||||
|
5. [Part 2: Verify the Issuer](#part-2-verify-the-issuer)
|
||||||
|
6. [Part 3: Create a Managed Certificate](#part-3-create-a-managed-certificate)
|
||||||
|
7. [Part 4: Trigger Certificate Renewal](#part-4-trigger-certificate-renewal)
|
||||||
|
8. [Part 4.5: Manage Deployment Targets](#part-45-manage-deployment-targets)
|
||||||
|
9. [Part 5: Deploy the Certificate](#part-5-deploy-the-certificate)
|
||||||
|
10. [Part 6: View the Audit Trail](#part-6-view-the-audit-trail-immutable-api-audit-log)
|
||||||
|
11. [Part 7: Check Notifications](#part-7-check-notifications)
|
||||||
|
12. [Part 8: Create a Second Certificate and Compare](#part-8-create-a-second-certificate-and-compare)
|
||||||
|
13. [Part 8.5: Revoke a Certificate](#part-85-revoke-a-certificate)
|
||||||
|
14. [Part 9: Policy Violations](#part-9-policy-violations)
|
||||||
|
15. [Part 9.5: Dashboard Stats and Metrics](#part-95-dashboard-stats-and-metrics)
|
||||||
|
16. [Part 10: Certificate Profiles](#part-10-certificate-profiles)
|
||||||
|
17. [Part 11: Agent Groups](#part-11-agent-groups)
|
||||||
|
18. [Part 12: Interactive Approval Workflow](#part-12-interactive-approval-workflow)
|
||||||
|
19. [Part 13: Advanced Query Features](#part-13-advanced-query-features)
|
||||||
|
20. [Part 14: CLI Tool](#part-14-cli-tool-m16b)
|
||||||
|
21. [Part 15: MCP Server for AI Integration](#part-15-mcp-server-for-ai-integration-m18a)
|
||||||
|
22. [Part 16: Certificate Discovery](#part-16-certificate-discovery-m18b--m21)
|
||||||
|
23. [End-to-End Architecture Summary](#end-to-end-architecture-summary)
|
||||||
|
24. [Full Automated Script](#full-automated-script)
|
||||||
|
25. [What to Show Stakeholders](#what-to-show-stakeholders)
|
||||||
|
26. [Teardown](#teardown)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Make sure certctl is running:
|
Make sure certctl is running:
|
||||||
@@ -62,6 +97,27 @@ docker compose -f deploy/docker-compose.yml restart server
|
|||||||
|
|
||||||
The CA key can be RSA, ECDSA, or PKCS#8 format. The connector validates that the certificate has `IsCA=true` and `KeyUsageCertSign`.
|
The CA key can be RSA, ECDSA, or PKCS#8 format. The connector validates that the certificate has `IsCA=true` and `KeyUsageCertSign`.
|
||||||
|
|
||||||
|
### ACME with ZeroSSL (Auto-EAB)
|
||||||
|
|
||||||
|
ZeroSSL is a free ACME CA that requires External Account Binding (EAB) for account registration. certctl auto-fetches EAB credentials from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — you just need an email address:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Minimal config — certctl auto-fetches EAB credentials from ZeroSSL
|
||||||
|
export CERTCTL_ACME_DIRECTORY_URL="https://acme.zerossl.com/v2/DV90"
|
||||||
|
export CERTCTL_ACME_EMAIL="ops@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
No dashboard visit, no manual EAB credential copy-paste. certctl calls `api.zerossl.com/acme/eab-credentials-email` with your email, gets back a KID + HMAC key, and uses them for ACME account registration automatically.
|
||||||
|
|
||||||
|
If you already have EAB credentials (e.g., from the ZeroSSL dashboard or for other CAs like Google Trust Services or SSL.com), you can provide them explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DIRECTORY_URL="https://acme.zerossl.com/v2/DV90"
|
||||||
|
export CERTCTL_ACME_EMAIL="ops@example.com"
|
||||||
|
export CERTCTL_ACME_EAB_KID="your-key-id"
|
||||||
|
export CERTCTL_ACME_EAB_HMAC="your-base64url-hmac-key"
|
||||||
|
```
|
||||||
|
|
||||||
### ACME with DNS-01 Challenges (Wildcard Certificates)
|
### ACME with DNS-01 Challenges (Wildcard Certificates)
|
||||||
|
|
||||||
For Let's Encrypt or other ACME providers with wildcard support:
|
For Let's Encrypt or other ACME providers with wildcard support:
|
||||||
@@ -97,6 +153,21 @@ curl -s -X POST $API/api/v1/certificates \
|
|||||||
}' | jq .
|
}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ACME with DNS-PERSIST-01 (Zero-Touch Renewals)
|
||||||
|
|
||||||
|
DNS-PERSIST-01 uses a standing `_validation-persist` TXT record that you set once. The CA revalidates it on every renewal — no per-renewal DNS updates, no cleanup scripts, no propagation waits. If the CA doesn't support DNS-PERSIST-01 yet, certctl falls back to DNS-01 automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure ACME DNS-PERSIST-01
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE="dns-persist-01"
|
||||||
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT="/usr/local/bin/dns-present.sh"
|
||||||
|
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN="letsencrypt.org"
|
||||||
|
|
||||||
|
# The present script creates a _validation-persist.<domain> TXT record with value:
|
||||||
|
# "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345"
|
||||||
|
# This record is set once and never touched again.
|
||||||
|
```
|
||||||
|
|
||||||
### step-ca (Smallstep Private CA)
|
### step-ca (Smallstep Private CA)
|
||||||
|
|
||||||
For organizations running step-ca as their private CA:
|
For organizations running step-ca as their private CA:
|
||||||
@@ -221,7 +292,7 @@ You should see:
|
|||||||
|
|
||||||
The result is a structurally valid X.509 certificate — browsers won't trust it (no root CA in their trust store), but it exercises the exact same code paths that a production ACME or Vault issuer would.
|
The result is a structurally valid X.509 certificate — browsers won't trust it (no root CA in their trust store), but it exercises the exact same code paths that a production ACME or Vault issuer would.
|
||||||
|
|
||||||
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). V2 adds the OpenSSL/Custom CA connector (script-based signing). DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3+.
|
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 + DNS-PERSIST-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). V2 adds the OpenSSL/Custom CA connector (script-based signing). DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3+.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -805,14 +876,14 @@ curl -s -X POST $API/api/v1/agent-groups \
|
|||||||
|
|
||||||
## Part 12: Interactive Approval Workflow
|
## Part 12: Interactive Approval Workflow
|
||||||
|
|
||||||
For high-value certificates, you may want human oversight before renewal proceeds. Create a policy that requires approval:
|
For high-value certificates, you may want human oversight before renewal proceeds. The demo includes 2 pre-seeded `AwaitingApproval` renewal jobs (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar — you'll see the amber "Pending Approval" banner and Approve/Reject buttons immediately.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check jobs that need approval
|
# Check jobs that need approval (demo includes 2)
|
||||||
curl -s "$API/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, type, certificate_id, status}'
|
curl -s "$API/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, type, certificate_id, status}'
|
||||||
```
|
```
|
||||||
|
|
||||||
If there are jobs awaiting approval, approve or reject them:
|
Approve or reject them:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Approve a job
|
# Approve a job
|
||||||
@@ -830,6 +901,8 @@ curl -s -X POST $API/api/v1/jobs/JOB_ID/reject \
|
|||||||
|
|
||||||
**Why interactive approval:** Not every certificate renewal should be automatic. PCI-scoped certificates, certs with specific compliance requirements, or certificates being migrated between issuers benefit from a human checkpoint. The AwaitingApproval state creates that checkpoint without blocking the entire job pipeline.
|
**Why interactive approval:** Not every certificate renewal should be automatic. PCI-scoped certificates, certs with specific compliance requirements, or certificates being migrated between issuers benefit from a human checkpoint. The AwaitingApproval state creates that checkpoint without blocking the entire job pipeline.
|
||||||
|
|
||||||
|
**In the dashboard:** Click "Jobs" in the sidebar, filter by status "AwaitingApproval", and you'll see a list of renewal jobs waiting for approval. Each job shows the certificate, issuer, and requested validity period. Click a job to open its detail view and see the Approve / Reject buttons with a reason text field. After approval or rejection, the job status updates in real-time and the audit trail records the decision.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part 13: Advanced Query Features
|
## Part 13: Advanced Query Features
|
||||||
@@ -956,6 +1029,8 @@ The MCP server is perfect for:
|
|||||||
|
|
||||||
certctl discovers existing certificates two ways: **filesystem scanning** (agents scan local directories) and **network scanning** (the server probes TLS endpoints). Both feed into the same triage pipeline.
|
certctl discovers existing certificates two ways: **filesystem scanning** (agents scan local directories) and **network scanning** (the server probes TLS endpoints). Both feed into the same triage pipeline.
|
||||||
|
|
||||||
|
**The demo comes pre-loaded with discovery data:** 9 discovered certificates (3 Unmanaged from filesystem scans, 3 Unmanaged from network scans, 2 Managed, 1 Dismissed), 3 discovery scans, and 3 network scan targets with recent scan results. Open **Discovery** in the sidebar to see the triage workflow immediately. The steps below show how to configure discovery from scratch.
|
||||||
|
|
||||||
### Filesystem Discovery (Agent-Side)
|
### Filesystem Discovery (Agent-Side)
|
||||||
|
|
||||||
Configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled:
|
Configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled:
|
||||||
@@ -976,7 +1051,7 @@ certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/cert
|
|||||||
|
|
||||||
### Network Discovery (Server-Side)
|
### Network Discovery (Server-Side)
|
||||||
|
|
||||||
The server can also discover certificates by actively probing TLS endpoints — no agent required. Create a scan target and trigger a scan:
|
The server can also discover certificates by actively probing TLS endpoints — no agent required. Network scanning is enabled by default in the Docker Compose demo (`CERTCTL_NETWORK_SCAN_ENABLED=true`), with 3 pre-configured scan targets. You can create additional targets:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a network scan target
|
# Create a network scan target
|
||||||
@@ -1030,6 +1105,28 @@ curl -s -X POST "$API/api/v1/discovered-certificates/$DISCOVERED_ID/dismiss" \
|
|||||||
|
|
||||||
**How it works:** Filesystem discovery: the agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, extracts metadata (common name, SANs, issuer, expiration, key type, fingerprint) from all PEM and DER files, and POSTs findings to `POST /api/v1/agents/{id}/discoveries`. Network discovery: the server expands CIDR ranges (capped at /20 = 4096 IPs), connects to each IP:port via TLS, extracts the peer certificate chain, and stores results using `server-scanner` as a sentinel agent ID. Both sources deduplicate by fingerprint and store results with a status: **Unmanaged** (discovered, not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage). This gives you a triage workflow: discover → review → claim or dismiss.
|
**How it works:** Filesystem discovery: the agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, extracts metadata (common name, SANs, issuer, expiration, key type, fingerprint) from all PEM and DER files, and POSTs findings to `POST /api/v1/agents/{id}/discoveries`. Network discovery: the server expands CIDR ranges (capped at /20 = 4096 IPs), connects to each IP:port via TLS, extracts the peer certificate chain, and stores results using `server-scanner` as a sentinel agent ID. Both sources deduplicate by fingerprint and store results with a status: **Unmanaged** (discovered, not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage). This gives you a triage workflow: discover → review → claim or dismiss.
|
||||||
|
|
||||||
|
### Discovery & Network Scans in the Dashboard
|
||||||
|
|
||||||
|
**Discovered Certificates Page:** Click "Discovery" in the sidebar to see a triage workflow. The page lists all discovered certificates grouped by status (Unmanaged, Managed, Dismissed). For each Unmanaged certificate, you see:
|
||||||
|
- Common name and SANs
|
||||||
|
- Issuer and subject DN
|
||||||
|
- Expiration date
|
||||||
|
- Fingerprint (helps dedup)
|
||||||
|
- Source (agent ID or `server-scanner` for network scans)
|
||||||
|
- Action buttons: Claim (manage this cert), Dismiss (ignore it)
|
||||||
|
|
||||||
|
Click "Claim" to bring an unmanaged certificate under certctl's control. Click "Dismiss" to remove it from the triage queue.
|
||||||
|
|
||||||
|
**Network Scans Page:** Click "Network Scans" in the sidebar to manage network scan targets. The page shows all configured scan targets with:
|
||||||
|
- Target name and description
|
||||||
|
- CIDR ranges and ports scanned
|
||||||
|
- Enabled/disabled toggle
|
||||||
|
- Scan interval and connection timeout
|
||||||
|
- Last scan timestamp and result summary
|
||||||
|
- Action buttons: Edit, Delete, Scan Now (immediate)
|
||||||
|
|
||||||
|
Click "Scan Now" to trigger an immediate TLS probe of the target's IP ranges. Results appear within seconds in the Discovered Certificates page as entries with `agent_id=server-scanner`.
|
||||||
|
|
||||||
**In the dashboard**, click "Discovered Certificates" in the sidebar to see what agents and network scans found — claim unmanaged certs to bring them under certctl's management, or dismiss them.
|
**In the dashboard**, click "Discovered Certificates" in the sidebar to see what agents and network scans found — claim unmanaged certs to bring them under certctl's management, or dismiss them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Complete reference of all features shipped in the V2 release (as of March 2026).
|
|||||||
## API Surface
|
## API Surface
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
- **95 endpoints** across 20 resource domains under `/api/v1/` + `/.well-known/est/`
|
- **99 endpoints** across 23 resource domains under `/api/v1/` + `/.well-known/est/`
|
||||||
- REST API with HTTP semantics (GET, POST, PUT, DELETE)
|
- REST API with HTTP semantics (GET, POST, PUT, DELETE)
|
||||||
- All endpoints require authentication by default (configurable)
|
- All endpoints require authentication by default (configurable)
|
||||||
- OpenAPI 3.1 spec with full schema documentation
|
- OpenAPI 3.1 spec with full schema documentation
|
||||||
@@ -43,8 +43,9 @@ Protects the control plane from being overwhelmed by a single client — whether
|
|||||||
|
|
||||||
Required for the web dashboard to communicate with the API when served from a different origin (e.g., during development on `localhost:3000` while the API runs on `localhost:8443`). Without CORS headers, browsers block the requests silently.
|
Required for the web dashboard to communicate with the API when served from a different origin (e.g., during development on `localhost:3000` while the API runs on `localhost:8443`). Without CORS headers, browsers block the requests silently.
|
||||||
|
|
||||||
- **Configurable Per-Origin Allowlist** — `CERTCTL_CORS_ORIGINS` (comma-separated or wildcard)
|
- **Deny-by-Default** — Empty `CERTCTL_CORS_ORIGINS` blocks all cross-origin requests (secure default)
|
||||||
- **Preflight Caching** — Standard CORS headers
|
- **Configurable Per-Origin Allowlist** — `CERTCTL_CORS_ORIGINS` (comma-separated or `*` for wildcard)
|
||||||
|
- **Preflight Caching** — Standard CORS headers with `Access-Control-Max-Age`
|
||||||
|
|
||||||
### Query Features (M20)
|
### Query Features (M20)
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z
|
|||||||
|
|
||||||
| Domain | Endpoints | Key Operations |
|
| Domain | Endpoints | Key Operations |
|
||||||
|--------|-----------|-----------------|
|
|--------|-----------|-----------------|
|
||||||
| **Certificates** | 11 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke |
|
| **Certificates** | 13 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke, export (PEM/PKCS#12) |
|
||||||
| **CRL & OCSP** | 3 | JSON CRL, DER CRL per issuer, OCSP responder |
|
| **CRL & OCSP** | 3 | JSON CRL, DER CRL per issuer, OCSP responder |
|
||||||
| **Issuers** | 6 | List, create, get, update, delete, test connection |
|
| **Issuers** | 6 | List, create, get, update, delete, test connection |
|
||||||
| **Targets** | 5 | List, create, get, update, delete |
|
| **Targets** | 5 | List, create, get, update, delete |
|
||||||
@@ -94,6 +95,8 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z
|
|||||||
| **Notifications** | 3 | List, get, mark as read |
|
| **Notifications** | 3 | List, get, mark as read |
|
||||||
| **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate |
|
| **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate |
|
||||||
| **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format |
|
| **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format |
|
||||||
|
| **Verification** | 2 | Submit verification result, get verification status |
|
||||||
|
| **Digest** | 2 | Preview HTML digest, send digest immediately |
|
||||||
| **EST (RFC 7030)** | 4 | CA certs (PKCS#7), simple enrollment, re-enrollment, CSR attributes |
|
| **EST (RFC 7030)** | 4 | CA certs (PKCS#7), simple enrollment, re-enrollment, CSR attributes |
|
||||||
| **Health** | 4 | Health check, readiness check, auth info, auth check |
|
| **Health** | 4 | Health check, readiness check, auth info, auth check |
|
||||||
|
|
||||||
@@ -144,6 +147,32 @@ curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/certificates/mc-api-prod/deploy
|
|||||||
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/deployments" | jq '.data[] | {id, name, type}'
|
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/deployments" | jq '.data[] | {id, name, type}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Post-Deployment TLS Verification (M25)
|
||||||
|
|
||||||
|
After deploying a certificate, the agent connects back to the target's live TLS endpoint and verifies the served certificate matches what was deployed — using SHA-256 fingerprint comparison. This catches failures that deployment commands can't: wrong virtual host, stale cache, config that validates but doesn't apply.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Agent submits verification result after probing the live endpoint
|
||||||
|
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/jobs/j-deploy-123/verify -d '{
|
||||||
|
"target_id": "tgt-nginx-prod",
|
||||||
|
"expected_fingerprint": "sha256:a1b2c3...",
|
||||||
|
"actual_fingerprint": "sha256:a1b2c3...",
|
||||||
|
"verified": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Check verification status for a job
|
||||||
|
curl -H "$AUTH" $SERVER/api/v1/jobs/j-deploy-123/verification | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feature | Details |
|
||||||
|
|---------|---------|
|
||||||
|
| **Verification Method** | `crypto/tls.DialWithDialer` with `InsecureSkipVerify=true` to handle self-signed and internal CA certs |
|
||||||
|
| **Fingerprint Comparison** | SHA-256 of raw certificate DER bytes |
|
||||||
|
| **Best-Effort** | Verification failures are recorded but don't block or rollback deployments |
|
||||||
|
| **Job Fields** | `verification_status` (pending/success/failed/skipped), `verified_at`, `verification_fingerprint`, `verification_error` |
|
||||||
|
| **Audit Trail** | `job_verification_success` and `job_verification_failed` events recorded |
|
||||||
|
| **Configuration** | `CERTCTL_VERIFY_DEPLOYMENT` (enable/disable), `CERTCTL_VERIFY_TIMEOUT` (TLS dial timeout), `CERTCTL_VERIFY_DELAY` (wait after deploy before probing) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Revocation Infrastructure
|
## Revocation Infrastructure
|
||||||
@@ -190,34 +219,86 @@ curl $SERVER/api/v1/ocsp/iss-local/ABC123DEF456
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Certificate Export
|
||||||
|
|
||||||
|
Operators need to export certificates for use in third-party systems or for compliance audits. certctl provides two export formats: PEM (cert + chain, JSON or file download) and PKCS#12 (cert + chain in a passwordless bundle for compatibility with systems like Java keystores and Windows certificate stores).
|
||||||
|
|
||||||
|
**Important:** Private keys are never exported — they remain on agents where they were generated. This is a core security property. Exports only bundle the public certificate material (cert + chain).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export as PEM (returns JSON with base64-encoded data + chain)
|
||||||
|
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pem"
|
||||||
|
# {"certificate_pem":"-----BEGIN CERTIFICATE-----\n...", "chain_pem":"-----BEGIN CERTIFICATE-----\n..."}
|
||||||
|
|
||||||
|
# Export as PKCS#12 file (binary download, no password)
|
||||||
|
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pkcs12" > cert.p12
|
||||||
|
|
||||||
|
# Via CLI
|
||||||
|
certctl-cli certs export mc-api-prod --format pem --out cert.pem
|
||||||
|
certctl-cli certs export mc-api-prod --format pkcs12 --out cert.p12
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Details |
|
||||||
|
|-------|---------|
|
||||||
|
| **Formats** | PEM (text, cert + chain), PKCS#12 (binary, cert + chain, passwordless) |
|
||||||
|
| **Private Key Inclusion** | Never — private keys remain on agents |
|
||||||
|
| **Audit Trail** | All exports recorded with actor, timestamp, export format |
|
||||||
|
| **API Endpoints** | `GET /api/v1/certificates/{id}/export/pem`, `POST /api/v1/certificates/{id}/export/pkcs12` |
|
||||||
|
| **GUI** | Export PEM and Export PKCS#12 buttons on certificate detail page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Certificate Profiles
|
## Certificate Profiles
|
||||||
|
|
||||||
### Profile Model
|
### Profile Model
|
||||||
Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth EKU only."
|
Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth and clientAuth EKUs only."
|
||||||
|
|
||||||
|
Profiles also support **Extended Key Usage (EKU)** constraints, enabling S/MIME and device certificates. Common EKUs:
|
||||||
|
- `serverAuth` — TLS server certificates (HTTPS, mail servers)
|
||||||
|
- `clientAuth` — TLS client certificates (mutual TLS, device auth)
|
||||||
|
- `emailProtection` — S/MIME signing and encryption
|
||||||
|
- `codeSigning` — Code signing and software updates
|
||||||
|
- `timeStamping` — Trusted timestamps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a profile enforcing short-lived certs with ECDSA keys
|
# Create a TLS profile
|
||||||
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||||
"name": "Short-Lived Service Mesh",
|
"name": "Standard TLS",
|
||||||
"allowed_key_algorithms": ["ECDSA"],
|
"allowed_key_algorithms": ["ECDSA"],
|
||||||
"max_ttl_hours": 1,
|
"max_ttl_hours": 2160,
|
||||||
|
"allowed_ekus": ["serverAuth"]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Create an S/MIME profile
|
||||||
|
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||||
|
"name": "S/MIME Email",
|
||||||
|
"allowed_key_algorithms": ["RSA", "ECDSA"],
|
||||||
|
"max_ttl_hours": 8760,
|
||||||
|
"allowed_ekus": ["emailProtection"]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Create a multi-purpose profile
|
||||||
|
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||||
|
"name": "Multi-Purpose",
|
||||||
|
"allowed_key_algorithms": ["ECDSA"],
|
||||||
|
"max_ttl_hours": 2160,
|
||||||
"allowed_ekus": ["serverAuth", "clientAuth"]
|
"allowed_ekus": ["serverAuth", "clientAuth"]
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# Assign profile to a certificate
|
# Assign profile to a certificate
|
||||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/certificates/mc-api-prod -d '{
|
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/certificates/mc-api-prod -d '{
|
||||||
"profile_id": "prof-short-lived"
|
"profile_id": "prof-standard-tls"
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# List all profiles
|
# List all profiles
|
||||||
curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms}'
|
curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms, allowed_ekus}'
|
||||||
|
|
||||||
# Get profile details
|
# Get profile details
|
||||||
curl -H "$AUTH" "$SERVER/api/v1/profiles/prof-standard-tls" | jq .
|
curl -H "$AUTH" "$SERVER/api/v1/profiles/prof-standard-tls" | jq .
|
||||||
|
|
||||||
# Update profile constraints
|
# Update profile constraints
|
||||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
||||||
"name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"]
|
"name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"], "allowed_ekus": ["serverAuth"]
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -227,14 +308,22 @@ curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
|||||||
| **Name** | Human-readable profile name |
|
| **Name** | Human-readable profile name |
|
||||||
| **Allowed Key Algorithms** | RSA, ECDSA, Ed25519 with minimum key sizes (e.g., RSA 2048+, ECDSA P-256+) |
|
| **Allowed Key Algorithms** | RSA, ECDSA, Ed25519 with minimum key sizes (e.g., RSA 2048+, ECDSA P-256+) |
|
||||||
| **Max TTL** | Maximum certificate lifetime (days or duration) |
|
| **Max TTL** | Maximum certificate lifetime (days or duration) |
|
||||||
| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, etc.) |
|
| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, emailProtection, codeSigning, timeStamping) |
|
||||||
| **Required SANs** | Mandatory Subject Alternative Names (patterns or fixed values) |
|
| **Required SANs** | Mandatory Subject Alternative Names (patterns or fixed values) |
|
||||||
| **Short-Lived Support** | TTL < 1 hour triggers CRL/OCSP exemption |
|
| **Short-Lived Support** | TTL < 1 hour triggers CRL/OCSP exemption |
|
||||||
|
|
||||||
### GUI Management
|
### GUI Management
|
||||||
- Full CRUD page with profile details
|
- Full CRUD page with profile details
|
||||||
- Crypto constraint badges visible in list view
|
- EKU constraint badges visible in list view (serverAuth, clientAuth, emailProtection, etc.)
|
||||||
- Profile assignment dropdown on certificate detail
|
- Profile assignment dropdown on certificate detail
|
||||||
|
- S/MIME profile creation wizard with email SAN configuration
|
||||||
|
|
||||||
|
### S/MIME Support
|
||||||
|
When a profile specifies `emailProtection` EKU, certctl adapts the issuance flow for email certificates:
|
||||||
|
- **SAN handling** — email addresses in SANs are formatted as `rfc822Name` (not DNS names)
|
||||||
|
- **Key usage** — S/MIME certs use `DigitalSignature | ContentCommitment` instead of the TLS default `DigitalSignature | KeyEncipherment`
|
||||||
|
- **Agent CSR generation** — agents correctly distinguish DNS SANs from email SANs based on profile EKU
|
||||||
|
- **Issuer constraints** — Local CA and other issuers thread EKUs through the signing pipeline
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -288,9 +377,10 @@ curl -H "$AUTH" "$SERVER/api/v1/policies/rp-standard/violations"
|
|||||||
- **Use Case** — Internal PKI, enterprise trust chains
|
- **Use Case** — Internal PKI, enterprise trust chains
|
||||||
|
|
||||||
### ACME v2
|
### ACME v2
|
||||||
- **Challenge Types** — HTTP-01 (default) and DNS-01 (wildcard support)
|
- **Challenge Types** — HTTP-01 (default), DNS-01 (wildcard support), and DNS-PERSIST-01 (standing record, no per-renewal DNS updates)
|
||||||
- **DNS-01 Script Hooks** — Pluggable DNS solver for any provider (Cloudflare, Route53, Azure DNS, etc.)
|
- **DNS-01 Script Hooks** — Pluggable DNS solver for any provider (Cloudflare, Route53, Azure DNS, etc.)
|
||||||
- **Configuration** — `CERTCTL_ACME_DIRECTORY_URL`, `CERTCTL_ACME_EMAIL`, `CERTCTL_ACME_CHALLENGE_TYPE=dns-01`, `CERTCTL_ACME_DNS_PRESENT_SCRIPT`, `CERTCTL_ACME_DNS_CLEANUP_SCRIPT`
|
- **DNS-PERSIST-01** — Standing `_validation-persist` TXT record set once, reused forever. Auto-fallback to DNS-01 if CA doesn't support it yet.
|
||||||
|
- **Configuration** — `CERTCTL_ACME_DIRECTORY_URL`, `CERTCTL_ACME_EMAIL`, `CERTCTL_ACME_CHALLENGE_TYPE`, `CERTCTL_ACME_DNS_PRESENT_SCRIPT`, `CERTCTL_ACME_DNS_CLEANUP_SCRIPT`, `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN`
|
||||||
- **DNS Propagation Wait** — Configurable timeout before validation
|
- **DNS Propagation Wait** — Configurable timeout before validation
|
||||||
- **Use Case** — Public CAs (LetsEncrypt), wildcard certs
|
- **Use Case** — Public CAs (LetsEncrypt), wildcard certs
|
||||||
|
|
||||||
@@ -310,7 +400,7 @@ curl -H "$AUTH" "$SERVER/api/v1/policies/rp-standard/violations"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Target Connectors (3 Implemented + 2 Stubs)
|
## Target Connectors (5 Implemented + 2 Stubs)
|
||||||
|
|
||||||
### NGINX
|
### NGINX
|
||||||
- **Deployment** — Separate cert, chain, and key files
|
- **Deployment** — Separate cert, chain, and key files
|
||||||
@@ -333,6 +423,19 @@ curl -H "$AUTH" "$SERVER/api/v1/policies/rp-standard/violations"
|
|||||||
- **Target Config** — Combined PEM path, optional reload command
|
- **Target Config** — Combined PEM path, optional reload command
|
||||||
- **Status** — Fully implemented (M10)
|
- **Status** — Fully implemented (M10)
|
||||||
|
|
||||||
|
### Traefik
|
||||||
|
- **Deployment** — File provider: writes cert and key to Traefik's watched certificate directory
|
||||||
|
- **Auto-Reload** — Traefik's file provider watches the directory for changes; no explicit reload needed
|
||||||
|
- **Target Config** — Certificate directory, cert filename, key filename
|
||||||
|
- **Status** — Fully implemented (M26)
|
||||||
|
|
||||||
|
### Caddy
|
||||||
|
- **Dual-Mode Deployment** — Admin API (hot-reload via `POST /load`) or file-based (write cert+key, Caddy watches)
|
||||||
|
- **API Mode** — Posts certificate to Caddy's admin API endpoint for zero-downtime reload
|
||||||
|
- **File Mode** — Writes cert and key files to configured directory (fallback when admin API is unavailable)
|
||||||
|
- **Target Config** — Admin API URL, certificate directory, cert filename, key filename, mode (api/file)
|
||||||
|
- **Status** — Fully implemented (M26)
|
||||||
|
|
||||||
### F5 BIG-IP (Stub)
|
### F5 BIG-IP (Stub)
|
||||||
- **Protocol** — iControl REST API via proxy agent
|
- **Protocol** — iControl REST API via proxy agent
|
||||||
- **Status** — Interface only in V2; implementation in V3 (paid)
|
- **Status** — Interface only in V2; implementation in V3 (paid)
|
||||||
@@ -411,6 +514,148 @@ export CERTCTL_PAGERDUTY_SEVERITY="critical"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ACME Renewal Information (ARI, RFC 9702)
|
||||||
|
|
||||||
|
Instead of using fixed renewal thresholds (renew 30 days before expiry), ACME ARI lets the CA tell certctl exactly when to renew. This is useful for distributing renewal load across maintenance windows and coordinating mass-revocation scenarios.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable ARI on your ACME issuer
|
||||||
|
export CERTCTL_ACME_ARI_ENABLED=true
|
||||||
|
|
||||||
|
# Certificates now query the ARI endpoint for suggested renewal windows
|
||||||
|
# If the CA doesn't support ARI (404), certctl falls back to threshold-based renewal
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Details |
|
||||||
|
|-------|---------|
|
||||||
|
| **Protocol** | ACME Renewal Information (RFC 9702) |
|
||||||
|
| **Cert ID Computation** | base64url(SHA-256(DER cert)) |
|
||||||
|
| **Suggested Window** | Start and end times provided by CA |
|
||||||
|
| **Renewal Timing** — If current time is after window start, renew immediately. Otherwise, wait until start time. |
|
||||||
|
| **Fallback** | 404 from ARI endpoint triggers automatic fallback to threshold-based renewal |
|
||||||
|
| **Configuration** | `CERTCTL_ACME_ARI_ENABLED=true` on ACME issuer config |
|
||||||
|
| **Supported CAs** | Let's Encrypt (v2.1.0+), Sectigo, others gradually adopting |
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- **Load Distribution** — CA specifies renewal window to avoid thundering herd spikes
|
||||||
|
- **Coordination** — Support for mass revocation scenarios where CA controls timing
|
||||||
|
- **No Over-Renewal** — Avoid unnecessary early renewals that waste your CA's capacity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduled Certificate Digest Emails
|
||||||
|
|
||||||
|
Scheduled HTML digest emails with certificate stats, expiration timeline, job health, and agent fleet overview. Useful for daily ops briefings and compliance reporting.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure SMTP
|
||||||
|
export CERTCTL_SMTP_HOST=smtp.example.com
|
||||||
|
export CERTCTL_SMTP_PORT=587
|
||||||
|
export CERTCTL_SMTP_USERNAME=admin@example.com
|
||||||
|
export CERTCTL_SMTP_PASSWORD=your-app-password
|
||||||
|
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
|
||||||
|
|
||||||
|
# Enable digest
|
||||||
|
export CERTCTL_DIGEST_ENABLED=true
|
||||||
|
export CERTCTL_DIGEST_INTERVAL=24h
|
||||||
|
export CERTCTL_DIGEST_RECIPIENTS=ops@example.com,security@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feature | Details |
|
||||||
|
|---------|---------|
|
||||||
|
| **Scheduler Loop** | 7th background loop, default 24-hour interval (configurable: 12h, 7d, etc.) |
|
||||||
|
| **Startup Behavior** | Does NOT run on startup; waits for first scheduled tick |
|
||||||
|
| **Operation Timeout** | 5 minutes per digest generation + send |
|
||||||
|
| **Idempotency** — `sync/atomic.Bool` guard prevents concurrent digest executions |
|
||||||
|
| **HTML Template** | Responsive email with stats grid (total, expiring, expired, agents), jobs summary (30-day), expiring certs table with color-coded urgency (7/14/30 days) |
|
||||||
|
| **Recipients** | Comma-separated email addresses. Falls back to certificate owner emails if none configured. |
|
||||||
|
| **API Endpoints** — `GET /api/v1/digest/preview` (HTML preview), `POST /api/v1/digest/send` (trigger immediately) |
|
||||||
|
| **Configuration** — `CERTCTL_DIGEST_ENABLED`, `CERTCTL_DIGEST_INTERVAL` (default 24h), `CERTCTL_DIGEST_RECIPIENTS` |
|
||||||
|
|
||||||
|
**Digest Contents:**
|
||||||
|
|
||||||
|
- **Certificate Stats** — Total, active, expiring soon, expired, revoked
|
||||||
|
- **Job Health** — Completed, failed (last 30 days)
|
||||||
|
- **Agent Fleet** — Total agents online, offline, version distribution
|
||||||
|
- **Expiring Certificates** — Table with CN, SANs, days remaining, owner, status badges
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
|
||||||
|
- Daily ops briefing for certificate inventory health
|
||||||
|
- Compliance reporting (audit trail + digest archive)
|
||||||
|
- Stakeholder visibility (automated newsletter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Helm Chart for Kubernetes
|
||||||
|
|
||||||
|
Production-ready Helm chart for Kubernetes deployments with secure defaults and comprehensive configurability.
|
||||||
|
|
||||||
|
### Chart Components
|
||||||
|
|
||||||
|
| Component | Details |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Server Deployment** | Configurable replicas (default 2), liveness/readiness probes, security context (non-root, read-only rootfs), resource limits, graceful shutdown |
|
||||||
|
| **PostgreSQL StatefulSet** | Primary + replica, persistent volumes with configurable storage class/size (default 10Gi), automatic backup (via init container or sidecarsynchronous |
|
||||||
|
| **Agent DaemonSet** | One agent per infrastructure node, key storage volume (agent_keys), server discovery via internal DNS |
|
||||||
|
| **ConfigMap** | Issuer, target, and scheduler configuration; all certctl env vars exposed |
|
||||||
|
| **Secret** — API key, database password, SMTP credentials (base64-encoded) |
|
||||||
|
| **Ingress** — Optional with TLS, configurable hostname and certificate (via cert-manager or manual) |
|
||||||
|
| **ServiceAccount** — RBAC with configurable annotations for Kubernetes audit logging |
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with custom values
|
||||||
|
helm install certctl deploy/helm/certctl/ \
|
||||||
|
--namespace certctl --create-namespace \
|
||||||
|
--set server.auth.apiKey="your-secure-key" \
|
||||||
|
--set postgresql.auth.password="your-db-password" \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.hosts[0].host="certctl.example.com" \
|
||||||
|
--set ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Values
|
||||||
|
|
||||||
|
| Value | Default | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `server.replicaCount` | 2 | Number of server replicas |
|
||||||
|
| `server.auth.apiKey` | — | (required) API key for authentication |
|
||||||
|
| `postgresql.auth.password` | — | (required) PostgreSQL password |
|
||||||
|
| `postgresql.storage.size` | 10Gi | Database volume size |
|
||||||
|
| `ingress.enabled` | false | Enable Ingress for public access |
|
||||||
|
| `ingress.hosts[0].host` | certctl.example.com | Primary hostname |
|
||||||
|
| `ingress.tls.enabled` | true | TLS on Ingress (requires cert-manager) |
|
||||||
|
| `agent.enabled` | true | Deploy agent DaemonSet |
|
||||||
|
| `smtp.enabled` | false | Enable SMTP for digest emails |
|
||||||
|
| `smtp.host` | — | SMTP server hostname |
|
||||||
|
|
||||||
|
### Security Defaults
|
||||||
|
|
||||||
|
- **Non-root containers** — Server and agent run as unprivileged user
|
||||||
|
- **Read-only filesystem** — Root filesystem mounted read-only (except /tmp)
|
||||||
|
- **Network policies** — Optional KubernetesNetworkPolicy to restrict traffic
|
||||||
|
- **Secrets** — API keys and passwords stored in K8s Secrets, never in ConfigMaps or environment defaults
|
||||||
|
- **RBAC** — ServiceAccount with minimal required permissions
|
||||||
|
|
||||||
|
### Upgrade Path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upgrade to a new certctl release
|
||||||
|
helm upgrade certctl deploy/helm/certctl/ \
|
||||||
|
--namespace certctl \
|
||||||
|
-f my-values.yaml
|
||||||
|
|
||||||
|
# Rollback if needed
|
||||||
|
helm rollback certctl [REVISION]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Agent Fleet
|
## Agent Fleet
|
||||||
|
|
||||||
Agents are lightweight Go binaries deployed on your servers that handle the last mile — generating private keys locally, submitting CSRs, and deploying signed certificates to web servers. The control plane never touches private keys or initiates outbound connections, keeping your security perimeter intact.
|
Agents are lightweight Go binaries deployed on your servers that handle the last mile — generating private keys locally, submitting CSRs, and deploying signed certificates to web servers. The control plane never touches private keys or initiates outbound connections, keeping your security perimeter intact.
|
||||||
@@ -479,7 +724,7 @@ curl -H "$AUTH" "$SERVER/api/v1/agent-groups/ag-linux-dc1/members" | jq '.items[
|
|||||||
### Agent Capabilities
|
### Agent Capabilities
|
||||||
Agents report to `/api/v1/agents/{id}/work` with supported target types and issuers.
|
Agents report to `/api/v1/agents/{id}/work` with supported target types and issuers.
|
||||||
|
|
||||||
- **Target Deployment** — NGINX, Apache httpd, HAProxy, F5 BIG-IP (proxy), IIS (proxy)
|
- **Target Deployment** — NGINX, Apache httpd, HAProxy, Traefik, Caddy, F5 BIG-IP (proxy), IIS (proxy)
|
||||||
- **Key Management** — ECDSA P-256 keygen, key storage at `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`), 0600 file permissions
|
- **Key Management** — ECDSA P-256 keygen, key storage at `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`), 0600 file permissions
|
||||||
- **CSR Submission** — `POST /api/v1/agents/{id}/csr` for AwaitingCSR jobs
|
- **CSR Submission** — `POST /api/v1/agents/{id}/csr` for AwaitingCSR jobs
|
||||||
|
|
||||||
@@ -797,7 +1042,8 @@ curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/jobs/j-abc123/approve -d '{"reas
|
|||||||
5. **CSR received** → Server signs; Job transitioned to `Running`
|
5. **CSR received** → Server signs; Job transitioned to `Running`
|
||||||
6. **Deployment scheduled** → New Deployment job created in `Pending`
|
6. **Deployment scheduled** → New Deployment job created in `Pending`
|
||||||
7. **Agent deploys** → Deployment job → `Running` → `Completed`
|
7. **Agent deploys** → Deployment job → `Running` → `Completed`
|
||||||
8. **Status reported** → `POST /api/v1/agents/{id}/jobs/{job_id}/status`
|
8. **Post-deployment verification** → Agent probes live TLS endpoint, compares SHA-256 fingerprint
|
||||||
|
9. **Status reported** → `POST /api/v1/agents/{id}/jobs/{job_id}/status`
|
||||||
|
|
||||||
### Approval Flow (Interactive)
|
### Approval Flow (Interactive)
|
||||||
1. **Renewal job created** in `AwaitingApproval` state (if policy requires)
|
1. **Renewal job created** in `AwaitingApproval` state (if policy requires)
|
||||||
@@ -866,7 +1112,7 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
- **Save/Cancel** — API mutations with optimistic updates via TanStack Query
|
- **Save/Cancel** — API mutations with optimistic updates via TanStack Query
|
||||||
|
|
||||||
#### Target Configuration Wizard
|
#### Target Configuration Wizard
|
||||||
- **Step 1: Select Type** — Radio or dropdown (NGINX, Apache, HAProxy, F5, IIS)
|
- **Step 1: Select Type** — Radio or dropdown (NGINX, Apache, HAProxy, Traefik, Caddy, F5, IIS)
|
||||||
- **Step 2: Configure** — Type-specific fields (cert path, chain path, key path, etc.)
|
- **Step 2: Configure** — Type-specific fields (cert path, chain path, key path, etc.)
|
||||||
- **Step 3: Review** — Summary of config; confirm create
|
- **Step 3: Review** — Summary of config; confirm create
|
||||||
- **Validation** — Real-time field validation; show errors; disable Create if invalid
|
- **Validation** — Real-time field validation; show errors; disable Create if invalid
|
||||||
@@ -957,7 +1203,7 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
|
|
||||||
### OpenAPI 3.1 Specification
|
### OpenAPI 3.1 Specification
|
||||||
- **File** — `api/openapi.yaml`
|
- **File** — `api/openapi.yaml`
|
||||||
- **Scope** — 97 operations (95 API + /health + /ready), all request/response schemas, enums, pagination
|
- **Scope** — 99 operations (97 API + /health + /ready), all request/response schemas, enums, pagination
|
||||||
- **Schemas** — Complete domain models with examples
|
- **Schemas** — Complete domain models with examples
|
||||||
- **Enums** — Job types, states, policy rule types, notification types
|
- **Enums** — Job types, states, policy rule types, notification types
|
||||||
- **Pagination** — Standard envelope (data, total, page, per_page)
|
- **Pagination** — Standard envelope (data, total, page, per_page)
|
||||||
@@ -1035,8 +1281,8 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
- **GitHub Actions** — `.github/workflows/ci.yml`
|
- **GitHub Actions** — `.github/workflows/ci.yml`
|
||||||
- **Parallel Jobs** — Go (build, vet, test+coverage, gates) and Frontend (tsc, vitest, vite build)
|
- **Parallel Jobs** — Go (build, vet, test+coverage, gates) and Frontend (tsc, vitest, vite build)
|
||||||
- **Coverage Gates** — Service layer ≥30%, handler layer ≥50%
|
- **Coverage Gates** — Service layer ≥30%, handler layer ≥50%
|
||||||
- **Release Workflow** — Tag push → build → publish Docker images to `ghcr.io`
|
- **Release Workflow** — Tag push → build → publish Docker images to GitHub Container Registry
|
||||||
- **Docker Tags** — `:latest`, `:v{version}` (ghcr.io/shankar0123/certctl)
|
- **Docker Tags** — `:latest`, `:v{version}` (`shankar0123.docker.scarf.sh/certctl-server`, `shankar0123.docker.scarf.sh/certctl-agent`)
|
||||||
|
|
||||||
### Test Suite
|
### Test Suite
|
||||||
- **Unit Tests** — 625+ test functions across service, handler, middleware, domain layers
|
- **Unit Tests** — 625+ test functions across service, handler, middleware, domain layers
|
||||||
@@ -1117,9 +1363,10 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
|----------|------|---------|---------|
|
|----------|------|---------|---------|
|
||||||
| `CERTCTL_ACME_DIRECTORY_URL` | string | (empty) | ACME server directory URL |
|
| `CERTCTL_ACME_DIRECTORY_URL` | string | (empty) | ACME server directory URL |
|
||||||
| `CERTCTL_ACME_EMAIL` | string | (empty) | Account email for ACME registration |
|
| `CERTCTL_ACME_EMAIL` | string | (empty) | Account email for ACME registration |
|
||||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | string | http-01 | http-01 or dns-01 |
|
| `CERTCTL_ACME_CHALLENGE_TYPE` | string | http-01 | http-01, dns-01, or dns-persist-01 |
|
||||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | string | (empty) | Script path for DNS-01 present hook |
|
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | string | (empty) | Script path for DNS present hook (dns-01 and dns-persist-01) |
|
||||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | string | (empty) | Script path for DNS-01 cleanup hook |
|
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | string | (empty) | Script path for DNS cleanup hook (dns-01 only) |
|
||||||
|
| `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` | string | (empty) | CA issuer domain for dns-persist-01 (e.g., letsencrypt.org) |
|
||||||
|
|
||||||
#### step-ca Issuer
|
#### step-ca Issuer
|
||||||
| Variable | Type | Default | Purpose |
|
| Variable | Type | Default | Purpose |
|
||||||
|
|||||||
@@ -6,6 +6,30 @@ This guide gets you running in 5 minutes and walks you through everything certct
|
|||||||
|
|
||||||
New to certificates? Read the [Concepts Guide](concepts.md) first — it explains TLS, CAs, and private keys in plain language.
|
New to certificates? Read the [Concepts Guide](concepts.md) first — it explains TLS, CAs, and private keys in plain language.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [Start Everything](#start-everything)
|
||||||
|
3. [Open the Dashboard](#open-the-dashboard)
|
||||||
|
4. [Explore the API](#explore-the-api)
|
||||||
|
- [Core operations](#core-operations)
|
||||||
|
- [Sorting, filtering, and pagination](#sorting-filtering-and-pagination)
|
||||||
|
- [Stats and metrics](#stats-and-metrics)
|
||||||
|
5. [Create Your First Certificate](#create-your-first-certificate)
|
||||||
|
- [Revoke a certificate](#revoke-a-certificate)
|
||||||
|
- [Interactive approval workflow](#interactive-approval-workflow)
|
||||||
|
6. [Certificate Discovery](#certificate-discovery)
|
||||||
|
- [Filesystem discovery (agent-based)](#filesystem-discovery-agent-based)
|
||||||
|
- [Network discovery (agentless)](#network-discovery-agentless)
|
||||||
|
- [Triage discovered certificates](#triage-discovered-certificates)
|
||||||
|
7. [CLI Tool](#cli-tool)
|
||||||
|
8. [MCP Server (AI Integration)](#mcp-server-ai-integration)
|
||||||
|
9. [Demo Data Reference](#demo-data-reference)
|
||||||
|
10. [Dashboard Demo Mode](#dashboard-demo-mode)
|
||||||
|
11. [Presenting to Stakeholders](#presenting-to-stakeholders)
|
||||||
|
12. [Tear Down](#tear-down)
|
||||||
|
13. [What's Next](#whats-next)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
You need **Docker** and **Docker Compose** installed. That's it.
|
You need **Docker** and **Docker Compose** installed. That's it.
|
||||||
@@ -19,6 +43,8 @@ On Linux, follow the official Docker install guide for your distribution.
|
|||||||
|
|
||||||
## Start Everything
|
## Start Everything
|
||||||
|
|
||||||
|
### Docker Compose (Quick Start)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/shankar0123/certctl.git
|
git clone https://github.com/shankar0123/certctl.git
|
||||||
cd certctl
|
cd certctl
|
||||||
@@ -34,6 +60,22 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Kubernetes with Helm
|
||||||
|
|
||||||
|
For production deployments on Kubernetes, use the Helm chart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install certctl deploy/helm/certctl/ \
|
||||||
|
--create-namespace --namespace certctl \
|
||||||
|
--set server.auth.apiKey="your-secure-api-key" \
|
||||||
|
--set postgresql.auth.password="your-db-password" \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.hosts[0].host="certctl.example.com" \
|
||||||
|
--set ingress.hosts[0].tls=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The chart includes: server Deployment (with configurable replicas, health probes, security context), PostgreSQL StatefulSet with persistent volumes, agent DaemonSet (one agent per infrastructure node), optional Ingress with TLS, and ServiceAccount with RBAC. All certctl configuration options are exposed in `values.yaml` — customize issuer settings, target connectors, scheduler intervals, and notifier credentials there.
|
||||||
|
|
||||||
Wait about 30 seconds for PostgreSQL to initialize, then verify:
|
Wait about 30 seconds for PostgreSQL to initialize, then verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -59,13 +101,17 @@ curl http://localhost:8443/health
|
|||||||
|
|
||||||
Open **http://localhost:8443** in your browser.
|
Open **http://localhost:8443** in your browser.
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
>
|
||||||
|
> **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key.
|
||||||
|
|
||||||
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — expiring certs, expired certs, active certs, failed renewals. A realistic snapshot of what certificate management looks like in a real organization.
|
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — expiring certs, expired certs, active certs, failed renewals. A realistic snapshot of what certificate management looks like in a real organization.
|
||||||
|
|
||||||
### What you're looking at
|
### What you're looking at
|
||||||
|
|
||||||
The main dashboard shows total certificates, how many are expiring soon, how many have expired, the renewal success rate, and four charts: an **expiration heatmap** (90-day weekly buckets), **renewal success rate trends** (30-day line chart), **certificate status distribution** (donut chart), and **issuance rate** (30-day bar chart).
|
The main dashboard shows total certificates, how many are expiring soon, how many have expired, the renewal success rate, and four charts: an **expiration heatmap** (90-day weekly buckets), **renewal success rate trends** (30-day line chart), **certificate status distribution** (donut chart), and **issuance rate** (30-day bar chart).
|
||||||
|
|
||||||
Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifications, Profiles, Teams, Owners, Agent Groups, Fleet Overview, Short-Lived Credentials, Discovery.
|
Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifications, Profiles, Teams, Owners, Agent Groups, Fleet Overview, Short-Lived Credentials, Discovery, and Network Scans.
|
||||||
|
|
||||||
### Scenarios to walk through
|
### Scenarios to walk through
|
||||||
|
|
||||||
@@ -77,7 +123,9 @@ Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifica
|
|||||||
|
|
||||||
**"Can I revoke a compromised cert?"** — Click any active certificate, then "Revoke." A modal with RFC 5280 reason codes (Key Compromise, Superseded, Cessation of Operation). After revocation, CRL and OCSP are served automatically — clients stop trusting the cert immediately.
|
**"Can I revoke a compromised cert?"** — Click any active certificate, then "Revoke." A modal with RFC 5280 reason codes (Key Compromise, Superseded, Cessation of Operation). After revocation, CRL and OCSP are served automatically — clients stop trusting the cert immediately.
|
||||||
|
|
||||||
**"What about certificates already in production?"** — Click "Discovered Certificates." Agents scan local filesystems for existing certs. The server probes TLS endpoints on configured CIDR ranges. Both feed into a triage workflow: claim unmanaged certs to bring them under automation, or dismiss them.
|
**"What about certificates already in production?"** — Click "Discovery" in the sidebar. The demo comes pre-loaded with 9 discovered certificates — some found by agents scanning filesystems, some found by the server probing TLS endpoints on the network. You'll see Unmanaged certs waiting for triage (including an expired printer cert and an expiring switch management cert), certs already linked to managed inventory, and one that was dismissed. Claim unmanaged certs to bring them under automation, or dismiss them. Click "Network Scans" to see the 3 configured scan targets with recent scan results.
|
||||||
|
|
||||||
|
**"I need to approve a renewal before it proceeds"** — Click "Jobs" in the sidebar. You'll see an amber banner: "2 jobs awaiting approval." These are renewal jobs for `auth-production` and `payments-production` that require human sign-off before proceeding. Click Approve or Reject with a reason — the decision is recorded in the audit trail.
|
||||||
|
|
||||||
**"Show me the agent fleet"** — Click "Agents." Four agents online, one offline. Click "Fleet Overview" for OS/architecture grouping, version distribution, and per-platform listing. Agents generate ECDSA P-256 keys locally — private keys never leave your infrastructure.
|
**"Show me the agent fleet"** — Click "Agents." Four agents online, one offline. Click "Fleet Overview" for OS/architecture grouping, version distribution, and per-platform listing. Agents generate ECDSA P-256 keys locally — private keys never leave your infrastructure.
|
||||||
|
|
||||||
@@ -230,9 +278,12 @@ curl -s http://localhost:8443/api/v1/crl | jq .
|
|||||||
|
|
||||||
### Interactive approval workflow
|
### Interactive approval workflow
|
||||||
|
|
||||||
For high-value certificates where you want human oversight:
|
For high-value certificates where you want human oversight. The demo includes 2 pre-seeded jobs in `AwaitingApproval` status (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar and you'll see the amber "Pending Approval" banner immediately.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# List jobs awaiting approval (demo includes 2)
|
||||||
|
curl -s "http://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 -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -248,6 +299,8 @@ curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \
|
|||||||
|
|
||||||
Find certificates already running in your infrastructure — ones you didn't issue through certctl.
|
Find certificates already running in your infrastructure — ones you didn't issue through certctl.
|
||||||
|
|
||||||
|
The demo environment comes pre-loaded with 9 discovered certificates (from agent filesystem scans and server-side network scans), 3 network scan targets, and recent scan history. Open **Discovery** and **Network Scans** in the sidebar to see the triage workflow immediately.
|
||||||
|
|
||||||
### Filesystem discovery (agent-based)
|
### Filesystem discovery (agent-based)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -311,6 +364,35 @@ export CERTCTL_API_KEY="test-key-123"
|
|||||||
./certctl-cli status # Health + stats
|
./certctl-cli status # Health + stats
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Scheduled Certificate Digest Emails
|
||||||
|
|
||||||
|
Enable automatic HTML digest emails with certificate stats, expiration timeline, and job health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set SMTP configuration
|
||||||
|
export CERTCTL_SMTP_HOST=smtp.gmail.com
|
||||||
|
export CERTCTL_SMTP_PORT=587
|
||||||
|
export CERTCTL_SMTP_USERNAME=admin@example.com
|
||||||
|
export CERTCTL_SMTP_PASSWORD=your-app-password
|
||||||
|
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
|
||||||
|
export CERTCTL_SMTP_USE_TLS=true
|
||||||
|
|
||||||
|
# Enable digest and set recipients
|
||||||
|
export CERTCTL_DIGEST_ENABLED=true
|
||||||
|
export CERTCTL_DIGEST_INTERVAL=24h
|
||||||
|
export CERTCTL_DIGEST_RECIPIENTS=ops@example.com,security@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview the digest HTML before enabling scheduled delivery:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8443/api/v1/digest/preview | jq '.html' | grep -o '<html>' # Shows HTML is ready
|
||||||
|
|
||||||
|
# Trigger a digest send immediately (outside of schedule)
|
||||||
|
curl -X POST http://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).
|
||||||
|
|
||||||
## MCP Server (AI Integration)
|
## MCP Server (AI Integration)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -331,11 +413,15 @@ Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "Wha
|
|||||||
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
||||||
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
||||||
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
|
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
|
||||||
| Agents | 5 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod |
|
| Agents | 6 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod, server-scanner (network discovery) |
|
||||||
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
|
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
|
||||||
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
|
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
|
||||||
|
| Discovered Certs | 9 | 5 Unmanaged (filesystem + network), 2 Managed (linked), 1 Dismissed, network-discovered expired printer cert |
|
||||||
|
| Discovery Scans | 3 | Agent filesystem scans + network TLS scan |
|
||||||
|
| Network Scan Targets | 3 | DC1 Web Servers, DC2 Application Tier, DMZ Public Endpoints |
|
||||||
|
| Jobs (Approval) | 2 | AwaitingApproval renewal jobs for auth-prod and payments-prod |
|
||||||
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
||||||
| Profiles | 3 | Default TLS, Short-Lived, High-Security |
|
| Profiles | 4 | Standard TLS, Internal mTLS, Short-Lived, High Security |
|
||||||
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
||||||
|
|
||||||
## Dashboard Demo Mode
|
## Dashboard Demo Mode
|
||||||
|
|||||||
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,82 @@
|
|||||||
|
# Why certctl?
|
||||||
|
|
||||||
|
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi."
|
||||||
|
|
||||||
|
If you run a personal blog, Certbot works fine. If your company spends $200K/year on Keyfactor, you're covered. But if you're an ops engineer managing 20-500 certificates across NGINX, Apache, HAProxy, and maybe a private CA — the tools available today either don't do enough or cost too much.
|
||||||
|
|
||||||
|
certctl fills that gap.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: 200 days as of March 2026, 100 days by March 2027, and 47 days by March 2029. That means every organization needs automated certificate renewal — not eventually, but now.
|
||||||
|
|
||||||
|
The existing options for automation are:
|
||||||
|
|
||||||
|
- **ACME clients** (Certbot, Lego, CertWarden): Handle issuance and renewal for ACME-compatible CAs, but don't manage deployment to target servers, don't provide inventory visibility, don't support non-ACME CAs, and don't offer audit trails or policy enforcement.
|
||||||
|
- **Kubernetes-native** (cert-manager): Works well inside Kubernetes, but if your infrastructure includes bare-metal servers, VMs, or network appliances alongside Kubernetes, you need a separate solution for everything cert-manager can't reach.
|
||||||
|
- **Commercial SaaS** (CertKit, Sectigo CLM): Handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate — costs scale linearly with your infrastructure.
|
||||||
|
- **Enterprise platforms** (Venafi, Keyfactor, AppViewX): Comprehensive but start at $75K/year and require dedicated teams to operate.
|
||||||
|
|
||||||
|
## What certctl Does Differently
|
||||||
|
|
||||||
|
certctl is a self-hosted certificate lifecycle platform. It handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
||||||
|
|
||||||
|
### 1. Private Keys Never Leave Your Infrastructure
|
||||||
|
|
||||||
|
certctl agents generate private keys locally using ECDSA P-256. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions.
|
||||||
|
|
||||||
|
This isn't a premium feature — it's the default behavior in the free tier. Most competitors either generate keys server-side (creating a single point of compromise) or gate key isolation behind paid tiers.
|
||||||
|
|
||||||
|
### 2. CA-Agnostic Issuer Architecture
|
||||||
|
|
||||||
|
certctl works with any certificate authority, not just ACME providers:
|
||||||
|
|
||||||
|
- **ACME** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01 and DNS-01 challenges, DNS-PERSIST-01 for zero-touch renewals, External Account Binding
|
||||||
|
- **step-ca** (Smallstep) — native /sign API with JWK provisioner authentication
|
||||||
|
- **Local CA** — self-signed or sub-CA mode (chain to your enterprise root CA, e.g. ADCS)
|
||||||
|
- **OpenSSL / Custom CA** — delegate signing to any shell script with configurable timeout
|
||||||
|
- **EST enrollment** (RFC 7030) — device certificate enrollment for WiFi/802.1X, MDM, and IoT
|
||||||
|
|
||||||
|
Every issuer connector implements the same interface. Switching CAs or running multiple CAs in parallel requires zero code changes — just configuration.
|
||||||
|
|
||||||
|
### 3. Post-Deployment Verification
|
||||||
|
|
||||||
|
Every other tool in this space stops at "the deployment command succeeded." certctl goes further: after deploying a certificate to a target, the agent connects back to the target's TLS endpoint and verifies the served certificate matches what was deployed, using SHA-256 fingerprint comparison.
|
||||||
|
|
||||||
|
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl catches this.
|
||||||
|
|
||||||
|
## How certctl Compares
|
||||||
|
|
||||||
|
### vs. CertKit
|
||||||
|
|
||||||
|
Closest competitor architecturally — agent-based, private key isolation (Keystore), multi-platform. certctl leads on issuer coverage (ACME + step-ca + Local CA + OpenSSL + EST vs. ACME-only), PKI compliance (CRL, OCSP, RFC 5280 revocation, immutable audit trail — all missing from CertKit today), policy engine (5 rule types vs. none), and network discovery (CIDR TLS scanning vs. none). certctl is source-available (BSL 1.1 → Apache 2.0) with no cert limit; CertKit is proprietary SaaS with a 3-cert free tier. Where CertKit leads: more deployment targets today (adds LiteSpeed, IIS, auto-detection), Windows support, Kubernetes, and polished SaaS onboarding.
|
||||||
|
|
||||||
|
### vs. KeyTalk
|
||||||
|
|
||||||
|
Commercial (proprietary) PKI platform from a Dutch company — on-prem appliance, cloud, or managed service. Broader cert type coverage (TLS, S/MIME, device auth, VPN) and DigiCert + SCEP integrations. No public documentation on policy engine, API surface, or audit capabilities. No free tier, no public pricing. certctl trades breadth of cert types for full transparency — source-available, public API spec, free community edition with no limits.
|
||||||
|
|
||||||
|
### vs. Enterprise Platforms (Venafi, Keyfactor)
|
||||||
|
|
||||||
|
Comprehensive solutions with decades of features — at $75K-$250K+/yr. certctl targets organizations that need 80% of those capabilities at 1% of the cost. The trade-off: no SSO/RBAC yet (coming in certctl Pro), no F5/IIS target connectors yet, no SLA-backed support.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and start with Docker Compose (includes demo data)
|
||||||
|
git clone https://github.com/shankar0123/certctl.git
|
||||||
|
cd certctl/deploy
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Open the dashboard
|
||||||
|
open http://localhost:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
The demo seeds 15 certificates, 5 agents, 5 deployment targets, discovery data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
||||||
|
|
||||||
|
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
certctl is licensed under the [Business Source License 1.1](../LICENSE). The licensed work is free to use for any purpose other than offering a competing managed service. The license converts to Apache 2.0 on March 1, 2033.
|
||||||
|
|
||||||
|
The source is available, auditable, and self-hostable. You own your data, your keys, and your deployment.
|
||||||
@@ -6,15 +6,62 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
|
github.com/testcontainers/testcontainers-go v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/crypto v0.31.0
|
require golang.org/x/crypto v0.31.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
dario.cat/mergo v1.0.0 // indirect
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||||
|
github.com/containerd/containerd v1.7.18 // indirect
|
||||||
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
github.com/docker/docker v27.1.1+incompatible // indirect
|
||||||
|
github.com/docker/go-connections v0.5.0 // indirect
|
||||||
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.1 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
|
github.com/moby/sys/sequential v0.5.0 // indirect
|
||||||
|
github.com/moby/sys/user v0.1.0 // indirect
|
||||||
|
github.com/moby/term v0.5.0 // indirect
|
||||||
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/stretchr/testify v1.9.0 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||||
golang.org/x/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
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,214 @@
|
|||||||
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
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/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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
|
||||||
|
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/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
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/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/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/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
||||||
|
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
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/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
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/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.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/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/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/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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
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/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
|
||||||
|
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
|
||||||
|
github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
|
||||||
|
github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
|
||||||
|
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/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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
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/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
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/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=
|
||||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
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/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/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/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
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.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||||
|
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/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||||
|
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
||||||
|
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
||||||
|
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/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||||
|
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
golang.org/x/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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||||
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.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-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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
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/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/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
|
||||||
|
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
||||||
|
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
||||||
|
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||||
|
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||||
|
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||||
|
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=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,28 +22,28 @@ type MockAgentGroupService struct {
|
|||||||
ListMembersFn func(id string) ([]domain.Agent, int64, error)
|
ListMembersFn func(id string) ([]domain.Agent, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentGroupService) ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) {
|
func (m *MockAgentGroupService) ListAgentGroups(_ context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) {
|
||||||
if m.ListAgentGroupsFn != nil {
|
if m.ListAgentGroupsFn != nil {
|
||||||
return m.ListAgentGroupsFn(page, perPage)
|
return m.ListAgentGroupsFn(page, perPage)
|
||||||
}
|
}
|
||||||
return []domain.AgentGroup{}, 0, nil
|
return []domain.AgentGroup{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentGroupService) GetAgentGroup(id string) (*domain.AgentGroup, error) {
|
func (m *MockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*domain.AgentGroup, error) {
|
||||||
if m.GetAgentGroupFn != nil {
|
if m.GetAgentGroupFn != nil {
|
||||||
return m.GetAgentGroupFn(id)
|
return m.GetAgentGroupFn(id)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("not found")
|
return nil, fmt.Errorf("not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentGroupService) CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) {
|
func (m *MockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||||
if m.CreateAgentGroupFn != nil {
|
if m.CreateAgentGroupFn != nil {
|
||||||
return m.CreateAgentGroupFn(group)
|
return m.CreateAgentGroupFn(group)
|
||||||
}
|
}
|
||||||
return &group, nil
|
return &group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentGroupService) UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
func (m *MockAgentGroupService) UpdateAgentGroup(_ context.Context, id string, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||||
if m.UpdateAgentGroupFn != nil {
|
if m.UpdateAgentGroupFn != nil {
|
||||||
return m.UpdateAgentGroupFn(id, group)
|
return m.UpdateAgentGroupFn(id, group)
|
||||||
}
|
}
|
||||||
@@ -50,14 +51,14 @@ func (m *MockAgentGroupService) UpdateAgentGroup(id string, group domain.AgentGr
|
|||||||
return &group, nil
|
return &group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentGroupService) DeleteAgentGroup(id string) error {
|
func (m *MockAgentGroupService) DeleteAgentGroup(_ context.Context, id string) error {
|
||||||
if m.DeleteAgentGroupFn != nil {
|
if m.DeleteAgentGroupFn != nil {
|
||||||
return m.DeleteAgentGroupFn(id)
|
return m.DeleteAgentGroupFn(id)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) {
|
func (m *MockAgentGroupService) ListMembers(_ context.Context, id string) ([]domain.Agent, int64, error) {
|
||||||
if m.ListMembersFn != nil {
|
if m.ListMembersFn != nil {
|
||||||
return m.ListMembersFn(id)
|
return m.ListMembersFn(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -12,12 +13,12 @@ import (
|
|||||||
|
|
||||||
// AgentGroupService defines the service interface for agent group operations.
|
// AgentGroupService defines the service interface for agent group operations.
|
||||||
type AgentGroupService interface {
|
type AgentGroupService interface {
|
||||||
ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error)
|
ListAgentGroups(ctx context.Context, page, perPage int) ([]domain.AgentGroup, int64, error)
|
||||||
GetAgentGroup(id string) (*domain.AgentGroup, error)
|
GetAgentGroup(ctx context.Context, id string) (*domain.AgentGroup, error)
|
||||||
CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error)
|
CreateAgentGroup(ctx context.Context, group domain.AgentGroup) (*domain.AgentGroup, error)
|
||||||
UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error)
|
UpdateAgentGroup(ctx context.Context, id string, group domain.AgentGroup) (*domain.AgentGroup, error)
|
||||||
DeleteAgentGroup(id string) error
|
DeleteAgentGroup(ctx context.Context, id string) error
|
||||||
ListMembers(id string) ([]domain.Agent, int64, error)
|
ListMembers(ctx context.Context, id string) ([]domain.Agent, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentGroupHandler handles HTTP requests for agent group operations.
|
// AgentGroupHandler handles HTTP requests for agent group operations.
|
||||||
@@ -54,7 +55,7 @@ func (h AgentGroupHandler) ListAgentGroups(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups, total, err := h.svc.ListAgentGroups(page, perPage)
|
groups, total, err := h.svc.ListAgentGroups(r.Context(), page, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agent groups", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agent groups", requestID)
|
||||||
return
|
return
|
||||||
@@ -86,7 +87,7 @@ func (h AgentGroupHandler) GetAgentGroup(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := h.svc.GetAgentGroup(id)
|
group, err := h.svc.GetAgentGroup(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||||
return
|
return
|
||||||
@@ -120,7 +121,7 @@ func (h AgentGroupHandler) CreateAgentGroup(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateAgentGroup(group)
|
created, err := h.svc.CreateAgentGroup(r.Context(), group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") {
|
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") {
|
||||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
@@ -157,7 +158,7 @@ func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := h.svc.UpdateAgentGroup(id, group)
|
updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||||
@@ -186,7 +187,7 @@ func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.DeleteAgentGroup(id); err != nil {
|
if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||||
return
|
return
|
||||||
@@ -217,7 +218,7 @@ func (h AgentGroupHandler) ListAgentGroupMembers(w http.ResponseWriter, r *http.
|
|||||||
}
|
}
|
||||||
id := parts[0]
|
id := parts[0]
|
||||||
|
|
||||||
members, total, err := h.svc.ListMembers(id)
|
members, total, err := h.svc.ListMembers(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list group members", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list group members", requestID)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -25,70 +26,70 @@ type MockAgentService struct {
|
|||||||
UpdateJobStatusFn func(agentID string, jobID string, status string, errMsg string) error
|
UpdateJobStatusFn func(agentID string, jobID string, status string, errMsg string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) ListAgents(page, perPage int) ([]domain.Agent, int64, error) {
|
func (m *MockAgentService) ListAgents(_ context.Context, page, perPage int) ([]domain.Agent, int64, error) {
|
||||||
if m.ListAgentsFn != nil {
|
if m.ListAgentsFn != nil {
|
||||||
return m.ListAgentsFn(page, perPage)
|
return m.ListAgentsFn(page, perPage)
|
||||||
}
|
}
|
||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) GetAgent(id string) (*domain.Agent, error) {
|
func (m *MockAgentService) GetAgent(_ context.Context, id string) (*domain.Agent, error) {
|
||||||
if m.GetAgentFn != nil {
|
if m.GetAgentFn != nil {
|
||||||
return m.GetAgentFn(id)
|
return m.GetAgentFn(id)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) RegisterAgent(agent domain.Agent) (*domain.Agent, error) {
|
func (m *MockAgentService) RegisterAgent(_ context.Context, agent domain.Agent) (*domain.Agent, error) {
|
||||||
if m.RegisterAgentFn != nil {
|
if m.RegisterAgentFn != nil {
|
||||||
return m.RegisterAgentFn(agent)
|
return m.RegisterAgentFn(agent)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) Heartbeat(agentID string, metadata *domain.AgentMetadata) error {
|
func (m *MockAgentService) Heartbeat(_ context.Context, agentID string, metadata *domain.AgentMetadata) error {
|
||||||
if m.HeartbeatFn != nil {
|
if m.HeartbeatFn != nil {
|
||||||
return m.HeartbeatFn(agentID, metadata)
|
return m.HeartbeatFn(agentID, metadata)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) CSRSubmit(agentID string, csrPEM string) (string, error) {
|
func (m *MockAgentService) CSRSubmit(_ context.Context, agentID string, csrPEM string) (string, error) {
|
||||||
if m.CSRSubmitFn != nil {
|
if m.CSRSubmitFn != nil {
|
||||||
return m.CSRSubmitFn(agentID, csrPEM)
|
return m.CSRSubmitFn(agentID, csrPEM)
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) CSRSubmitForCert(agentID string, certID string, csrPEM string) (string, error) {
|
func (m *MockAgentService) CSRSubmitForCert(_ context.Context, agentID string, certID string, csrPEM string) (string, error) {
|
||||||
if m.CSRSubmitForCertFn != nil {
|
if m.CSRSubmitForCertFn != nil {
|
||||||
return m.CSRSubmitForCertFn(agentID, certID, csrPEM)
|
return m.CSRSubmitForCertFn(agentID, certID, csrPEM)
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) CertificatePickup(agentID, certID string) (string, error) {
|
func (m *MockAgentService) CertificatePickup(_ context.Context, agentID, certID string) (string, error) {
|
||||||
if m.CertificatePickupFn != nil {
|
if m.CertificatePickupFn != nil {
|
||||||
return m.CertificatePickupFn(agentID, certID)
|
return m.CertificatePickupFn(agentID, certID)
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) GetWork(agentID string) ([]domain.Job, error) {
|
func (m *MockAgentService) GetWork(_ context.Context, agentID string) ([]domain.Job, error) {
|
||||||
if m.GetWorkFn != nil {
|
if m.GetWorkFn != nil {
|
||||||
return m.GetWorkFn(agentID)
|
return m.GetWorkFn(agentID)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) GetWorkWithTargets(agentID string) ([]domain.WorkItem, error) {
|
func (m *MockAgentService) GetWorkWithTargets(_ context.Context, agentID string) ([]domain.WorkItem, error) {
|
||||||
if m.GetWorkWithTargetsFn != nil {
|
if m.GetWorkWithTargetsFn != nil {
|
||||||
return m.GetWorkWithTargetsFn(agentID)
|
return m.GetWorkWithTargetsFn(agentID)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentService) UpdateJobStatus(agentID string, jobID string, status string, errMsg string) error {
|
func (m *MockAgentService) UpdateJobStatus(_ context.Context, agentID string, jobID string, status string, errMsg string) error {
|
||||||
if m.UpdateJobStatusFn != nil {
|
if m.UpdateJobStatusFn != nil {
|
||||||
return m.UpdateJobStatusFn(agentID, jobID, status, errMsg)
|
return m.UpdateJobStatusFn(agentID, jobID, status, errMsg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -12,16 +13,16 @@ import (
|
|||||||
|
|
||||||
// AgentService defines the service interface for agent operations.
|
// AgentService defines the service interface for agent operations.
|
||||||
type AgentService interface {
|
type AgentService interface {
|
||||||
ListAgents(page, perPage int) ([]domain.Agent, int64, error)
|
ListAgents(ctx context.Context, page, perPage int) ([]domain.Agent, int64, error)
|
||||||
GetAgent(id string) (*domain.Agent, error)
|
GetAgent(ctx context.Context, id string) (*domain.Agent, error)
|
||||||
RegisterAgent(agent domain.Agent) (*domain.Agent, error)
|
RegisterAgent(ctx context.Context, agent domain.Agent) (*domain.Agent, error)
|
||||||
Heartbeat(agentID string, metadata *domain.AgentMetadata) error
|
Heartbeat(ctx context.Context, agentID string, metadata *domain.AgentMetadata) error
|
||||||
CSRSubmit(agentID string, csrPEM string) (string, error)
|
CSRSubmit(ctx context.Context, agentID string, csrPEM string) (string, error)
|
||||||
CSRSubmitForCert(agentID string, certID string, csrPEM string) (string, error)
|
CSRSubmitForCert(ctx context.Context, agentID string, certID string, csrPEM string) (string, error)
|
||||||
CertificatePickup(agentID, certID string) (string, error)
|
CertificatePickup(ctx context.Context, agentID, certID string) (string, error)
|
||||||
GetWork(agentID string) ([]domain.Job, error)
|
GetWork(ctx context.Context, agentID string) ([]domain.Job, error)
|
||||||
GetWorkWithTargets(agentID string) ([]domain.WorkItem, error)
|
GetWorkWithTargets(ctx context.Context, agentID string) ([]domain.WorkItem, error)
|
||||||
UpdateJobStatus(agentID string, jobID string, status string, errMsg string) error
|
UpdateJobStatus(ctx context.Context, agentID string, jobID string, status string, errMsg string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentHandler handles HTTP requests for agent operations.
|
// AgentHandler handles HTTP requests for agent operations.
|
||||||
@@ -58,7 +59,7 @@ func (h AgentHandler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
agents, total, err := h.svc.ListAgents(page, perPage)
|
agents, total, err := h.svc.ListAgents(r.Context(), page, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agents", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agents", requestID)
|
||||||
return
|
return
|
||||||
@@ -92,7 +93,7 @@ func (h AgentHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id = parts[0]
|
id = parts[0]
|
||||||
|
|
||||||
agent, err := h.svc.GetAgent(id)
|
agent, err := h.svc.GetAgent(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||||
return
|
return
|
||||||
@@ -131,7 +132,7 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := h.svc.RegisterAgent(agent)
|
created, err := h.svc.RegisterAgent(r.Context(), agent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||||
return
|
return
|
||||||
@@ -182,7 +183,7 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.Heartbeat(agentID, metadata); err != nil {
|
if err := h.svc.Heartbeat(r.Context(), agentID, metadata); err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -234,9 +235,9 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// If certificate_id is provided, sign the CSR for that specific certificate
|
// If certificate_id is provided, sign the CSR for that specific certificate
|
||||||
if req.CertificateID != "" {
|
if req.CertificateID != "" {
|
||||||
status, err = h.svc.CSRSubmitForCert(agentID, req.CertificateID, req.CSRPEM)
|
status, err = h.svc.CSRSubmitForCert(r.Context(), agentID, req.CertificateID, req.CSRPEM)
|
||||||
} else {
|
} else {
|
||||||
status, err = h.svc.CSRSubmit(agentID, req.CSRPEM)
|
status, err = h.svc.CSRSubmit(r.Context(), agentID, req.CSRPEM)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -271,7 +272,7 @@ func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Requ
|
|||||||
agentID := parts[0]
|
agentID := parts[0]
|
||||||
certID := parts[2]
|
certID := parts[2]
|
||||||
|
|
||||||
certPEM, err := h.svc.CertificatePickup(agentID, certID)
|
certPEM, err := h.svc.CertificatePickup(r.Context(), agentID, certID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found or not ready", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found or not ready", requestID)
|
||||||
return
|
return
|
||||||
@@ -303,7 +304,7 @@ func (h AgentHandler) AgentGetWork(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
agentID := parts[0]
|
agentID := parts[0]
|
||||||
|
|
||||||
workItems, err := h.svc.GetWorkWithTargets(agentID)
|
workItems, err := h.svc.GetWorkWithTargets(r.Context(), agentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get pending work", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get pending work", requestID)
|
||||||
return
|
return
|
||||||
@@ -353,7 +354,7 @@ func (h AgentHandler) AgentReportJobStatus(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.UpdateJobStatus(agentID, jobID, req.Status, req.Error); err != nil {
|
if err := h.svc.UpdateJobStatus(r.Context(), agentID, jobID, req.Status, req.Error); err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update job status", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update job status", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DigestServicer defines the interface for digest operations used by the handler.
|
||||||
|
type DigestServicer interface {
|
||||||
|
PreviewDigest(ctx context.Context) (string, error)
|
||||||
|
SendDigest(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigestHandler provides HTTP endpoints for certificate digest operations.
|
||||||
|
type DigestHandler struct {
|
||||||
|
service DigestServicer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDigestHandler creates a new digest handler.
|
||||||
|
func NewDigestHandler(service DigestServicer) *DigestHandler {
|
||||||
|
return &DigestHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewDigest renders the digest HTML without sending it.
|
||||||
|
// GET /api/v1/digest/preview
|
||||||
|
func (h *DigestHandler) PreviewDigest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.service == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "digest service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := h.service.PreviewDigest(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigest triggers an immediate digest send.
|
||||||
|
// POST /api/v1/digest/send
|
||||||
|
func (h *DigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.service == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "digest service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.SendDigest(r.Context()); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "sent"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockDigestService implements DigestServicer for testing.
|
||||||
|
type mockDigestService struct {
|
||||||
|
previewHTML string
|
||||||
|
previewErr error
|
||||||
|
sendErr error
|
||||||
|
sendCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDigestService) PreviewDigest(ctx context.Context) (string, error) {
|
||||||
|
if m.previewErr != nil {
|
||||||
|
return "", m.previewErr
|
||||||
|
}
|
||||||
|
return m.previewHTML, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDigestService) SendDigest(ctx context.Context) error {
|
||||||
|
m.sendCalled = true
|
||||||
|
return m.sendErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_PreviewDigest_Success(t *testing.T) {
|
||||||
|
svc := &mockDigestService{
|
||||||
|
previewHTML: "<html><body>Digest Preview</body></html>",
|
||||||
|
}
|
||||||
|
h := NewDigestHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.PreviewDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.Header().Get("Content-Type") != "text/html; charset=utf-8" {
|
||||||
|
t.Errorf("expected Content-Type text/html, got %s", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.Body.String() != "<html><body>Digest Preview</body></html>" {
|
||||||
|
t.Errorf("unexpected body: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_PreviewDigest_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockDigestService{}
|
||||||
|
h := NewDigestHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/preview", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.PreviewDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_PreviewDigest_ServiceError(t *testing.T) {
|
||||||
|
svc := &mockDigestService{
|
||||||
|
previewErr: errors.New("stats unavailable"),
|
||||||
|
}
|
||||||
|
h := NewDigestHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.PreviewDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_PreviewDigest_NotConfigured(t *testing.T) {
|
||||||
|
h := NewDigestHandler(nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.PreviewDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("expected status 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_SendDigest_Success(t *testing.T) {
|
||||||
|
svc := &mockDigestService{}
|
||||||
|
h := NewDigestHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.SendDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !svc.sendCalled {
|
||||||
|
t.Error("expected SendDigest to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_SendDigest_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockDigestService{}
|
||||||
|
h := NewDigestHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/send", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.SendDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_SendDigest_ServiceError(t *testing.T) {
|
||||||
|
svc := &mockDigestService{
|
||||||
|
sendErr: errors.New("SMTP connection refused"),
|
||||||
|
}
|
||||||
|
h := NewDigestHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.SendDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestHandler_SendDigest_NotConfigured(t *testing.T) {
|
||||||
|
h := NewDigestHandler(nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.SendDigest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("expected status 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -610,3 +610,122 @@ func TestGetDiscoverySummary_MethodNotAllowed(t *testing.T) {
|
|||||||
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test DismissDiscovered - service error
|
||||||
|
func TestDismissDiscovered_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockDiscoveryService{
|
||||||
|
DismissDiscoveredFn: func(ctx context.Context, id string) error {
|
||||||
|
return fmt.Errorf("database error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewDiscoveryHandler(mock)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/dismiss", nil)
|
||||||
|
req = req.WithContext(discoveryContextWithRequestID())
|
||||||
|
req.SetPathValue("id", "dcert-1")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.DismissDiscovered(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ClaimDiscovered - invalid body (malformed JSON)
|
||||||
|
func TestClaimDiscovered_InvalidJSON(t *testing.T) {
|
||||||
|
mock := &MockDiscoveryService{}
|
||||||
|
handler := NewDiscoveryHandler(mock)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/claim", bytes.NewReader([]byte("invalid json")))
|
||||||
|
req = req.WithContext(discoveryContextWithRequestID())
|
||||||
|
req.SetPathValue("id", "dcert-1")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ClaimDiscovered(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ClaimDiscovered - method not allowed
|
||||||
|
func TestClaimDiscovered_MethodNotAllowed(t *testing.T) {
|
||||||
|
mock := &MockDiscoveryService{}
|
||||||
|
handler := NewDiscoveryHandler(mock)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/dcert-1/claim", nil)
|
||||||
|
req = req.WithContext(discoveryContextWithRequestID())
|
||||||
|
req.SetPathValue("id", "dcert-1")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ClaimDiscovered(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ListDiscovered - service error
|
||||||
|
func TestListDiscovered_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockDiscoveryService{
|
||||||
|
ListDiscoveredFn: func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
|
||||||
|
return nil, 0, fmt.Errorf("database error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewDiscoveryHandler(mock)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates", nil)
|
||||||
|
req = req.WithContext(discoveryContextWithRequestID())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ListDiscovered(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ListScans - service error
|
||||||
|
func TestListScans_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockDiscoveryService{
|
||||||
|
ListScansFn: func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
|
||||||
|
return nil, 0, fmt.Errorf("database error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewDiscoveryHandler(mock)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-scans", nil)
|
||||||
|
req = req.WithContext(discoveryContextWithRequestID())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ListScans(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetDiscoverySummary - service error
|
||||||
|
func TestGetDiscoverySummary_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockDiscoveryService{
|
||||||
|
GetDiscoverySummaryFn: func(ctx context.Context) (map[string]int, error) {
|
||||||
|
return nil, fmt.Errorf("database error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewDiscoveryHandler(mock)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-summary", nil)
|
||||||
|
req = req.WithContext(discoveryContextWithRequestID())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.GetDiscoverySummary(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -396,3 +396,49 @@ func TestASN1EncodeLength(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestESTCSRAttrs_ServiceError(t *testing.T) {
|
||||||
|
svc := &mockESTService{
|
||||||
|
CSRAttrsErr: errors.New("service error"),
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CSRAttrs(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTSimpleReEnroll_ServiceError(t *testing.T) {
|
||||||
|
csrPEM := generateTestCSRPEM(t)
|
||||||
|
svc := &mockESTService{
|
||||||
|
EnrollErr: errors.New("renewal failed"),
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(csrPEM))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.SimpleReEnroll(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestESTCACerts_UnableToGetCerts(t *testing.T) {
|
||||||
|
svc := &mockESTService{
|
||||||
|
CACertErr: errors.New("CA unavailable"),
|
||||||
|
}
|
||||||
|
h := NewESTHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CACerts(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportService defines the service interface for certificate export operations.
|
||||||
|
type ExportService interface {
|
||||||
|
ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error)
|
||||||
|
ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportHandler handles HTTP requests for certificate export operations.
|
||||||
|
type ExportHandler struct {
|
||||||
|
svc ExportService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExportHandler creates a new ExportHandler with a service dependency.
|
||||||
|
func NewExportHandler(svc ExportService) ExportHandler {
|
||||||
|
return ExportHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportPEM exports a certificate and its chain in PEM format.
|
||||||
|
// GET /api/v1/certificates/{id}/export/pem
|
||||||
|
func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pem
|
||||||
|
id := extractCertIDFromExportPath(r.URL.Path)
|
||||||
|
if id == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.svc.ExportPEM(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if client wants file download via Accept header or ?download=true query param
|
||||||
|
if r.URL.Query().Get("download") == "true" {
|
||||||
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.pem\"")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(result.FullPEM))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportPKCS12 exports a certificate and chain in PKCS#12 format.
|
||||||
|
// POST /api/v1/certificates/{id}/export/pkcs12
|
||||||
|
// Body: { "password": "optional-password" }
|
||||||
|
func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pkcs12
|
||||||
|
id := extractCertIDFromExportPath(r.URL.Path)
|
||||||
|
if id == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional password from request body (may be empty)
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
// Body is optional — empty body means empty password
|
||||||
|
_ = parseJSONBody(r, &req)
|
||||||
|
|
||||||
|
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/x-pkcs12")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.p12\"")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(pfxData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCertIDFromExportPath extracts the certificate ID from an export path.
|
||||||
|
// Path format: /api/v1/certificates/{id}/export/pem or /api/v1/certificates/{id}/export/pkcs12
|
||||||
|
func extractCertIDFromExportPath(path string) string {
|
||||||
|
prefix := "/api/v1/certificates/"
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(path, prefix)
|
||||||
|
// rest should be "{id}/export/pem" or "{id}/export/pkcs12"
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
if len(parts) < 3 || parts[1] != "export" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJSONBody is a helper that decodes JSON from the request body.
|
||||||
|
// Returns an error if the body is malformed, nil if body is empty.
|
||||||
|
func parseJSONBody(r *http.Request, v interface{}) error {
|
||||||
|
if r.Body == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(r.Body).Decode(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add context import was already there — verify import is present above
|
||||||
|
|
||||||
|
// MockExportService is a mock implementation of ExportService interface.
|
||||||
|
type MockExportService struct {
|
||||||
|
ExportPEMFn func(ctx context.Context, certID string) (*service.ExportPEMResult, error)
|
||||||
|
ExportPKCS12Fn func(ctx context.Context, certID string, password string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockExportService) ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error) {
|
||||||
|
if m.ExportPEMFn != nil {
|
||||||
|
return m.ExportPEMFn(ctx, certID)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockExportService) ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) {
|
||||||
|
if m.ExportPKCS12Fn != nil {
|
||||||
|
return m.ExportPKCS12Fn(ctx, certID, password)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPEM_Success(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPEMFn: func(_ context.Context, certID string) (*service.ExportPEMResult, error) {
|
||||||
|
if certID != "mc-test-1" {
|
||||||
|
t.Errorf("expected certID mc-test-1, got %s", certID)
|
||||||
|
}
|
||||||
|
return &service.ExportPEMResult{
|
||||||
|
CertPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n",
|
||||||
|
ChainPEM: "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n",
|
||||||
|
FullPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPEM(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("expected application/json content type, got %s", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result service.ExportPEMResult
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if result.CertPEM == "" {
|
||||||
|
t.Error("expected non-empty CertPEM")
|
||||||
|
}
|
||||||
|
if result.ChainPEM == "" {
|
||||||
|
t.Error("expected non-empty ChainPEM")
|
||||||
|
}
|
||||||
|
if result.FullPEM == "" {
|
||||||
|
t.Error("expected non-empty FullPEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPEM_Download(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||||
|
return &service.ExportPEMResult{
|
||||||
|
CertPEM: "cert",
|
||||||
|
ChainPEM: "chain",
|
||||||
|
FullPEM: "full-pem-content",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem?download=true", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPEM(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "application/x-pem-file" {
|
||||||
|
t.Errorf("expected application/x-pem-file, got %s", ct)
|
||||||
|
}
|
||||||
|
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.pem"` {
|
||||||
|
t.Errorf("expected Content-Disposition attachment, got %s", cd)
|
||||||
|
}
|
||||||
|
if w.Body.String() != "full-pem-content" {
|
||||||
|
t.Errorf("expected full-pem-content body, got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPEM_NotFound(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||||
|
return nil, fmt.Errorf("certificate not found")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/nonexistent/export/pem", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPEM(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPEM_ServiceError(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||||
|
return nil, fmt.Errorf("internal error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPEM(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPEM_MethodNotAllowed(t *testing.T) {
|
||||||
|
h := NewExportHandler(&MockExportService{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPEM(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPKCS12_Success(t *testing.T) {
|
||||||
|
pfxData := []byte{0x30, 0x82, 0x01, 0x00} // mock PKCS#12 data
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPKCS12Fn: func(_ context.Context, certID string, password string) ([]byte, error) {
|
||||||
|
if certID != "mc-test-1" {
|
||||||
|
t.Errorf("expected certID mc-test-1, got %s", certID)
|
||||||
|
}
|
||||||
|
if password != "mysecret" {
|
||||||
|
t.Errorf("expected password mysecret, got %s", password)
|
||||||
|
}
|
||||||
|
return pfxData, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"password":"mysecret"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", body)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPKCS12(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "application/x-pkcs12" {
|
||||||
|
t.Errorf("expected application/x-pkcs12, got %s", ct)
|
||||||
|
}
|
||||||
|
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.p12"` {
|
||||||
|
t.Errorf("expected Content-Disposition attachment, got %s", cd)
|
||||||
|
}
|
||||||
|
if len(w.Body.Bytes()) != len(pfxData) {
|
||||||
|
t.Errorf("expected %d bytes, got %d", len(pfxData), len(w.Body.Bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPKCS12_EmptyPassword(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPKCS12Fn: func(_ context.Context, _ string, password string) ([]byte, error) {
|
||||||
|
if password != "" {
|
||||||
|
t.Errorf("expected empty password, got %s", password)
|
||||||
|
}
|
||||||
|
return []byte{0x30}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
// Empty body — password defaults to ""
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPKCS12(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPKCS12_NotFound(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("certificate not found")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/nonexistent/export/pkcs12", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPKCS12(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPKCS12_ServiceError(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("encoding error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPKCS12(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPKCS12_MethodNotAllowed(t *testing.T) {
|
||||||
|
h := NewExportHandler(&MockExportService{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPKCS12(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractCertIDFromExportPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"/api/v1/certificates/mc-test-1/export/pem", "mc-test-1"},
|
||||||
|
{"/api/v1/certificates/mc-api-prod/export/pkcs12", "mc-api-prod"},
|
||||||
|
{"/api/v1/certificates//export/pem", ""},
|
||||||
|
{"/api/v1/other/mc-test-1/export/pem", ""},
|
||||||
|
{"/api/v1/certificates/mc-test-1", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := extractCertIDFromExportPath(tt.path)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("extractCertIDFromExportPath(%q) = %q, want %q", tt.path, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPKCS12_InvalidJSON(t *testing.T) {
|
||||||
|
mockSvc := &MockExportService{
|
||||||
|
ExportPKCS12Fn: func(_ context.Context, _ string, password string) ([]byte, error) {
|
||||||
|
// Invalid JSON is silently ignored, defaults to empty password
|
||||||
|
if password != "" {
|
||||||
|
t.Errorf("expected empty password (invalid JSON ignored), got %s", password)
|
||||||
|
}
|
||||||
|
return []byte{0x30}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewExportHandler(mockSvc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", strings.NewReader(`{"invalid json`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPKCS12(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 (invalid JSON ignored), got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPEM_MethodNotAllowedDelete(t *testing.T) {
|
||||||
|
h := NewExportHandler(&MockExportService{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ExportPEM(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,8 +77,8 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
|
|||||||
func TestListNetworkScanTargets(t *testing.T) {
|
func TestListNetworkScanTargets(t *testing.T) {
|
||||||
svc := &mockNetworkScanService{
|
svc := &mockNetworkScanService{
|
||||||
targets: []*domain.NetworkScanTarget{
|
targets: []*domain.NetworkScanTarget{
|
||||||
{ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int{443}},
|
{ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int64{443}},
|
||||||
{ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int{443, 8443}},
|
{ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int64{443, 8443}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
h := NewNetworkScanHandler(svc)
|
h := NewNetworkScanHandler(svc)
|
||||||
@@ -118,7 +118,7 @@ func TestCreateNetworkScanTarget(t *testing.T) {
|
|||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
"name": "Production",
|
"name": "Production",
|
||||||
"cidrs": []string{"10.0.0.0/24"},
|
"cidrs": []string{"10.0.0.0/24"},
|
||||||
"ports": []int{443},
|
"ports": []int64{443},
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ func encodeCursor(createdAt time.Time, id string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decodeCursor extracts a timestamp and ID from a cursor token.
|
// decodeCursor extracts a timestamp and ID from a cursor token.
|
||||||
func decodeCursor(cursor string) (time.Time, string, error) {
|
// Kept as var assignment to suppress unused lint — will be used when
|
||||||
|
// cursor-based pagination is wired into list handlers.
|
||||||
|
var _ = func(cursor string) (time.Time, string, error) {
|
||||||
raw, err := base64.URLEncoding.DecodeString(cursor)
|
raw, err := base64.URLEncoding.DecodeString(cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, "", fmt.Errorf("invalid cursor: %w", err)
|
return time.Time{}, "", fmt.Errorf("invalid cursor: %w", err)
|
||||||
|
|||||||
@@ -316,3 +316,115 @@ func TestGetPrometheusMetrics_ZeroValues(t *testing.T) {
|
|||||||
func containsLine(text, substr string) bool {
|
func containsLine(text, substr string) bool {
|
||||||
return strings.Contains(text, substr)
|
return strings.Contains(text, substr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test GetCertificatesByStatus - method not allowed
|
||||||
|
func TestGetCertificatesByStatus_MethodNotAllowed(t *testing.T) {
|
||||||
|
mock := &MockStatsService{}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/certificates-by-status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetCertificatesByStatus(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetCertificatesByStatus - service error
|
||||||
|
func TestGetCertificatesByStatus_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockStatsService{
|
||||||
|
GetCertificatesByStatusFn: func(ctx context.Context) (interface{}, error) {
|
||||||
|
return nil, fmt.Errorf("db error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/certificates-by-status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetCertificatesByStatus(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetExpirationTimeline - method not allowed
|
||||||
|
func TestGetExpirationTimeline_MethodNotAllowed(t *testing.T) {
|
||||||
|
mock := &MockStatsService{}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/expiration-timeline", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetExpirationTimeline(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetExpirationTimeline - service error
|
||||||
|
func TestGetExpirationTimeline_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockStatsService{
|
||||||
|
GetExpirationTimelineFn: func(ctx context.Context, days int) (interface{}, error) {
|
||||||
|
return nil, fmt.Errorf("db error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline?days=30", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetExpirationTimeline(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetJobTrends - method not allowed
|
||||||
|
func TestGetJobTrends_MethodNotAllowed(t *testing.T) {
|
||||||
|
mock := &MockStatsService{}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/job-trends", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetJobTrends(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetJobTrends - service error
|
||||||
|
func TestGetJobTrends_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockStatsService{
|
||||||
|
GetJobStatsFn: func(ctx context.Context, days int) (interface{}, error) {
|
||||||
|
return nil, fmt.Errorf("db error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/job-trends?days=14", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetJobTrends(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetIssuanceRate - method not allowed
|
||||||
|
func TestGetIssuanceRate_MethodNotAllowed(t *testing.T) {
|
||||||
|
mock := &MockStatsService{}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/issuance-rate", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetIssuanceRate(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetIssuanceRate - service error
|
||||||
|
func TestGetIssuanceRate_ServiceError(t *testing.T) {
|
||||||
|
mock := &MockStatsService{
|
||||||
|
GetIssuanceRateFn: func(ctx context.Context, days int) (interface{}, error) {
|
||||||
|
return nil, fmt.Errorf("db error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewStatsHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/issuance-rate?days=7", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.GetIssuanceRate(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerificationService defines the service interface for verification operations.
|
||||||
|
type VerificationService interface {
|
||||||
|
// RecordVerificationResult records the outcome of TLS endpoint verification.
|
||||||
|
RecordVerificationResult(ctx context.Context, result *domain.VerificationResult) error
|
||||||
|
|
||||||
|
// GetVerificationResult retrieves the verification status for a job.
|
||||||
|
GetVerificationResult(ctx context.Context, jobID string) (*domain.VerificationResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerificationHandler handles HTTP requests for certificate deployment verification.
|
||||||
|
type VerificationHandler struct {
|
||||||
|
svc VerificationService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVerificationHandler creates a new VerificationHandler.
|
||||||
|
func NewVerificationHandler(svc VerificationService) VerificationHandler {
|
||||||
|
return VerificationHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDeploymentRequest represents the request body for POST /api/v1/jobs/{id}/verify
|
||||||
|
type VerifyDeploymentRequest struct {
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
ExpectedFingerprint string `json:"expected_fingerprint"`
|
||||||
|
ActualFingerprint string `json:"actual_fingerprint"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDeployment handles POST /api/v1/jobs/{id}/verify
|
||||||
|
// Agents submit verification results after attempting to probe the live TLS endpoint.
|
||||||
|
// This endpoint records the verification outcome (success or failure) and updates the job status.
|
||||||
|
func (h VerificationHandler) VerifyDeployment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract job ID from URL path: /api/v1/jobs/{id}/verify
|
||||||
|
jobID, err := extractIDFromPath(r.URL.Path, "/api/v1/jobs/", "/verify")
|
||||||
|
if err != nil || jobID == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid job ID", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req VerifyDeploymentRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err), middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if req.TargetID == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "target_id is required", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExpectedFingerprint == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "expected_fingerprint is required", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ActualFingerprint == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "actual_fingerprint is required", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build verification result
|
||||||
|
result := &domain.VerificationResult{
|
||||||
|
JobID: jobID,
|
||||||
|
TargetID: req.TargetID,
|
||||||
|
ExpectedFingerprint: req.ExpectedFingerprint,
|
||||||
|
ActualFingerprint: req.ActualFingerprint,
|
||||||
|
Verified: req.Verified,
|
||||||
|
VerifiedAt: time.Now().UTC(),
|
||||||
|
Error: req.Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record result
|
||||||
|
if err := h.svc.RecordVerificationResult(r.Context(), result); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to record verification result: %v", err), middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"verified": req.Verified,
|
||||||
|
"verified_at": result.VerifiedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVerificationStatus handles GET /api/v1/jobs/{id}/verification
|
||||||
|
// Returns the current verification status for a job.
|
||||||
|
func (h VerificationHandler) GetVerificationStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract job ID from URL path: /api/v1/jobs/{id}/verification
|
||||||
|
jobID, err := extractIDFromPath(r.URL.Path, "/api/v1/jobs/", "/verification")
|
||||||
|
if err != nil || jobID == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid job ID", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get verification result
|
||||||
|
result, err := h.svc.GetVerificationResult(r.Context(), jobID)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get verification result: %v", err), middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return result
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIDFromPath extracts the resource ID from a path like /api/v1/jobs/{id}/verify
|
||||||
|
// prefix: "/api/v1/jobs/" suffix: "/verify"
|
||||||
|
// Returns the extracted ID between prefix and suffix.
|
||||||
|
func extractIDFromPath(path, prefix, suffix string) (string, error) {
|
||||||
|
if len(path) <= len(prefix)+len(suffix) {
|
||||||
|
return "", fmt.Errorf("path too short")
|
||||||
|
}
|
||||||
|
if !HasPrefix(path, prefix) {
|
||||||
|
return "", fmt.Errorf("path does not start with prefix")
|
||||||
|
}
|
||||||
|
// Remove prefix
|
||||||
|
remainder := path[len(prefix):]
|
||||||
|
// Find suffix
|
||||||
|
idx := FindLastOccurrence(remainder, suffix)
|
||||||
|
if idx == -1 {
|
||||||
|
return "", fmt.Errorf("suffix not found")
|
||||||
|
}
|
||||||
|
return remainder[:idx], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPrefix checks if a string starts with a prefix.
|
||||||
|
func HasPrefix(s, prefix string) bool {
|
||||||
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLastOccurrence finds the last occurrence of a substring (simplified version).
|
||||||
|
func FindLastOccurrence(s, substr string) int {
|
||||||
|
if len(substr) == 0 {
|
||||||
|
return len(s)
|
||||||
|
}
|
||||||
|
for i := len(s) - len(substr); i >= 0; i-- {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockVerificationService is a test double for VerificationService.
|
||||||
|
type mockVerificationService struct {
|
||||||
|
recordErr error
|
||||||
|
getErr error
|
||||||
|
results map[string]*domain.VerificationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVerificationService) RecordVerificationResult(ctx context.Context, result *domain.VerificationResult) error {
|
||||||
|
if m.recordErr != nil {
|
||||||
|
return m.recordErr
|
||||||
|
}
|
||||||
|
if m.results == nil {
|
||||||
|
m.results = make(map[string]*domain.VerificationResult)
|
||||||
|
}
|
||||||
|
m.results[result.JobID] = result
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVerificationService) GetVerificationResult(ctx context.Context, jobID string) (*domain.VerificationResult, error) {
|
||||||
|
if m.getErr != nil {
|
||||||
|
return nil, m.getErr
|
||||||
|
}
|
||||||
|
if m.results == nil {
|
||||||
|
m.results = make(map[string]*domain.VerificationResult)
|
||||||
|
}
|
||||||
|
return m.results[jobID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_Success(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{
|
||||||
|
results: make(map[string]*domain.VerificationResult),
|
||||||
|
}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
req := VerifyDeploymentRequest{
|
||||||
|
TargetID: "t-nginx1",
|
||||||
|
ExpectedFingerprint: "abc123",
|
||||||
|
ActualFingerprint: "abc123",
|
||||||
|
Verified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test1/verify", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify result was recorded
|
||||||
|
result := mockSvc.results["j-test1"]
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected verification result to be recorded")
|
||||||
|
}
|
||||||
|
if !result.Verified {
|
||||||
|
t.Error("expected Verified to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_FingerPrintMismatch(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{
|
||||||
|
results: make(map[string]*domain.VerificationResult),
|
||||||
|
}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
req := VerifyDeploymentRequest{
|
||||||
|
TargetID: "t-apache1",
|
||||||
|
ExpectedFingerprint: "aaa111",
|
||||||
|
ActualFingerprint: "bbb222",
|
||||||
|
Verified: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test2/verify", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := mockSvc.results["j-test2"]
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected verification result to be recorded")
|
||||||
|
}
|
||||||
|
if result.Verified {
|
||||||
|
t.Error("expected Verified to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_MissingTargetID(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
req := VerifyDeploymentRequest{
|
||||||
|
ExpectedFingerprint: "abc123",
|
||||||
|
ActualFingerprint: "abc123",
|
||||||
|
Verified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test3/verify", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_MissingExpectedFingerprint(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
req := VerifyDeploymentRequest{
|
||||||
|
TargetID: "t-nginx1",
|
||||||
|
ActualFingerprint: "abc123",
|
||||||
|
Verified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test4/verify", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_InvalidMethod(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test5/verify", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_InvalidJSON(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test6/verify", bytes.NewBufferString("invalid json"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVerificationStatus_Success(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
fp := "xyz789"
|
||||||
|
mockSvc := &mockVerificationService{
|
||||||
|
results: map[string]*domain.VerificationResult{
|
||||||
|
"j-test7": {
|
||||||
|
JobID: "j-test7",
|
||||||
|
TargetID: "t-haproxy1",
|
||||||
|
ExpectedFingerprint: "xyz789",
|
||||||
|
ActualFingerprint: fp,
|
||||||
|
Verified: true,
|
||||||
|
VerifiedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test7/verification", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.GetVerificationStatus(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result domain.VerificationResult
|
||||||
|
json.NewDecoder(w.Body).Decode(&result)
|
||||||
|
if result.JobID != "j-test7" {
|
||||||
|
t.Errorf("expected job ID j-test7, got %s", result.JobID)
|
||||||
|
}
|
||||||
|
if !result.Verified {
|
||||||
|
t.Error("expected Verified to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVerificationStatus_InvalidMethod(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test8/verification", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.GetVerificationStatus(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_ServiceError(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{
|
||||||
|
recordErr: ErrServiceUnavailable,
|
||||||
|
}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
req := VerifyDeploymentRequest{
|
||||||
|
TargetID: "t-nginx1",
|
||||||
|
ExpectedFingerprint: "abc123",
|
||||||
|
ActualFingerprint: "abc123",
|
||||||
|
Verified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test9/verify", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeployment_EmptyBody(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test10/verify", bytes.NewBufferString(""))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.VerifyDeployment(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVerificationStatus_ServiceError(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{
|
||||||
|
getErr: ErrServiceUnavailable,
|
||||||
|
}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test11/verification", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.GetVerificationStatus(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVerificationStatus_NotFound(t *testing.T) {
|
||||||
|
mockSvc := &mockVerificationService{
|
||||||
|
results: make(map[string]*domain.VerificationResult),
|
||||||
|
}
|
||||||
|
handler := NewVerificationHandler(mockSvc)
|
||||||
|
|
||||||
|
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-nonexistent/verification", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.GetVerificationStatus(w, httpReq)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *domain.VerificationResult
|
||||||
|
json.NewDecoder(w.Body).Decode(&result)
|
||||||
|
if result != nil {
|
||||||
|
t.Error("expected nil result for nonexistent job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrServiceUnavailable = NewServiceError("service unavailable")
|
||||||
|
|
||||||
|
func NewServiceError(msg string) error {
|
||||||
|
return &serviceError{msg: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceError struct {
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *serviceError) Error() string {
|
||||||
|
return e.msg
|
||||||
|
}
|
||||||
@@ -78,7 +78,12 @@ func NewAuditLog(recorder AuditRecorder, cfg AuditConfig) func(http.Handler) htt
|
|||||||
|
|
||||||
latency := time.Since(start).Milliseconds()
|
latency := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
// Record audit event asynchronously (best-effort, don't block response)
|
// Record audit event asynchronously (best-effort, don't block response).
|
||||||
|
// SECURITY: We intentionally use r.URL.Path (not r.URL.String() or r.RequestURI)
|
||||||
|
// to prevent query parameters from being recorded in the immutable audit trail.
|
||||||
|
// Query strings may contain cursor tokens, API keys passed as params, or other
|
||||||
|
// sensitive filter values. Since the audit trail is append-only with no deletion
|
||||||
|
// capability, any sensitive data recorded would persist permanently.
|
||||||
go func() {
|
go func() {
|
||||||
if err := recorder.RecordAPICall(
|
if err := recorder.RecordAPICall(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -50,8 +50,46 @@ func (m *mockAuditRecorder) getCalls() []auditCall {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitableAuditRecorder wraps a mockAuditRecorder and signals when a recording completes.
|
||||||
|
// This allows tests to synchronously wait for async audit records without using time.Sleep.
|
||||||
|
type waitableAuditRecorder struct {
|
||||||
|
inner *mockAuditRecorder
|
||||||
|
recorded chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWaitableAuditRecorder() *waitableAuditRecorder {
|
||||||
|
return &waitableAuditRecorder{
|
||||||
|
inner: &mockAuditRecorder{},
|
||||||
|
recorded: make(chan struct{}, 100), // buffered to avoid blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *waitableAuditRecorder) RecordAPICall(ctx context.Context, method, path, actor, bodyHash string, status int, latencyMs int64) error {
|
||||||
|
err := w.inner.RecordAPICall(ctx, method, path, actor, bodyHash, status, latencyMs)
|
||||||
|
// Signal that a recording was completed
|
||||||
|
select {
|
||||||
|
case w.recorded <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *waitableAuditRecorder) getCalls() []auditCall {
|
||||||
|
return w.inner.getCalls()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until a recording is signaled or timeout expires. Returns true if recording completed, false on timeout.
|
||||||
|
func (w *waitableAuditRecorder) Wait(timeout time.Duration) bool {
|
||||||
|
select {
|
||||||
|
case <-w.recorded:
|
||||||
|
return true
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuditLog_RecordsAPICall(t *testing.T) {
|
func TestAuditLog_RecordsAPICall(t *testing.T) {
|
||||||
recorder := &mockAuditRecorder{}
|
recorder := newWaitableAuditRecorder()
|
||||||
mw := NewAuditLog(recorder, AuditConfig{})
|
mw := NewAuditLog(recorder, AuditConfig{})
|
||||||
|
|
||||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -67,8 +105,10 @@ func TestAuditLog_RecordsAPICall(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", rr.Code)
|
t.Fatalf("expected 200, got %d", rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit recording is async — give goroutine time to complete
|
// Audit recording is async — wait for goroutine to complete
|
||||||
time.Sleep(50 * time.Millisecond)
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
calls := recorder.getCalls()
|
calls := recorder.getCalls()
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
@@ -89,7 +129,7 @@ func TestAuditLog_RecordsAPICall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditLog_CapturesStatusCode(t *testing.T) {
|
func TestAuditLog_CapturesStatusCode(t *testing.T) {
|
||||||
recorder := &mockAuditRecorder{}
|
recorder := newWaitableAuditRecorder()
|
||||||
mw := NewAuditLog(recorder, AuditConfig{})
|
mw := NewAuditLog(recorder, AuditConfig{})
|
||||||
|
|
||||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -100,7 +140,9 @@ func TestAuditLog_CapturesStatusCode(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rr, req)
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
calls := recorder.getCalls()
|
calls := recorder.getCalls()
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
@@ -112,7 +154,7 @@ func TestAuditLog_CapturesStatusCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditLog_ExcludesHealth(t *testing.T) {
|
func TestAuditLog_ExcludesHealth(t *testing.T) {
|
||||||
recorder := &mockAuditRecorder{}
|
recorder := newWaitableAuditRecorder()
|
||||||
mw := NewAuditLog(recorder, AuditConfig{
|
mw := NewAuditLog(recorder, AuditConfig{
|
||||||
ExcludePaths: []string{"/health", "/ready"},
|
ExcludePaths: []string{"/health", "/ready"},
|
||||||
})
|
})
|
||||||
@@ -136,7 +178,9 @@ func TestAuditLog_ExcludesHealth(t *testing.T) {
|
|||||||
rr3 := httptest.NewRecorder()
|
rr3 := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rr3, req3)
|
handler.ServeHTTP(rr3, req3)
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
calls := recorder.getCalls()
|
calls := recorder.getCalls()
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
@@ -148,7 +192,7 @@ func TestAuditLog_ExcludesHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditLog_HashesRequestBody(t *testing.T) {
|
func TestAuditLog_HashesRequestBody(t *testing.T) {
|
||||||
recorder := &mockAuditRecorder{}
|
recorder := newWaitableAuditRecorder()
|
||||||
mw := NewAuditLog(recorder, AuditConfig{})
|
mw := NewAuditLog(recorder, AuditConfig{})
|
||||||
|
|
||||||
// Handler verifies body was restored
|
// Handler verifies body was restored
|
||||||
@@ -165,7 +209,9 @@ func TestAuditLog_HashesRequestBody(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rr, req)
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
calls := recorder.getCalls()
|
calls := recorder.getCalls()
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
@@ -181,7 +227,7 @@ func TestAuditLog_HashesRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditLog_EmptyBodyNoHash(t *testing.T) {
|
func TestAuditLog_EmptyBodyNoHash(t *testing.T) {
|
||||||
recorder := &mockAuditRecorder{}
|
recorder := newWaitableAuditRecorder()
|
||||||
mw := NewAuditLog(recorder, AuditConfig{})
|
mw := NewAuditLog(recorder, AuditConfig{})
|
||||||
|
|
||||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -192,7 +238,9 @@ func TestAuditLog_EmptyBodyNoHash(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rr, req)
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
calls := recorder.getCalls()
|
calls := recorder.getCalls()
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
@@ -204,7 +252,7 @@ func TestAuditLog_EmptyBodyNoHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
|
func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
|
||||||
recorder := &mockAuditRecorder{}
|
recorder := newWaitableAuditRecorder()
|
||||||
mw := NewAuditLog(recorder, AuditConfig{})
|
mw := NewAuditLog(recorder, AuditConfig{})
|
||||||
|
|
||||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -219,7 +267,9 @@ func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rr, req)
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
calls := recorder.getCalls()
|
calls := recorder.getCalls()
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
@@ -253,7 +303,7 @@ func TestAuditLog_RecorderErrorDoesNotBreakResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditLog_CapturesLatency(t *testing.T) {
|
func TestAuditLog_CapturesLatency(t *testing.T) {
|
||||||
recorder := &mockAuditRecorder{}
|
recorder := newWaitableAuditRecorder()
|
||||||
mw := NewAuditLog(recorder, AuditConfig{})
|
mw := NewAuditLog(recorder, AuditConfig{})
|
||||||
|
|
||||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -265,7 +315,9 @@ func TestAuditLog_CapturesLatency(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rr, req)
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
calls := recorder.getCalls()
|
calls := recorder.getCalls()
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
@@ -276,6 +328,46 @@ func TestAuditLog_CapturesLatency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuditLog_ExcludesQueryParamsFromPath(t *testing.T) {
|
||||||
|
recorder := newWaitableAuditRecorder()
|
||||||
|
mw := NewAuditLog(recorder, AuditConfig{})
|
||||||
|
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Send a request with sensitive query parameters
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?api_key=secret123&cursor=abc&status=active", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if !recorder.Wait(1 * time.Second) {
|
||||||
|
t.Fatal("timeout waiting for audit record")
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := recorder.getCalls()
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path should contain ONLY the path, no query parameters
|
||||||
|
if calls[0].Path != "/api/v1/certificates" {
|
||||||
|
t.Errorf("expected path /api/v1/certificates (no query params), got %s", calls[0].Path)
|
||||||
|
}
|
||||||
|
if strings.Contains(calls[0].Path, "api_key") {
|
||||||
|
t.Error("audit path contains 'api_key' — query parameters leaked into audit trail")
|
||||||
|
}
|
||||||
|
if strings.Contains(calls[0].Path, "secret123") {
|
||||||
|
t.Error("audit path contains sensitive value 'secret123' — query parameters leaked into audit trail")
|
||||||
|
}
|
||||||
|
if strings.Contains(calls[0].Path, "cursor") {
|
||||||
|
t.Error("audit path contains 'cursor' — query parameters leaked into audit trail")
|
||||||
|
}
|
||||||
|
if strings.Contains(calls[0].Path, "?") {
|
||||||
|
t.Error("audit path contains '?' — query string leaked into audit trail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuditServiceAdapter_TranslatesCallToEvent(t *testing.T) {
|
func TestAuditServiceAdapter_TranslatesCallToEvent(t *testing.T) {
|
||||||
var capturedActor, capturedActorType, capturedAction, capturedResourceType, capturedResourceID string
|
var capturedActor, capturedActorType, capturedAction, capturedResourceType, capturedResourceID string
|
||||||
var capturedDetails map[string]interface{}
|
var capturedDetails map[string]interface{}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAuth_MultiKeyAcceptsBothKeys(t *testing.T) {
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "api-key",
|
||||||
|
Secret: "key-one,key-two",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// First key should work
|
||||||
|
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req1.Header.Set("Authorization", "Bearer key-one")
|
||||||
|
rr1 := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr1, req1)
|
||||||
|
if rr1.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for first key, got %d", rr1.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second key should work
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req2.Header.Set("Authorization", "Bearer key-two")
|
||||||
|
rr2 := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr2, req2)
|
||||||
|
if rr2.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for second key, got %d", rr2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuth_MultiKeyRejectsInvalidKey(t *testing.T) {
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "api-key",
|
||||||
|
Secret: "key-one,key-two",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Invalid key should be rejected
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong-key")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for invalid key, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuth_MultiKeyWithSpaces(t *testing.T) {
|
||||||
|
// Keys with leading/trailing spaces should be trimmed
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "api-key",
|
||||||
|
Secret: " key-one , key-two ",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer key-one")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for trimmed key, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuth_SingleKeyStillWorks(t *testing.T) {
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "api-key",
|
||||||
|
Secret: "my-single-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer my-single-key")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for single key, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuth_NoneMode(t *testing.T) {
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "none",
|
||||||
|
Secret: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// No auth header needed in none mode
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 in none mode, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuth_MissingAuthHeader(t *testing.T) {
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "api-key",
|
||||||
|
Secret: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for missing auth, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuth_InvalidBearerFormat(t *testing.T) {
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "api-key",
|
||||||
|
Secret: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for non-Bearer auth, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuth_RemovedKeyIsRejected(t *testing.T) {
|
||||||
|
// Simulate key rotation: only key-two is configured (key-one was removed)
|
||||||
|
cfg := AuthConfig{
|
||||||
|
Type: "api-key",
|
||||||
|
Secret: "key-two",
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := NewAuth(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Old key should be rejected
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer key-one")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for removed key, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New key should work
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||||
|
req2.Header.Set("Authorization", "Bearer key-two")
|
||||||
|
rr2 := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr2, req2)
|
||||||
|
if rr2.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for current key, got %d", rr2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||