mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:31:36 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6515b4323 | |||
| 11173a74c6 | |||
| ec0e7a3560 | |||
| a0b9285323 | |||
| 2655493ac8 | |||
| a8fc177118 | |||
| 20378ea7bb | |||
| bcf2c3ae92 | |||
| 5f81de3219 |
@@ -7,9 +7,74 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GO_VERSION: '1.22'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# Cross-compile agent and server binaries for multiple platforms
|
||||
build-binaries:
|
||||
name: Build Cross-Platform Binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# Agent binaries (4 platforms)
|
||||
- os: linux
|
||||
arch: amd64
|
||||
binary: agent
|
||||
- os: linux
|
||||
arch: arm64
|
||||
binary: agent
|
||||
- os: darwin
|
||||
arch: amd64
|
||||
binary: agent
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
binary: agent
|
||||
# Server binaries (2 platforms)
|
||||
- os: linux
|
||||
arch: amd64
|
||||
binary: server
|
||||
- os: linux
|
||||
arch: arm64
|
||||
binary: server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build ${{ matrix.binary }} binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
env:
|
||||
GOOS: ${{ matrix.os }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
OUTPUT_NAME="certctl-${{ matrix.binary }}-${{ matrix.os }}-${{ matrix.arch }}"
|
||||
go build -ldflags="-w -s -X main.Version=${{ steps.version.outputs.VERSION }}" \
|
||||
-o "dist/${OUTPUT_NAME}" \
|
||||
"./cmd/${{ matrix.binary }}"
|
||||
ls -lh "dist/${OUTPUT_NAME}"
|
||||
|
||||
- name: Upload binaries to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
dist/certctl-agent-*
|
||||
dist/certctl-server-*
|
||||
|
||||
# Build and push Docker images
|
||||
build-and-push-docker:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -57,19 +122,67 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create GitHub Release
|
||||
# Create release notes with all artifacts
|
||||
create-release:
|
||||
name: Create Release Notes
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, build-and-push-docker]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create release with notes
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## Docker Images
|
||||
## Installation
|
||||
|
||||
### Quick Install (Linux/macOS)
|
||||
|
||||
```bash
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
### Manual Binary Download
|
||||
|
||||
Download the appropriate binary for your OS and architecture:
|
||||
|
||||
- **Linux x86_64**: `certctl-agent-linux-amd64`
|
||||
- **Linux ARM64**: `certctl-agent-linux-arm64`
|
||||
- **macOS x86_64**: `certctl-agent-darwin-amd64`
|
||||
- **macOS ARM64 (Apple Silicon)**: `certctl-agent-darwin-arm64`
|
||||
|
||||
Then make it executable and start the service:
|
||||
|
||||
```bash
|
||||
chmod +x certctl-agent-linux-amd64
|
||||
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
|
||||
```
|
||||
|
||||
## Docker Images
|
||||
|
||||
Pull pre-built Docker images for server and agent:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
docker pull ghcr.io/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
```
|
||||
|
||||
Or use the latest tag:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/shankar0123/certctl-server:latest
|
||||
docker pull ghcr.io/shankar0123/certctl-agent:latest
|
||||
```
|
||||
|
||||
## Docker Compose Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
@@ -77,3 +190,22 @@ jobs:
|
||||
cp deploy/.env.example deploy/.env
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Server Binaries
|
||||
|
||||
Pre-compiled server binaries are also available for direct installation:
|
||||
|
||||
- **Linux x86_64**: `certctl-server-linux-amd64`
|
||||
- **Linux ARM64**: `certctl-server-linux-arm64`
|
||||
|
||||
## Helm Chart
|
||||
|
||||
Deploy certctl to Kubernetes using Helm:
|
||||
|
||||
```bash
|
||||
helm repo add certctl https://github.com/shankar0123/certctl/tree/master/deploy/helm
|
||||
helm repo update
|
||||
helm install certctl certctl/certctl
|
||||
```
|
||||
|
||||
See `deploy/helm/certctl/` for values customization.
|
||||
|
||||
@@ -38,6 +38,9 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
|
||||
| [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 |
|
||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||
| [Migrate from Certbot](docs/migrate-from-certbot.md) | Step-by-step migration from Certbot/Let's Encrypt cron jobs |
|
||||
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
|
||||
| [certctl for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure |
|
||||
|
||||
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,471 Go tests + 193 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
|
||||
|
||||
@@ -160,7 +163,7 @@ docker compose -f deploy/docker-compose.yml up -d --build
|
||||
|
||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
||||
|
||||
The dashboard comes pre-loaded with 15 demo certificates, 5 agents, policy rules, audit events, and notifications — a realistic snapshot of a certificate inventory so you can explore immediately.
|
||||
The dashboard comes pre-loaded with 35 demo certificates across 5 issuers, 8 agents, 90 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
|
||||
|
||||
Verify the API:
|
||||
```bash
|
||||
@@ -168,13 +171,21 @@ curl http://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||
# 15
|
||||
# 35
|
||||
```
|
||||
|
||||
### Agent Install (One-Liner)
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||
```
|
||||
|
||||
Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh) for details.
|
||||
|
||||
### Manual Build
|
||||
|
||||
```bash
|
||||
# Prerequisites: Go 1.25+, PostgreSQL 16+
|
||||
# Prerequisites: Go 1.25+, PostgreSQL 16+, Docker (for testcontainers-go)
|
||||
go mod download
|
||||
make build
|
||||
|
||||
@@ -196,7 +207,7 @@ export CERTCTL_AGENT_ID=agent-local-01
|
||||
|
||||
## Architecture
|
||||
|
||||
**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.
|
||||
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 7 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
@@ -494,23 +505,44 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
- **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
|
||||
- **ACME ARI (RFC 9702)** — CA-directed renewal timing: the CA tells certctl the optimal renewal window, gracefully degrading to fixed thresholds when ARI is unavailable
|
||||
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; configurable daily/hourly/weekly briefings via SMTP
|
||||
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress
|
||||
|
||||
**Coming in v2.1.0:**
|
||||
- Vault PKI issuer connector (HashiCorp Vault /sign API)
|
||||
- DigiCert CertCentral issuer connector (enterprise CA)
|
||||
- Dynamic issuer and target configuration via GUI (no env var restarts)
|
||||
- Issuer catalog page (see all supported CAs, configure from dashboard)
|
||||
- First-run onboarding wizard
|
||||
- Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer)
|
||||
- Migration guides (Certbot, acme.sh, cert-manager complement)
|
||||
- One-line agent install script with cross-compiled binaries
|
||||
|
||||
### V3: certctl Pro
|
||||
|
||||
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, 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.
|
||||
|
||||
### V4+: Cloud, Scale & Passive Discovery
|
||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Vault PKI, Google CAS, EJBCA), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Google CAS, EJBCA, Sectigo), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||
|
||||
## Examples
|
||||
|
||||
Turnkey Docker Compose configurations for common scenarios — pick the one closest to your setup and have it running in 2 minutes.
|
||||
|
||||
| Example | Scenario |
|
||||
|---------|----------|
|
||||
| [`examples/acme-nginx/`](examples/acme-nginx/) | Let's Encrypt + NGINX, HTTP-01 challenges |
|
||||
| [`examples/acme-wildcard-dns01/`](examples/acme-wildcard-dns01/) | Wildcard certs via DNS-01 (Cloudflare hook included) |
|
||||
| [`examples/private-ca-traefik/`](examples/private-ca-traefik/) | Local CA (self-signed or sub-CA) + Traefik file provider |
|
||||
| [`examples/step-ca-haproxy/`](examples/step-ca-haproxy/) | Smallstep step-ca + HAProxy combined PEM |
|
||||
| [`examples/multi-issuer/`](examples/multi-issuer/) | ACME for public + Local CA for internal, one dashboard |
|
||||
|
||||
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
|
||||
|
||||
## 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. The BSL 1.1 license converts automatically to Apache 2.0 on March 1, 2033, providing perpetual freedom.
|
||||
|
||||
For licensing inquiries: certctl@proton.me
|
||||
|
||||
|
||||
@@ -250,6 +250,8 @@ paths:
|
||||
$ref: "#/components/schemas/ManagedCertificate"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
delete:
|
||||
@@ -261,6 +263,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Certificate archived
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -306,6 +310,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -820,6 +830,8 @@ paths:
|
||||
$ref: "#/components/schemas/Agent"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -877,6 +889,8 @@ paths:
|
||||
$ref: "#/components/schemas/StatusResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -2469,6 +2483,12 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
Conflict:
|
||||
description: Resource conflict
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
InternalError:
|
||||
description: Internal server error
|
||||
content:
|
||||
@@ -2571,6 +2591,13 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- name
|
||||
- common_name
|
||||
- renewal_policy_id
|
||||
- issuer_id
|
||||
- owner_id
|
||||
- team_id
|
||||
|
||||
CertificateVersion:
|
||||
type: object
|
||||
|
||||
@@ -226,6 +226,7 @@ func main() {
|
||||
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||
certificateService.SetTargetRepo(targetRepo)
|
||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||
renewalService.SetTargetRepo(targetRepo)
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
|
||||
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
|
||||
### Design Principles
|
||||
|
||||
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
|
||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work. For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work and receive only jobs assigned to their targets (routed via `agent_id` on jobs or through target→agent relationships). For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
|
||||
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
|
||||
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
||||
@@ -61,7 +61,7 @@ flowchart TB
|
||||
API["REST API\n(Go net/http, :8443)"]
|
||||
SVC["Service Layer"]
|
||||
REPO["Repository Layer\n(database/sql + lib/pq)"]
|
||||
SCHED["Background Scheduler\n6 loops"]
|
||||
SCHED["Background Scheduler\n7 loops"]
|
||||
DASH["Web Dashboard\n(React SPA)"]
|
||||
end
|
||||
|
||||
@@ -751,7 +751,7 @@ The HTTP middleware stack processes requests in the following order (see `cmd/se
|
||||
|
||||
### 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.
|
||||
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 7 loops — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
|
||||
|
||||
### Logging
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# certctl for cert-manager Users
|
||||
|
||||
You run cert-manager inside Kubernetes and it works well for in-cluster certificates. But you also have VMs, bare-metal servers, network appliances, and legacy systems outside the cluster. cert-manager can't reach those. This guide shows how certctl complements cert-manager to give you unified certificate visibility and automation across your entire infrastructure.
|
||||
|
||||
## Not a Replacement
|
||||
|
||||
cert-manager is the right tool for in-cluster certs. It's tightly integrated with Kubernetes:
|
||||
- Native CRDs (Certificate, ClusterIssuer, Issuer)
|
||||
- Automatic cert injection into Ingress and Service objects
|
||||
- Controller-driven renewal within the cluster
|
||||
|
||||
**certctl does not replace this.** Instead, it extends your certificate management to everything outside Kubernetes: VMs, bare metal, network appliances, Windows servers, and legacy systems.
|
||||
|
||||
## The Problem
|
||||
|
||||
Your setup:
|
||||
- **cert-manager**: handles all certs in Kubernetes (TLS for Ingress, service-to-service, internal services)
|
||||
- **Everything else**: NGINX/Apache on VMs, HAProxy load balancers on bare metal, network appliances, Windows servers with IIS — these are managed inconsistently. Maybe Certbot cron jobs, maybe manual renewal, maybe deprecated cert files sitting around.
|
||||
|
||||
Result:
|
||||
- No unified visibility — you don't know when non-Kubernetes certs expire
|
||||
- Renewal failures go unnoticed until the cert is already expired
|
||||
- Audit trail fragmented across multiple tools
|
||||
- Scaling to hundreds of machines becomes impossible
|
||||
|
||||
## The Solution
|
||||
|
||||
Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows:
|
||||
- **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly)
|
||||
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (planned), private CA)
|
||||
- **Unified renewal and deployment** across both worlds
|
||||
- **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail
|
||||
|
||||
## How to Set Up
|
||||
|
||||
### 1. Install certctl Control Plane
|
||||
|
||||
**Option A: Docker Compose** (quickest for evaluation)
|
||||
```bash
|
||||
cd /opt/certctl
|
||||
docker compose up -d
|
||||
# Dashboard & API: http://localhost:8443
|
||||
```
|
||||
|
||||
**Option B: Kubernetes** (recommended for prod)
|
||||
```bash
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--set auth.apiKey=YOUR_SECURE_KEY
|
||||
```
|
||||
|
||||
### 2. Deploy Agents to Non-Kubernetes Infrastructure
|
||||
|
||||
On each VM, bare-metal server, or appliance (via proxy agent):
|
||||
```bash
|
||||
# Linux amd64
|
||||
curl -sSL https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \
|
||||
-o /usr/local/bin/certctl-agent
|
||||
chmod +x /usr/local/bin/certctl-agent
|
||||
|
||||
# Config
|
||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||
CERTCTL_SERVER_URL=http://certctl-control-plane:8443
|
||||
CERTCTL_API_KEY=your-api-key
|
||||
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
|
||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||
EOF
|
||||
sudo chmod 600 /etc/certctl/agent.env
|
||||
|
||||
# Start
|
||||
sudo systemctl start certctl-agent
|
||||
```
|
||||
|
||||
### 3. Enable Discovery Scanning
|
||||
|
||||
Agents scan configured directories and report back all existing certs. In the dashboard:
|
||||
- **Discovery** page: all found certs grouped by agent
|
||||
- Claim cert-manager certs to link them with Kubernetes metadata
|
||||
- Dismiss obsolete certs
|
||||
|
||||
### 4. Configure Shared Issuers
|
||||
|
||||
Set up the same issuer certctl uses for non-Kubernetes certs:
|
||||
- **ACME** (Let's Encrypt, for public certs)
|
||||
- **step-ca** (Smallstep, for internal certs)
|
||||
- **Vault PKI** (planned) (HashiCorp Vault, for enterprise PKI)
|
||||
- **Private CA** (your own internal root CA)
|
||||
|
||||
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
|
||||
|
||||
### 5. Create Policies for Non-Kubernetes Certs
|
||||
|
||||
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||
- **Name:** e.g., "VM Certificate Policy"
|
||||
- **Type:** `expiration_window` or `key_algorithm` (enforce renewal thresholds or crypto requirements)
|
||||
- **Severity:** `high`
|
||||
- **Config:** set your enforcement parameters
|
||||
|
||||
Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other compliance rules across your fleet.
|
||||
|
||||
### 6. View Unified Inventory
|
||||
|
||||
**Dashboard** shows:
|
||||
- Certificate status heatmap (all 1000 certs: cert-manager + certctl)
|
||||
- Renewal job trends (both types)
|
||||
- Expiration timeline (30/60/90 days)
|
||||
- Agent fleet status (all infrastructure)
|
||||
|
||||
**Certificates** page filters by issuer (show me all ACME certs, or all step-ca certs):
|
||||
- cert-manager certs discovered from Kubernetes nodes
|
||||
- certctl-managed certs on VMs
|
||||
- Network appliance certs auto-discovered
|
||||
|
||||
## Shared Infrastructure
|
||||
|
||||
If cert-manager and certctl both use the same CA:
|
||||
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
|
||||
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
|
||||
- **Vault PKI** (planned): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||
|
||||
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
|
||||
|
||||
## Key Differences from cert-manager
|
||||
|
||||
| Feature | cert-manager | certctl |
|
||||
|---------|--------------|---------|
|
||||
| Target | In-cluster (Kubernetes) | Out-of-cluster (VMs, bare metal, appliances) |
|
||||
| Configuration | CRDs (Certificate, ClusterIssuer, Issuer) | API + Dashboard (JSON REST) |
|
||||
| Deployment | Injected into Secret objects, mounted by pods | Agent pulls work, deploys via target-specific API (file, service restart, proxy agent) |
|
||||
| Renewal | Controller watches Certificate CRDs, triggers renewal when needed | Scheduler checks thresholds, agents poll for work |
|
||||
| Audit | Kubernetes event log | Immutable append-only audit trail |
|
||||
| Visibility | Per-namespace, per-resource | Fleet-wide, unified inventory |
|
||||
|
||||
## Future Integration
|
||||
|
||||
On the roadmap (V4): **cert-manager external issuer** — certctl acts as a ClusterIssuer backend for Kubernetes. This would allow cert-manager to request certificates from certctl, which could issue them via any of its connectors (step-ca, Vault, private CA, etc.). Pure integration play; no breaking changes.
|
||||
|
||||
For now: cert-manager handles Kubernetes, certctl handles everything else. They coexist seamlessly.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
|
||||
2. Explore [Architecture](./architecture.md#agents) for deployment architecture
|
||||
3. Read about [Discovery Scanning](./quickstart.md#certificate-discovery) to auto-find certs
|
||||
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||
@@ -183,13 +183,14 @@ Each section includes:
|
||||
|
||||
- **Health Endpoint** — `GET /health` returns 200 OK with service status. Consumed by Docker health checks and Kubernetes probes.
|
||||
- **Readiness Endpoint** — `GET /ready` returns 200 OK when the database is connected and migrations are applied.
|
||||
- **Background Scheduler Monitoring** — 6 background loops run on a fixed schedule:
|
||||
- **Background Scheduler Monitoring** — 7 background loops run on a fixed schedule:
|
||||
- Renewal loop: every 1 hour, scans for certificates approaching renewal threshold
|
||||
- Job processor loop: every 30 seconds, picks up pending/waiting jobs and advances their state
|
||||
- Health check loop: every 2 minutes, pings agents to detect downtime
|
||||
- Notification dispatcher loop: every 1 minute, sends queued alerts
|
||||
- Short-lived cert expiry loop: every 30 seconds, marks expired short-lived credentials
|
||||
- Network scanner loop: every 6 hours, scans enabled TLS endpoints for certificate discovery
|
||||
- Digest emailer loop: every 24 hours, sends scheduled certificate digest email to configured recipients
|
||||
Each loop includes error handling and logs failures via structured slog.
|
||||
- **Metrics Endpoints** — Two formats for monitoring integration:
|
||||
- `GET /api/v1/metrics` — JSON object with gauges, counters, and uptime for custom dashboards
|
||||
@@ -452,7 +453,7 @@ Each section includes:
|
||||
| | Metrics JSON Endpoint | `GET /api/v1/metrics` (gauges, counters, uptime) | ✅ | ✅ | Set thresholds, configure alerting |
|
||||
| | Stats API (time-series) | `GET /api/v1/stats/*` (summary, status, expiration, jobs, issuance) | ✅ | ✅ | Integrate into dashboards, SLO tracking |
|
||||
| | Structured Logging | `slog` middleware with request IDs | ✅ | ✅ | Aggregate logs to SIEM, define retention policy |
|
||||
| | Background Scheduler | 6 loops (renewal 1h, jobs 30s, health 2m, notifications 1m, short-lived 30s, network scan 6h) | ✅ | ✅ | Alert on scheduler loop failures |
|
||||
| | Background Scheduler | 7 loops (renewal 1h, jobs 30s, health 2m, notifications 1m, short-lived 30s, network scan 6h, digest 24h) | ✅ | ✅ | Alert on scheduler loop failures |
|
||||
| **CC7.2** Anomaly Detection | Immutable API Audit Trail | `internal/api/middleware/audit.go`, `GET /api/v1/audit` | ✅ | Enhanced (SIEM export) | Integrate into SIEM, search for anomalies, archive long-term |
|
||||
| | Expiration Threshold Alerting | Configurable per-policy (default 30/14/7/0 days) | ✅ | ✅ | Configure thresholds, integrate notifications |
|
||||
| | Status Auto-Transitions | Active → Expiring (30d) → Expired (0d) | ✅ | ✅ | Monitor status changes in audit trail |
|
||||
|
||||
+4
-4
@@ -312,12 +312,12 @@ The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used b
|
||||
|
||||
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||
|
||||
### Planned Issuers
|
||||
### Coming in V2.1
|
||||
|
||||
The following issuer connectors are planned for future milestones:
|
||||
The following issuer connectors are planned for the v2.1.0 release:
|
||||
|
||||
- **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).
|
||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine (`/v1/{mount}/sign/{role}` API) for organizations using Vault as their internal CA. Token auth, configurable mount and role.
|
||||
- **DigiCert** — Commercial CA integration via DigiCert CertCentral REST API. Async order model (submit → poll for completion). OV/EV certificate support.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1153,7 +1153,7 @@ flowchart TB
|
||||
API["REST API\nGo net/http"]
|
||||
SVC["Service Layer\nBusiness Logic"]
|
||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
||||
SCHED["Scheduler\n6 background loops"]
|
||||
SCHED["Scheduler\n7 background loops"]
|
||||
CONN["Connector Registry\nIssuer + Target + Notifier"]
|
||||
end
|
||||
|
||||
|
||||
+5
-4
@@ -1051,7 +1051,7 @@ curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/jobs/j-abc123/approve -d '{"reas
|
||||
3. **Approve** → `POST /api/v1/jobs/{id}/approve` → Job → `Running`
|
||||
4. **Reject** → `POST /api/v1/jobs/{id}/reject` + reason → Job → `Cancelled`
|
||||
|
||||
### Background Scheduler (6 loops)
|
||||
### Background Scheduler (7 loops)
|
||||
| Loop | Interval | Task |
|
||||
|------|----------|------|
|
||||
| **Renewal Checker** | 1 hour | Scan policies; trigger renewals if cert expires soon |
|
||||
@@ -1060,6 +1060,7 @@ curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/jobs/j-abc123/approve -d '{"reas
|
||||
| **Notification Processor** | 1 minute | Send queued notifications (email, Slack, webhook, etc.) |
|
||||
| **Short-Lived Cleanup** | 30 seconds | Audit short-lived credential expirations |
|
||||
| **Network Scanner** | 6 hours | Scan enabled network targets; discover TLS certificates |
|
||||
| **Digest Emailer** | 24 hours | Send HTML certificate digest email to configured recipients |
|
||||
|
||||
All loops have configurable intervals via environment variables (`CERTCTL_SCHEDULER_*_INTERVAL`).
|
||||
|
||||
@@ -1267,7 +1268,7 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
||||
### Docker Compose Deployment
|
||||
- **Services** — PostgreSQL 16, certctl server, agent
|
||||
- **Health Checks** — On all services (server health check, database readiness)
|
||||
- **Seed Data** — Demo dataset with 15 certs, 5 agents, 5 targets, policies, audit events
|
||||
- **Seed Data** — Demo dataset with 35 certs across 5 issuers, 8 agents, 8 targets, 90 days of job history, discovery data, network scans, policies, audit events
|
||||
- **Credentials** — Environment variables in `.env` file; app.key for API key
|
||||
|
||||
### PostgreSQL Schema
|
||||
@@ -1468,8 +1469,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
||||
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
||||
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
||||
| **DigiCert issuer** | ✗ | ✓ | Planned V3 |
|
||||
| **CT Log monitoring** | ✗ | ✓ | Planned V3 |
|
||||
| **DigiCert issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
||||
| **Vault PKI issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# Migrate from acme.sh to certctl
|
||||
|
||||
You use acme.sh to automate Let's Encrypt renewal across multiple servers. It works — but without centralized visibility, deployment verification, or policy enforcement.
|
||||
|
||||
This guide walks through moving your acme.sh workload to certctl while keeping your existing DNS provider setup.
|
||||
|
||||
## Why Migrate
|
||||
|
||||
**acme.sh strength:** Lightweight agent, works everywhere, integrates with any DNS provider via shell script hooks.
|
||||
|
||||
**acme.sh limitations:**
|
||||
- No inventory visibility — certificates scattered across servers, no unified view of expiry dates or renewal status
|
||||
- No deployment verification — cron job succeeds even if cert doesn't actually take effect on the service
|
||||
- No policy enforcement — no way to require approval, audit who renewed what, or prevent misconfigurations
|
||||
- No multi-server orchestration — each server manages its own renewals; no way to batch test or rollback
|
||||
|
||||
certctl adds a control plane that sees all your certificates, deploys with verification, enforces policy, and provides a complete audit trail. You keep the DNS-01 challenge scripts you already have.
|
||||
|
||||
## What You Keep
|
||||
|
||||
- **Existing certificates** — discovered automatically during migration, claimed in the dashboard
|
||||
- **DNS provider scripts** — acme.sh's `dns_*` hooks are shell-script compatible with certctl's DNS-01 implementation
|
||||
- **Same Let's Encrypt account** — ACME issuer in certctl uses the same account and email
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 1. Deploy certctl Server
|
||||
|
||||
Start with Docker Compose (5 minutes):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl/deploy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Access the dashboard at `http://localhost:8443` with API key from `.env` file.
|
||||
|
||||
### 2. Deploy Agents
|
||||
|
||||
On each server running acme.sh certs, install the certctl agent:
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||
# Prompted for server URL and API key
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# Download and install agent binary
|
||||
wget https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64
|
||||
chmod +x certctl-agent-linux-amd64
|
||||
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
|
||||
|
||||
# Create systemd unit
|
||||
sudo tee /etc/systemd/system/certctl-agent.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=certctl Agent
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/certctl-agent
|
||||
Environment="CERTCTL_SERVER_URL=https://certctl.internal:8443"
|
||||
Environment="CERTCTL_API_KEY=your-api-key-here"
|
||||
Environment="CERTCTL_DISCOVERY_DIRS=~/.acme.sh"
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now certctl-agent
|
||||
```
|
||||
|
||||
### 3. Discover Existing acme.sh Certificates
|
||||
|
||||
acme.sh stores certificates in `~/.acme.sh/<domain>/` (or `/etc/acme.sh/` if installed system-wide).
|
||||
|
||||
When you start the agent with `CERTCTL_DISCOVERY_DIRS` pointing to those directories, it scans for existing PEM/DER certificates and reports fingerprints to the control plane. The dashboard's **Discovery** page shows what was found.
|
||||
|
||||
Example agent systemd service (using home directory):
|
||||
|
||||
```bash
|
||||
Environment="CERTCTL_DISCOVERY_DIRS=/home/user/.acme.sh"
|
||||
```
|
||||
|
||||
Or for system-wide acme.sh:
|
||||
|
||||
```bash
|
||||
Environment="CERTCTL_DISCOVERY_DIRS=/etc/acme.sh"
|
||||
```
|
||||
|
||||
### 4. Claim Discovered Certificates
|
||||
|
||||
In the **Discovery** page:
|
||||
1. Review the "Unmanaged" certificates found by the agent
|
||||
2. Click **Claim** on each acme.sh certificate
|
||||
3. Enter the managed certificate ID to link it (e.g., `mc-api-prod`)
|
||||
|
||||
Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status.
|
||||
|
||||
### 5. Create an ACME Issuer
|
||||
|
||||
In **Issuers** → **+ New Issuer:**
|
||||
|
||||
1. Select **ACME** from the issuer type grid
|
||||
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and config
|
||||
|
||||
Or configure via environment variables:
|
||||
```bash
|
||||
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||
export CERTCTL_ACME_EMAIL=your-email@example.com # same as your acme.sh account
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||
```
|
||||
|
||||
### 6. Adapt Your DNS Provider Scripts
|
||||
|
||||
acme.sh uses `dns_*` hooks (e.g., `dns_cloudflare`) with predictable argument patterns. certctl's DNS-01 uses the same pattern, so your scripts often work with zero changes.
|
||||
|
||||
**acme.sh pattern:**
|
||||
```bash
|
||||
# acme.sh invokes: dns_cloudflare_add "domain" "record" "value"
|
||||
dns_cloudflare_add() {
|
||||
local full_domain=$1
|
||||
local record_name=$2
|
||||
local record_value=$3
|
||||
# ... DNS API call to create TXT record ...
|
||||
}
|
||||
```
|
||||
|
||||
**certctl pattern:**
|
||||
```bash
|
||||
# certctl invokes: /path/to/dns-present-script
|
||||
# Scripts receive environment variables:
|
||||
#!/bin/bash
|
||||
# CERTCTL_DNS_DOMAIN — domain name (e.g., "example.com")
|
||||
# CERTCTL_DNS_FQDN — full record name (e.g., "_acme-challenge.example.com")
|
||||
# CERTCTL_DNS_VALUE — TXT record value (key authorization digest)
|
||||
# CERTCTL_DNS_TOKEN — ACME challenge token
|
||||
# Create TXT record at "${CERTCTL_DNS_FQDN}" with value "${CERTCTL_DNS_VALUE}"
|
||||
```
|
||||
|
||||
**Example: Cloudflare DNS-01 adapter**
|
||||
|
||||
If you have an acme.sh Cloudflare hook, adapt it:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /etc/certctl/dns/cloudflare-present.sh
|
||||
set -e
|
||||
|
||||
# certctl passes these environment variables:
|
||||
# CERTCTL_DNS_DOMAIN — domain name
|
||||
# CERTCTL_DNS_FQDN — full record name (e.g., "_acme-challenge.example.com")
|
||||
# CERTCTL_DNS_VALUE — TXT record value
|
||||
# CERTCTL_DNS_TOKEN — ACME challenge token
|
||||
|
||||
# Call your existing Cloudflare API (example using curl)
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
|
||||
-H "X-Auth-Email: ${CF_EMAIL}" \
|
||||
-H "X-Auth-Key: ${CF_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"type\":\"TXT\",\"name\":\"${CERTCTL_DNS_FQDN}\",\"content\":\"${CERTCTL_DNS_VALUE}\"}"
|
||||
|
||||
echo "Created ${CERTCTL_DNS_FQDN}"
|
||||
```
|
||||
|
||||
DNS cleanup:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /etc/certctl/dns/cloudflare-cleanup.sh
|
||||
|
||||
# certctl passes these environment variables:
|
||||
# CERTCTL_DNS_DOMAIN — domain name
|
||||
# CERTCTL_DNS_FQDN — full record name (e.g., "_acme-challenge.example.com")
|
||||
# CERTCTL_DNS_VALUE — TXT record value
|
||||
# CERTCTL_DNS_TOKEN — ACME challenge token
|
||||
|
||||
# Query and delete the TXT record
|
||||
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
|
||||
-H "X-Auth-Email: ${CF_EMAIL}" \
|
||||
-H "X-Auth-Key: ${CF_KEY}"
|
||||
```
|
||||
|
||||
Configure the ACME issuer via environment variables:
|
||||
|
||||
```bash
|
||||
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cloudflare-cleanup.sh
|
||||
```
|
||||
|
||||
Or create the issuer through the dashboard: **Issuers** → **+ New Issuer** → select **ACME** → fill in the config fields.
|
||||
|
||||
### 7. Create Renewal Policies
|
||||
|
||||
In **Policies** → **+ New Policy:**
|
||||
|
||||
- **Name:** e.g., "ACME DNS-01 Policy"
|
||||
- **Type:** `expiration_window` (enforces renewal thresholds)
|
||||
- **Severity:** `high`
|
||||
- **Config:** set your renewal window (default: 30 days before expiry)
|
||||
|
||||
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails on top.
|
||||
|
||||
### 8. Phase Out acme.sh Cron
|
||||
|
||||
Once you verify renewals work via certctl (manually trigger one in the dashboard first), remove the acme.sh cron job:
|
||||
|
||||
```bash
|
||||
# Remove acme.sh from crontab
|
||||
crontab -e
|
||||
# Delete the line: "0 0 * * * /home/user/.acme.sh/acme.sh --cron --home /home/user/.acme.sh"
|
||||
|
||||
# OR disable the cron service if installed
|
||||
sudo systemctl disable acme-renew.timer
|
||||
```
|
||||
|
||||
## DNS Script Compatibility
|
||||
|
||||
Most acme.sh DNS provider hooks need only minor changes:
|
||||
|
||||
| acme.sh | certctl |
|
||||
|---------|---------|
|
||||
| Called on every renewal | Called once per challenge window |
|
||||
| Receives: domain, record name, record value as arguments | Receives: `CERTCTL_DNS_DOMAIN`, `CERTCTL_DNS_FQDN`, `CERTCTL_DNS_VALUE`, `CERTCTL_DNS_TOKEN` as environment variables |
|
||||
| Must support multiple concurrent records | Same — cleanup removes the specific token |
|
||||
| Environment variables for credentials | Same — pass via agent systemd `Environment=` or `.env` file |
|
||||
|
||||
**Real example:** If you use Route53, acme.sh's `dns_aws` hook submits via AWS CLI. Adapt it to use `${CERTCTL_DNS_FQDN}` and `${CERTCTL_DNS_VALUE}` environment variables instead of positional arguments, and it works with certctl's DNS-01.
|
||||
|
||||
## Coexistence Period
|
||||
|
||||
During migration, run both acme.sh and certctl in parallel:
|
||||
|
||||
1. Keep acme.sh cron running (low overhead, serves as fallback)
|
||||
2. Configure certctl policies and test renewal on 1-2 non-critical domains
|
||||
3. Monitor certctl's audit trail and deployment logs
|
||||
4. Once confident, disable acme.sh cron on those domains
|
||||
5. Roll out to remaining domains
|
||||
|
||||
This way, if certctl renewal fails, acme.sh's cron still renews the cert (you'll see duplicate renewals in the audit trail, but no gap).
|
||||
|
||||
## Next: DNS-PERSIST-01 (Zero-Touch Renewals)
|
||||
|
||||
After migrating to certctl + DNS-01, consider upgrading to **DNS-PERSIST-01**. Instead of creating/deleting DNS records on every renewal, you create one persistent TXT record at `_validation-persist.<domain>` that never changes. Let's Encrypt then validates against that standing record forever.
|
||||
|
||||
Benefits:
|
||||
- **Zero operational overhead per renewal** — no DNS API calls during renewal
|
||||
- **Auditable** — DNS record created once, visible to the team, never modified
|
||||
- **Vendor-agnostic** — works with any DNS provider that supports TXT records
|
||||
|
||||
To enable:
|
||||
|
||||
```bash
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01
|
||||
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN=letsencrypt.org
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||
```
|
||||
|
||||
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
||||
|
||||
## Support
|
||||
|
||||
See [Connector Configuration](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts).
|
||||
|
||||
See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Migrating from Certbot to certctl
|
||||
|
||||
You have 50 Let's Encrypt certificates across 10 servers, managed by a mix of Certbot cron jobs and manual renewals. Certbot handles issuance, but you lack inventory visibility, centralized alerting, and audit trails. This guide walks you through moving to certctl while keeping your existing certificates and ACME account.
|
||||
|
||||
## Why Migrate
|
||||
|
||||
Certbot renews certs in isolation. If a renewal fails on one server, you don't know until the cert expires. certctl gives you a single pane of glass: see all certs across all servers, get alerts 30/14/7 days before expiry, track who renewed what when, and verify each deployment succeeded via TLS fingerprint validation.
|
||||
|
||||
## What You Keep
|
||||
|
||||
- Your existing Certbot ACME account key and Let's Encrypt account
|
||||
- All issued certificates in `/etc/letsencrypt/live/`
|
||||
- Certbot's renewal history and hooks
|
||||
|
||||
You will not re-issue any certificates. certctl discovers them and takes over renewal scheduling.
|
||||
|
||||
## Step-by-Step Migration
|
||||
|
||||
### 1. Deploy certctl Control Plane
|
||||
|
||||
Option A: Docker Compose (quickest for evaluation)
|
||||
```bash
|
||||
cd /opt/certctl
|
||||
docker compose up -d
|
||||
# Dashboard & API: http://localhost:8443
|
||||
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
|
||||
```
|
||||
|
||||
Option B: Kubernetes (Helm)
|
||||
```bash
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--set auth.apiKey=YOUR_SECURE_KEY
|
||||
```
|
||||
|
||||
### 2. Deploy Agents to Each Server
|
||||
|
||||
On each of your 10 servers running Certbot:
|
||||
|
||||
```bash
|
||||
# Linux amd64 (adjust for your architecture)
|
||||
curl -sSL https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \
|
||||
-o /usr/local/bin/certctl-agent
|
||||
chmod +x /usr/local/bin/certctl-agent
|
||||
|
||||
# Create config
|
||||
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
|
||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||
CERTCTL_SERVER_URL=http://certctl-control-plane.example.com:8443
|
||||
CERTCTL_API_KEY=your-api-key-here
|
||||
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
|
||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||
EOF
|
||||
sudo chmod 600 /etc/certctl/agent.env
|
||||
|
||||
# Start agent
|
||||
sudo systemctl start certctl-agent # if installed via script
|
||||
# OR manually:
|
||||
sudo certctl-agent --server https://... --api-key ... --discovery-dirs /etc/letsencrypt/live
|
||||
```
|
||||
|
||||
The agent will scan `/etc/letsencrypt/live/` and report all discovered certificates to the control plane.
|
||||
|
||||
### 3. Triage Discovered Certificates
|
||||
|
||||
In the certctl dashboard, go to **Discovery**:
|
||||
- See all discovered certs grouped by agent
|
||||
- Status shows "Unmanaged" for certificates not yet claimed
|
||||
- For each Certbot cert, click **Claim** and link it to managed inventory
|
||||
|
||||
The control plane now knows about all 50 certs and where they live.
|
||||
|
||||
### 4. Configure ACME Issuer
|
||||
|
||||
Go to **Issuers** → **+ New Issuer**:
|
||||
1. Select **ACME** from the issuer type grid
|
||||
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and any required config
|
||||
|
||||
Alternatively, configure via environment variables before starting the server:
|
||||
```bash
|
||||
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE=http-01 # or dns-01 for wildcard certs
|
||||
```
|
||||
|
||||
For DNS-01, also set:
|
||||
```bash
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/present.sh
|
||||
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cleanup.sh
|
||||
```
|
||||
|
||||
certctl uses the same Let's Encrypt account; no new credentials needed.
|
||||
|
||||
### 5. Create Renewal Policies
|
||||
|
||||
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||
- Name: e.g., "ACME Renewal Policy"
|
||||
- Type: `expiration_window` (to enforce renewal thresholds)
|
||||
- Severity: `high`
|
||||
- Config: set your renewal threshold (default: 30 days before expiry)
|
||||
|
||||
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails (key algorithm requirements, expiration windows, etc.).
|
||||
|
||||
### 6. Disable Certbot Cron, One Server at a Time
|
||||
|
||||
On the first server (start with a low-traffic one):
|
||||
|
||||
```bash
|
||||
# Stop Certbot renewal
|
||||
sudo systemctl disable certbot.timer
|
||||
sudo systemctl stop certbot.timer
|
||||
|
||||
# Or remove the cron job
|
||||
sudo rm /etc/cron.d/certbot # if managed by cron
|
||||
```
|
||||
|
||||
Monitor that server in the certctl dashboard. Certctl will renew the cert ~30 days before expiry.
|
||||
|
||||
### 7. Verify First Renewal Succeeds
|
||||
|
||||
Wait for the renewal to trigger (or manually trigger it in **Certificates** → select cert → **Renew**). Check the dashboard:
|
||||
- **Certificates** page: status transitions from `Active` to `Renewing` to `Active`
|
||||
- **Jobs** page: renewal job shows `Completed` status
|
||||
- **Verification** tab: TLS check confirms the new cert is deployed and live
|
||||
|
||||
After verifying, disable Certbot on the remaining 9 servers.
|
||||
|
||||
### 8. Enable Alerting
|
||||
|
||||
Configure notifiers via environment variables before starting the server:
|
||||
```bash
|
||||
# Example: Slack alerting
|
||||
export CERTCTL_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||
docker compose up -d
|
||||
|
||||
# Or email alerting
|
||||
export CERTCTL_SMTP_HOST=smtp.gmail.com
|
||||
export CERTCTL_SMTP_PORT=587
|
||||
export CERTCTL_SMTP_USERNAME=your-email@gmail.com
|
||||
export CERTCTL_SMTP_PASSWORD=your-app-password
|
||||
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
|
||||
docker compose up -d
|
||||
|
||||
# Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY
|
||||
```
|
||||
|
||||
Now you get 30/14/7-day warnings before any cert expires, across all 10 servers, in one place.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 60 seconds vs. cron running once a day).
|
||||
- **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently.
|
||||
- **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds.
|
||||
- **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires.
|
||||
|
||||
## Coexistence and Rollback
|
||||
|
||||
During migration, certctl and Certbot can run simultaneously. The agent will discover Certbot certs even while Certbot continues renewing them. Run both for a week to build confidence.
|
||||
|
||||
**If you need to rollback**: Re-enable Certbot cron on any server:
|
||||
```bash
|
||||
sudo systemctl enable certbot.timer
|
||||
sudo systemctl start certbot.timer
|
||||
```
|
||||
|
||||
certctl will stop renewing that cert when the policy is disabled. Certbot resumes as before. Your certificates and ACME account remain untouched.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
||||
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
||||
- Set up [Kubernetes cert-manager integration](./certctl-for-cert-manager-users.md) if you manage in-cluster certs too
|
||||
+14
-13
@@ -105,7 +105,7 @@ Open **http://localhost:8443** in your browser.
|
||||
>
|
||||
> **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 35 demo certificates across 5 issuers, 8 agents, and 90 days of job history — expiring certs, expired certs, active certs, failed renewals, revocations, discovery scans, and approval workflows. A realistic snapshot of what certificate management looks like in a real organization.
|
||||
|
||||
### What you're looking at
|
||||
|
||||
@@ -127,7 +127,7 @@ Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifica
|
||||
|
||||
**"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." Eight agents across Linux, macOS, and Windows platforms—most online, showing OS, architecture, IP, and version metadata. A ninth entry (server-scanner) is the sentinel agent used for network certificate discovery. 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.
|
||||
|
||||
**"What about bulk operations?"** — On the Certificates page, select multiple certificates with checkboxes. A bulk action bar appears: trigger renewal, revoke with reason codes, or reassign ownership — all with progress tracking. At 47-day lifespans with hundreds of certs, bulk operations aren't optional.
|
||||
|
||||
@@ -410,18 +410,19 @@ Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "Wha
|
||||
|
||||
| Resource | Count | Examples |
|
||||
|----------|-------|---------|
|
||||
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
||||
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
||||
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Teams | 6 | Platform, Security, Payments, Frontend, Data, DevOps |
|
||||
| Owners | 6 | Alice, Bob, Carol, Dave, Eve, Frank |
|
||||
| Issuers | 5 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, ZeroSSL (EAB), Custom OpenSSL CA |
|
||||
| Agents | 9 | 8 real agents (linux/darwin/windows, amd64/arm64) + server-scanner (network discovery) |
|
||||
| Targets | 8 | NGINX prod, NGINX staging, NGINX data, HAProxy, Apache, IIS, Traefik, Caddy |
|
||||
| Certificates | 35 | Active, Expiring, Expired, Failed, Revoked, RenewalInProgress, Wildcard, S/MIME |
|
||||
| Jobs | 50+ | 90 days of issuance, renewal, deployment jobs + 2 AwaitingApproval |
|
||||
| Discovered Certs | 12 | Unmanaged (filesystem + network), Managed (linked), Dismissed |
|
||||
| Discovery Scans | 8 | Historical + recent agent filesystem scans + network TLS scans |
|
||||
| Network Scan Targets | 4 | DC1 Web Servers, DC2 Application Tier, DMZ Public Endpoints, Edge Locations |
|
||||
| Audit Events | 55+ | 90 days of lifecycle events (issuance, renewal, deployment, revocation, discovery) |
|
||||
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
||||
| Profiles | 4 | Standard TLS, Internal mTLS, Short-Lived, High Security |
|
||||
| Profiles | 5 | Standard TLS, Internal mTLS, Short-Lived, High Security, S/MIME Email |
|
||||
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
||||
|
||||
## Dashboard Demo Mode
|
||||
|
||||
+653
-39
@@ -39,6 +39,9 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
|
||||
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
||||
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
||||
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
||||
- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration)
|
||||
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
|
||||
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
||||
- [Release Sign-Off](#release-sign-off)
|
||||
|
||||
---
|
||||
@@ -3434,13 +3437,13 @@ Open `http://localhost:8443` in a browser.
|
||||
|
||||
## Part 20: Background Scheduler
|
||||
|
||||
**What this validates:** The 6 background scheduler loops — renewal checks, job processing, agent health, notification processing, short-lived cert expiry, and network scanning.
|
||||
**What this validates:** The 7 background scheduler loops — renewal checks, job processing, agent health, notification processing, short-lived cert expiry, network scanning, and scheduled digest emailer.
|
||||
|
||||
**Why it matters:** The scheduler is the automation engine. Without it, nothing happens automatically — certs expire unnoticed, jobs sit pending, agents go stale, notifications never fire.
|
||||
|
||||
> **Tip:** Open a second terminal with `docker compose logs -f certctl-server` to watch scheduler log output in real time.
|
||||
|
||||
**Test 20.1.1 — Scheduler startup: all 6 loops registered**
|
||||
**Test 20.1.1 — Scheduler startup: all 7 loops registered**
|
||||
|
||||
```bash
|
||||
docker compose logs certctl-server 2>&1 | grep -i "scheduler\|renewal check\|job processor\|health check\|notification\|short-lived\|network scan" | head -20
|
||||
@@ -5069,46 +5072,657 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
||||
|
||||
---
|
||||
|
||||
## Part 35: ARI (RFC 9702) Scheduler Integration
|
||||
|
||||
Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates.
|
||||
|
||||
### 35.1 ARI Defers Renewal When CA Says "Not Yet"
|
||||
|
||||
**Prerequisite:** ACME issuer configured with `CERTCTL_ACME_ARI_ENABLED=true`, connected to a CA that supports ARI (e.g., Let's Encrypt staging). Certificate within the 30-day expiry window but the CA's `suggestedWindow.start` is in the future.
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI deferral
|
||||
docker logs certctl-server 2>&1 | grep "ARI: renewal not yet suggested"
|
||||
```
|
||||
|
||||
**Expected:** Log line showing `ARI: renewal not yet suggested by CA` with `cert_id`, `suggested_start`, `suggested_end`. No renewal job created for that cert.
|
||||
**PASS if** the scheduler skips renewal job creation when ARI says the window hasn't opened.
|
||||
|
||||
### 35.2 ARI Triggers Renewal When CA Says "Now"
|
||||
|
||||
**Prerequisite:** Same setup as 35.1, but the certificate's ARI `suggestedWindow.start` is in the past (CA is actively suggesting renewal).
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI-triggered renewal
|
||||
docker logs certctl-server 2>&1 | grep "ARI: CA suggests renewal now"
|
||||
|
||||
# Verify renewal job was created
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/jobs?type=renewal" | jq '.data[] | select(.certificate_id == "<cert-id>")'
|
||||
```
|
||||
|
||||
**Expected:** Log line showing `ARI: CA suggests renewal now`. Renewal job created with `renewal_trigger: ari` in the audit trail.
|
||||
**PASS if** a renewal job is created when ARI indicates the renewal window is open.
|
||||
|
||||
### 35.3 ARI Fallback on Error
|
||||
|
||||
**Prerequisite:** ACME issuer with `CERTCTL_ACME_ARI_ENABLED=true`, but the ARI endpoint is unreachable or returns an error (e.g., network issue, 500 from CA).
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI fallback
|
||||
docker logs certctl-server 2>&1 | grep "ARI check failed, falling back"
|
||||
```
|
||||
|
||||
**Expected:** Warning log `ARI check failed, falling back to threshold-based renewal`. Renewal proceeds normally using the configured expiration thresholds.
|
||||
**PASS if** renewal still works when ARI is unavailable, using threshold-based logic as fallback.
|
||||
|
||||
---
|
||||
|
||||
## Part 36: Agent Work Routing (M31)
|
||||
|
||||
Tests that `GetPendingWork()` returns only jobs scoped to the requesting agent, and that deployment jobs have `agent_id` populated at creation time.
|
||||
|
||||
### 36.1 Multi-Agent Routing
|
||||
|
||||
**Prerequisite:** Two agents registered (`agent-web-01`, `agent-lb-01`), two targets (one per agent), one certificate mapped to both targets. Trigger renewal to create deployment jobs.
|
||||
|
||||
```bash
|
||||
# Poll as agent-web-01 — should only see its deployment job
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-web-01/work" | jq '.[] | .target_id'
|
||||
|
||||
# Poll as agent-lb-01 — should only see its deployment job
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-lb-01/work" | jq '.[] | .target_id'
|
||||
```
|
||||
|
||||
**Expected:** Each agent receives only the deployment job for its assigned target. Agent-web-01 does NOT see agent-lb-01's job and vice versa.
|
||||
**PASS if** each agent's work response contains only jobs for targets it owns.
|
||||
|
||||
### 36.2 Agent With No Targets Gets Empty Work
|
||||
|
||||
**Prerequisite:** Register a new agent with no target assignments.
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-no-targets/work" | jq 'length'
|
||||
```
|
||||
|
||||
**Expected:** Empty array (0 jobs).
|
||||
**PASS if** the response is an empty list.
|
||||
|
||||
### 36.3 Deployment Jobs Have agent_id Populated
|
||||
|
||||
**Prerequisite:** Deployment jobs created via renewal or manual trigger.
|
||||
|
||||
```bash
|
||||
# Check that deployment jobs in the system have agent_id set
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/jobs" | jq '[.data[] | select(.type == "Deployment") | .agent_id] | map(select(. != null)) | length'
|
||||
```
|
||||
|
||||
**Expected:** All deployment jobs for targets with agent assignments have `agent_id` populated.
|
||||
**PASS if** deployment jobs have non-null `agent_id` values.
|
||||
|
||||
---
|
||||
|
||||
## Release Sign-Off
|
||||
|
||||
All 34 parts must pass before tagging v2.1.0.
|
||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||
|
||||
| Section | Pass? | Tester | Date | Notes |
|
||||
|---------|-------|--------|------|-------|
|
||||
| Part 1: Infrastructure & Deployment | ☐ | | | |
|
||||
| Part 2: Authentication & Security | ☐ | | | |
|
||||
| Part 3: Certificate Lifecycle (CRUD) | ☐ | | | |
|
||||
| Part 4: Renewal Workflow | ☐ | | | |
|
||||
| Part 5: Revocation | ☐ | | | |
|
||||
| Part 6: Issuer Connectors | ☐ | | | |
|
||||
| Part 7: Target Connectors & Deployment | ☐ | | | |
|
||||
| Part 8: Agent Operations | ☐ | | | |
|
||||
| Part 9: Job System | ☐ | | | |
|
||||
| Part 10: Policies & Profiles | ☐ | | | |
|
||||
| Part 11: Ownership, Teams & Agent Groups | ☐ | | | |
|
||||
| Part 12: Notifications | ☐ | | | |
|
||||
| Part 13: Observability (JSON + Prometheus) | ☐ | | | |
|
||||
| Part 14: Audit Trail | ☐ | | | |
|
||||
| Part 15: Certificate Discovery (Filesystem + Network) | ☐ | | | |
|
||||
| Part 16: Enhanced Query API | ☐ | | | |
|
||||
| Part 17: CLI Tool | ☐ | | | |
|
||||
| Part 18: MCP Server | ☐ | | | |
|
||||
| Part 19: GUI Testing | ☐ | | | |
|
||||
| Part 20: Background Scheduler | ☐ | | | |
|
||||
| Part 21: Error Handling | ☐ | | | |
|
||||
| Part 22: Performance Spot Checks | ☐ | | | |
|
||||
| Part 23: Structured Logging | ☐ | | | |
|
||||
| Part 24: Documentation Verification | ☐ | | | |
|
||||
| Part 25: Regression Tests | ☐ | | | |
|
||||
| Part 26: EST Server (RFC 7030) | ☐ | | | |
|
||||
| Part 27: Post-Deployment TLS Verification | ☐ | | | |
|
||||
| Part 28: Traefik & Caddy Target Connectors | ☐ | | | |
|
||||
| Part 29: Certificate Export (PEM & PKCS#12) | ☐ | | | |
|
||||
| Part 30: S/MIME & EKU Support | ☐ | | | |
|
||||
| Part 31: OCSP Responder & DER CRL | ☐ | | | |
|
||||
| Part 32: Request Body Size Limits | ☐ | | | |
|
||||
| Part 33: Apache & HAProxy Target Connectors | ☐ | | | |
|
||||
| Part 34: Sub-CA Mode | ☐ | | | |
|
||||
### Automated Prerequisites
|
||||
|
||||
These must be green before starting manual QA:
|
||||
|
||||
| Gate | Pass? | Date | Notes |
|
||||
|------|-------|------|-------|
|
||||
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
||||
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
||||
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
|
||||
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 124 pass, 0 fail, 5 skip |
|
||||
|
||||
### Part 1: Infrastructure & Deployment
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 1.1.1 | PostgreSQL is accepting connections | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.2 | Database schema applied (21 tables) | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.3 | Server liveness probe | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.4 | Server readiness probe | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.5 | Agent container is running | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.6 | Demo seed data loaded (all 9 resource types) | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.2.1 | Server shuts down cleanly on SIGTERM | Manual | ☐ | | |
|
||||
| 1.2.2 | Data persists across full restart | Manual | ☐ | | |
|
||||
| 1.3.1 | Custom port binding | Manual | ☐ | | |
|
||||
| 1.3.2 | Debug logging | Manual | ☐ | | |
|
||||
| 1.3.3 | Auth disabled with explicit none | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.3.4 | Auth none produces warning log | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 2: Authentication & Security
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 2.1.1 | Request without auth header returns 401 | Manual | ☐ | | |
|
||||
| 2.1.2 | Request with wrong API key returns 401 | Manual | ☐ | | |
|
||||
| 2.1.3 | Request with valid API key returns 200 | Manual | ☐ | | |
|
||||
| 2.1.4 | /health accessible without auth (always) | Manual | ☐ | | |
|
||||
| 2.1.5 | /ready accessible without auth (always) | Manual | ☐ | | |
|
||||
| 2.1.6 | /api/v1/auth/info accessible without auth (GUI bootstrap) | Manual | ☐ | | |
|
||||
| 2.1.7 | /api/v1/auth/check with valid key returns 200 | Manual | ☐ | | |
|
||||
| 2.1.8 | /api/v1/auth/check without key returns 401 | Manual | ☐ | | |
|
||||
| 2.2.1 | Burst exceeds limit, returns 429 with Retry-After | Manual | ☐ | | |
|
||||
| 2.2.2 | 429 response includes Retry-After header | Manual | ☐ | | |
|
||||
| 2.2.3 | Rate limit bucket refills after waiting | Manual | ☐ | | |
|
||||
| 2.3.1 | Preflight OPTIONS with allowed origin returns CORS headers | Manual | ☐ | | |
|
||||
| 2.3.2 | Request from disallowed origin has no CORS headers | Manual | ☐ | | |
|
||||
| 2.3.3 | Wildcard CORS mode | Manual | ☐ | | |
|
||||
| 2.4.1 | Private keys never in API responses (certificate detail) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.2 | Private keys never in API responses (certificate versions) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.3 | Private keys never in API responses (agent work) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.4 | Private keys never in server logs | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.5 | API key stored as SHA-256 hash (not plaintext) | Manual | ☐ | | |
|
||||
|
||||
### Part 3: Certificate Lifecycle (CRUD)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 3.1.1 | Create certificate with minimal fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.1.2 | Create certificate with all fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.1.3 | Create certificate with duplicate common_name | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.1 | List certificates with pagination metadata | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.2 | Filter by status | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.3 | Filter by owner | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.4 | Filter by issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.5 | Filter by environment | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.6 | Pagination: page 2 | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.7 | Sort descending by notAfter | Manual | ☐ | | |
|
||||
| 3.2.8 | Sort ascending by commonName | Manual | ☐ | | |
|
||||
| 3.2.9 | Sparse fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.10 | Cursor pagination: first page | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.11 | Cursor pagination: second page | Manual | ☐ | | |
|
||||
| 3.2.12 | Time-range filter: expires_before | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.1 | Get single certificate by ID | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.2 | Get nonexistent certificate returns 404 | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.3 | Update certificate fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.4 | Archive (soft delete) certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.5 | Get archived certificate behavior | Manual | ☐ | | |
|
||||
| 3.4.1 | Get certificate versions | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.4.2 | Get certificate deployments | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.4.3 | Trigger deployment creates a job | Manual | ☐ | | |
|
||||
|
||||
### Part 4: Renewal Workflow
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 4.1.1 | Trigger renewal creates job | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.1.2 | Renewal job appears in jobs list | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.1.3 | Renewal on nonexistent certificate returns 404 | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.2.1 | Server keygen mode: job completes automatically | Manual | ☐ | | |
|
||||
| 4.3.1 | Approve a job | Manual | ☐ | | |
|
||||
| 4.3.2 | Reject a job with reason | Manual | ☐ | | |
|
||||
| 4.4.1 | Agent work endpoint returns pending jobs | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.4.2 | Agent reports job status | Manual | ☐ | | |
|
||||
|
||||
### Part 5: Revocation
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 5.1.1 | Revoke with default reason | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.1.2 | Revoke with reason: keyCompromise | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.1.3 | Revoke with reason: caCompromise | Manual | ☐ | | |
|
||||
| 5.1.4 | Revoke with reason: affiliationChanged | Manual | ☐ | | |
|
||||
| 5.1.5 | Revoke with reason: superseded | Manual | ☐ | | |
|
||||
| 5.1.6 | Revoke with reason: cessationOfOperation | Manual | ☐ | | |
|
||||
| 5.1.7 | Revoke with reason: certificateHold | Manual | ☐ | | |
|
||||
| 5.1.8 | Revoke with reason: privilegeWithdrawn | Manual | ☐ | | |
|
||||
| 5.2.1 | Revoke already-revoked certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.2 | Revoke nonexistent certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.3 | Revoke with invalid reason | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.4 | Revocation appears in audit trail | Manual | ☐ | | |
|
||||
| 5.3.1 | JSON CRL endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.2 | DER CRL endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.3 | OCSP: good response for non-revoked cert | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.4 | OCSP: revoked response for revoked cert | Manual | ☐ | | |
|
||||
| 5.3.5 | OCSP: unknown serial | Manual | ☐ | | |
|
||||
|
||||
### Part 6: Issuer Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 6.1.1 | List issuers shows seed data | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.2 | Get issuer detail | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.3 | Create issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.4 | Update issuer | Manual | ☐ | | |
|
||||
| 6.1.5 | Delete issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.6 | Test issuer connection | Manual | ☐ | | |
|
||||
| 6.1.7 | Create issuer with missing name returns validation error | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.8 | Create issuer with invalid type | Manual | ☐ | | |
|
||||
| 6.2.1 | List ACME issuer with DNS-01 configuration | Manual | ☐ | | |
|
||||
| 6.2.2 | Create ACME issuer with DNS-PERSIST-01 | Manual | ☐ | | |
|
||||
| 6.2.3 | Configure ACME with External Account Binding (ZeroSSL) | Manual | ☐ | | |
|
||||
|
||||
### Part 7: Target Connectors & Deployment
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 7.1.1 | List targets shows seed data | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.2 | Create NGINX target | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.3 | Create Apache target | Manual | ☐ | | |
|
||||
| 7.1.4 | Create HAProxy target | Manual | ☐ | | |
|
||||
| 7.1.5 | Create F5 BIG-IP target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.6 | Create IIS target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.7 | Get target verifies type-specific config stored | Manual | ☐ | | |
|
||||
| 7.1.8 | Update target config | Manual | ☐ | | |
|
||||
| 7.1.9 | Delete target returns 204 | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 8: Agent Operations
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 8.1.1 | Register new agent | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.1.2 | List agents includes new agent | Manual | ☐ | | |
|
||||
| 8.1.3 | Get agent detail with metadata | Manual | ☐ | | |
|
||||
| 8.2.1 | Agent heartbeat updates last_heartbeat_at | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.2.2 | Heartbeat metadata stored | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.2.3 | Heartbeat for nonexistent agent | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.3.1 | Agent work polling returns jobs | Manual | ☐ | | |
|
||||
| 8.3.2 | Agent work polling with no pending work | Manual | ☐ | | |
|
||||
| 8.3.3 | Agent certificate pickup | Manual | ☐ | | |
|
||||
| 8.3.4 | Delete agent for cleanup | Auto | — | 2026-03-30 | Skipped — DELETE not implemented |
|
||||
|
||||
### Part 9: Job System
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 9.1.1 | List jobs with pagination | Auto | ☑ | 2026-03-30 | |
|
||||
| 9.1.2 | Filter jobs by status | Manual | ☐ | | |
|
||||
| 9.1.3 | Filter jobs by type | Manual | ☐ | | |
|
||||
| 9.1.4 | Get job detail | Manual | ☐ | | |
|
||||
| 9.1.5 | Get nonexistent job | Auto | ☑ | 2026-03-30 | |
|
||||
| 9.2.1 | Cancel pending job | Manual | ☐ | | |
|
||||
| 9.2.2 | Cancel already-completed job | Manual | ☐ | | |
|
||||
|
||||
### Part 10: Policies & Profiles
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 10.1.1 | List policies | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.2 | Create policy | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.3 | Get policy | Manual | ☐ | | |
|
||||
| 10.1.4 | Update policy | Manual | ☐ | | |
|
||||
| 10.1.5 | Delete policy | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.6 | Policy violations endpoint | Manual | ☐ | | |
|
||||
| 10.1.7 | Invalid policy type returns 400 | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.1 | List profiles | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.2 | Create profile with crypto constraints | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.3 | Get profile | Manual | ☐ | | |
|
||||
| 10.2.4 | Update profile | Manual | ☐ | | |
|
||||
| 10.2.5 | Delete profile | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.6 | Short-lived profile exists (TTL < 1 hour) | Manual | ☐ | | |
|
||||
|
||||
### Part 11: Ownership, Teams & Agent Groups
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 11.1.1 | List teams | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.1.2 | Team CRUD cycle | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.2.1 | Owner CRUD with team assignment | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.2.2 | Get, update, delete owner | Manual | ☐ | | |
|
||||
| 11.3.1 | List agent groups | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.3.2 | Create agent group with dynamic criteria | Manual | ☐ | | |
|
||||
| 11.3.3 | Agent group membership endpoint | Manual | ☐ | | |
|
||||
| 11.3.4 | Delete agent group returns 204 | Manual | ☐ | | |
|
||||
| 11.4.1 | Delete owner with assigned certificates (expect 409) | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.4.2 | Delete issuer with assigned certificates (expect 409) | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.4.3 | Delete team cascades successfully | Manual | ☐ | | |
|
||||
|
||||
### Part 12: Notifications
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 12.1.1 | List notifications with pagination | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.2 | Get single notification | Manual | ☐ | | |
|
||||
| 12.1.3 | Mark notification as read | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.4 | Mark already-read notification (idempotent) | Manual | ☐ | | |
|
||||
| 12.1.5 | Get nonexistent notification | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.6 | Verify notification created from revocation | Manual | ☐ | | |
|
||||
|
||||
### Part 13: Observability
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 13.1.1 | Dashboard summary | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.2 | Certificates by status | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.3 | Expiration timeline | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.4 | Job trends | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.5 | Issuance rate | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.6 | Stats with invalid days parameter | Manual | ☐ | | |
|
||||
| 13.2.1 | JSON metrics endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.2.2 | Metric values are non-negative | Manual | ☐ | | |
|
||||
| 13.2.3 | Uptime is positive | Manual | ☐ | | |
|
||||
| 13.3.1 | Prometheus content type | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.2 | Prometheus output contains HELP lines | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.3 | Prometheus output contains TYPE lines | Manual | ☐ | | |
|
||||
| 13.3.4 | All documented Prometheus metrics present | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.5 | Prometheus metric values are parseable numbers | Manual | ☐ | | |
|
||||
| 13.3.6 | Method not allowed on metrics (POST) | Manual | ☐ | | |
|
||||
|
||||
### Part 14: Audit Trail
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 14.1.1 | List audit events | Auto | ☑ | 2026-03-30 | |
|
||||
| 14.1.2 | Get single audit event | Manual | ☐ | | |
|
||||
| 14.1.3 | Filter audit by time range | Manual | ☐ | | |
|
||||
| 14.1.4 | Filter audit by actor | Manual | ☐ | | |
|
||||
| 14.1.5 | Filter audit by resource type | Auto | ☑ | 2026-03-30 | |
|
||||
| 14.1.6 | Filter audit by action | Manual | ☐ | | |
|
||||
| 14.1.7 | API calls create audit entries | Manual | ☐ | | |
|
||||
| 14.1.8 | Audit immutability (no PUT/DELETE) | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 15: Certificate Discovery
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 15.1.1 | Submit discovery report | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.1.2 | Submit report with multiple certificates | Manual | ☐ | | |
|
||||
| 15.1.3 | Duplicate fingerprint deduplication | Manual | ☐ | | |
|
||||
| 15.1.4 | List discovered certificates | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.1.5 | Filter by status: Unmanaged | Manual | ☐ | | |
|
||||
| 15.1.6 | Filter by agent_id | Manual | ☐ | | |
|
||||
| 15.1.7 | Get discovered certificate detail | Manual | ☐ | | |
|
||||
| 15.1.8 | Claim discovered certificate | Manual | ☐ | | |
|
||||
| 15.1.9 | Dismiss discovered certificate | Manual | ☐ | | |
|
||||
| 15.1.10 | List discovery scans | Manual | ☐ | | |
|
||||
| 15.1.11 | Discovery summary | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.1 | List network scan targets (seed data) | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.2 | Create network scan target | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.3 | Get scan target detail | Manual | ☐ | | |
|
||||
| 15.2.4 | Update scan target | Manual | ☐ | | |
|
||||
| 15.2.5 | Delete scan target | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.6 | Trigger manual scan | Manual | ☐ | | |
|
||||
| 15.2.7 | Invalid CIDR validation | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 16: Enhanced Query API
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 16.1.1 | Sparse fields: only requested fields returned | Manual | ☐ | | |
|
||||
| 16.1.2 | Sort ascending: commonName | Manual | ☐ | | |
|
||||
| 16.1.3 | Sort descending: notAfter | Manual | ☐ | | |
|
||||
| 16.1.4 | Sort by invalid field | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.5 | Cursor pagination first page | Manual | ☐ | | |
|
||||
| 16.1.6 | Cursor pagination second page | Manual | ☐ | | |
|
||||
| 16.1.7 | Time-range: expires_before | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.8 | Time-range: created_after | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.9 | Combined filters | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 17: CLI Tool
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 17.2.1 | List certificates (table format) | Manual | ☐ | | |
|
||||
| 17.2.2 | List certificates (JSON format) | Manual | ☐ | | |
|
||||
| 17.2.3 | Get specific certificate | Manual | ☐ | | |
|
||||
| 17.2.4 | Get nonexistent certificate | Manual | ☐ | | |
|
||||
| 17.2.5 | Renew certificate | Manual | ☐ | | |
|
||||
| 17.2.6 | Revoke certificate with reason | Manual | ☐ | | |
|
||||
| 17.3.1 | List agents | Manual | ☐ | | |
|
||||
| 17.3.2 | List jobs | Manual | ☐ | | |
|
||||
| 17.4.1 | Server status/health | Manual | ☐ | | |
|
||||
| 17.4.2 | CLI version | Manual | ☐ | | |
|
||||
| 17.5.1 | Import single PEM file | Manual | ☐ | | |
|
||||
| 17.6.1 | --server flag overrides env var | Manual | ☐ | | |
|
||||
| 17.6.2 | --api-key flag overrides env var | Manual | ☐ | | |
|
||||
| 17.6.3 | Missing server URL produces error | Manual | ☐ | | |
|
||||
|
||||
### Part 18: MCP Server
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 18.1.1 | Binary builds successfully | Manual | ☐ | | |
|
||||
| 18.1.2 | Startup with valid env vars | Manual | ☐ | | |
|
||||
| 18.1.3 | Missing CERTCTL_SERVER_URL behavior | Manual | ☐ | | |
|
||||
| 18.2.1 | Tool count verification (78 tools) | Manual | ☐ | | |
|
||||
| 18.2.2 | All 16 resource domains present | Manual | ☐ | | |
|
||||
| 18.3.1 | List certificates via MCP | Manual | ☐ | | |
|
||||
| 18.3.2 | Get specific certificate via MCP | Manual | ☐ | | |
|
||||
|
||||
### Part 19: GUI Testing
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 19.1 | Authentication Flow | Manual | ☐ | | |
|
||||
| 19.2 | Dashboard Page | Manual | ☐ | | |
|
||||
| 19.3 | Certificates Page | Manual | ☐ | | |
|
||||
| 19.4 | Certificate Detail Page | Manual | ☐ | | |
|
||||
| 19.5 | Jobs Page — Approval Workflow | Manual | ☐ | | |
|
||||
| 19.6 | Discovery Triage Page | Manual | ☐ | | |
|
||||
| 19.7 | Network Scan Management Page | Manual | ☐ | | |
|
||||
| 19.8 | Other Pages (agents, policies, audit, etc.) | Manual | ☐ | | |
|
||||
| 19.9 | Cross-Cutting (responsive, error states, dark theme) | Manual | ☐ | | |
|
||||
|
||||
### Part 20: Background Scheduler
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 20.1.1 | Scheduler startup: all 7 loops registered | Manual | ☐ | | |
|
||||
| 20.1.2 | Job processor loop fires (30s interval) | Manual | ☐ | | |
|
||||
| 20.1.3 | Agent health check marks offline (2m interval) | Manual | ☐ | | |
|
||||
| 20.1.4 | Notification processor fires (1m interval) | Manual | ☐ | | |
|
||||
| 20.1.5 | Short-lived expiry check (30s interval) | Manual | ☐ | | |
|
||||
| 20.1.6 | Network scanner loop (conditional on env var) | Manual | ☐ | | |
|
||||
| 20.1.7 | Renewal check loop (1h interval — log verification) | Manual | ☐ | | |
|
||||
| 20.1.8 | Scheduler graceful stop | Manual | ☐ | | |
|
||||
|
||||
### Part 21: Error Handling
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 21.1.1 | Malformed JSON body | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.2 | Missing required field | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.3 | Method not allowed | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.4 | Invalid query parameter | Manual | ☐ | | |
|
||||
| 21.1.5 | UTF-8 in common name | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.6 | Concurrent requests (parallel curl) | Manual | ☐ | | |
|
||||
| 21.1.7 | Server survives internal error | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.8 | Empty request body on POST | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 22: Performance Spot Checks
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 22.1.1 | List certificates < 200ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.2 | Stats summary < 500ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.3 | Metrics < 200ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.4 | 50 health checks < 5 seconds total | Manual | ☐ | | |
|
||||
|
||||
### Part 23: Structured Logging
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 23.1.1 | Server logs are valid JSON | Manual | ☐ | | |
|
||||
| 23.1.2 | Log lines contain level field | Manual | ☐ | | |
|
||||
| 23.1.3 | Request ID propagation | Manual | ☐ | | |
|
||||
| 23.1.4 | Error logs at ERROR level | Manual | ☐ | | |
|
||||
| 23.1.5 | No unstructured output in log stream | Manual | ☐ | | |
|
||||
|
||||
### Part 24: Documentation Verification
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 24.1 | OpenAPI spec matches router, README accuracy | Manual | ☐ | | |
|
||||
|
||||
### Part 25: Regression Tests
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 25.1.1 | DELETE endpoints return 204, not 200 | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.2 | per_page exceeding max falls back to default | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.3 | Seed demo network scan targets present | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.4 | GUI delete on FK-restricted entities shows error, not silent f... | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.5 | OpenAPI spec operations match router | Manual | ☐ | | |
|
||||
| 25.1.6 | Go service tests use strings.Contains, not errors.Is | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 26: EST Server (RFC 7030)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 26.1 | GET /.well-known/est/cacerts returns PKCS#7 CA chain | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.2 | GET /cacerts method enforcement | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.3 | POST /.well-known/est/simpleenroll with PEM CSR | Manual | ☐ | | |
|
||||
| 26.4 | POST /simpleenroll with base64-encoded DER CSR | Manual | ☐ | | |
|
||||
| 26.5 | POST /simpleenroll with empty body | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.6 | POST /simpleenroll with invalid CSR | Manual | ☐ | | |
|
||||
| 26.7 | POST /simpleenroll with CSR missing Common Name | Manual | ☐ | | |
|
||||
| 26.8 | POST /simpleenroll method enforcement (GET not allowed) | Manual | ☐ | | |
|
||||
| 26.9 | POST /.well-known/est/simplereenroll (re-enrollment) | Manual | ☐ | | |
|
||||
| 26.10 | GET /simplereenroll method enforcement | Manual | ☐ | | |
|
||||
| 26.11 | GET /.well-known/est/csrattrs returns 204 (no required attrs) | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.12 | POST /csrattrs method enforcement | Manual | ☐ | | |
|
||||
| 26.13 | EST enrollment creates audit event | Manual | ☐ | | |
|
||||
| 26.14 | EST disabled returns 404 | Manual | ☐ | | |
|
||||
| 26.15 | EST with profile binding | Manual | ☐ | | |
|
||||
|
||||
### Part 27: Post-Deployment TLS Verification
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 27.1 | Submit Verification Result (Success) | Manual | ☐ | | |
|
||||
| 27.2 | Submit Verification Result (Failure — Fingerprint Mismatch) | Manual | ☐ | | |
|
||||
| 27.3 | Get Verification Status | Manual | ☐ | | |
|
||||
| 27.4 | Missing Required Fields | Manual | ☐ | | |
|
||||
| 27.5 | Audit Trail | Manual | ☐ | | |
|
||||
| 27.6 | Database Schema Verification | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 28: Traefik & Caddy Target Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 28.1 | Traefik File Provider Deployment | Manual | ☐ | | |
|
||||
| 28.2 | Caddy API Mode Deployment | Manual | ☐ | | |
|
||||
| 28.3 | Caddy File Mode Deployment | Manual | ☐ | | |
|
||||
| 28.4 | Agent Connector Dispatch | Manual | ☐ | | |
|
||||
| 28.5 | Connector Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 29: Certificate Export
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 29.1 | Export PEM (JSON Response) | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.2 | Export PEM (File Download) | Manual | ☐ | | |
|
||||
| 29.3 | Export PEM — Not Found | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.4 | Export PKCS#12 | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.5 | Export PKCS#12 — Empty Password | Manual | ☐ | | |
|
||||
| 29.6 | Export Audit Trail | Manual | ☐ | | |
|
||||
| 29.7 | Export Unit Tests | Manual | ☐ | | |
|
||||
| 29.8 | GUI Export Buttons | Manual | ☐ | | |
|
||||
|
||||
### Part 30: S/MIME & EKU Support
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 30.1 | S/MIME Profile Exists in Seed Data | Auto | ☑ | 2026-03-30 | |
|
||||
| 30.2 | All Five Profiles Present | Auto | ☑ | 2026-03-30 | |
|
||||
| 30.3 | EKU Strings in Profile API | Manual | ☐ | | |
|
||||
| 30.4 | Agent CSR SAN Splitting (Email vs DNS) | Manual | ☐ | | |
|
||||
| 30.5 | EKU Service-Layer Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 31: OCSP Responder & DER CRL
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 31.1 | DER-Encoded CRL | Auto | ☑ | 2026-03-30 | |
|
||||
| 31.2 | DER CRL — Nonexistent Issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 31.3 | OCSP Responder — Good Status | Manual | ☐ | | |
|
||||
| 31.4 | OCSP Responder — Revoked Status | Manual | ☐ | | |
|
||||
| 31.5 | OCSP — Unknown Certificate | Manual | ☐ | | |
|
||||
| 31.6 | Short-Lived Certificate CRL Exemption | Manual | ☐ | | |
|
||||
| 31.7 | OCSP / CRL Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 32: Request Body Size Limits
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 32.1 | Default 1MB Limit | Manual | ☐ | | |
|
||||
| 32.2 | Normal-Sized Requests Work | Auto | ☑ | 2026-03-30 | |
|
||||
| 32.3 | Custom Body Size via Environment Variable | Manual | ☐ | | |
|
||||
| 32.4 | Requests Without Bodies Are Unaffected | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 33: Apache & HAProxy Target Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 33.1 | Create Apache Target | Manual | ☐ | | |
|
||||
| 33.2 | Apache Config — Separate Files | Manual | ☐ | | |
|
||||
| 33.3 | Create HAProxy Target | Manual | ☐ | | |
|
||||
| 33.4 | HAProxy Combined PEM Requirement | Manual | ☐ | | |
|
||||
| 33.5 | Shell Command Injection Prevention | Manual | ☐ | | |
|
||||
| 33.6 | Connector Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 34: Sub-CA Mode
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 34.1 | Self-Signed Mode (Default) | Manual | ☐ | | |
|
||||
| 34.2 | Sub-CA Mode — Configuration | Manual | ☐ | | |
|
||||
| 34.3 | Sub-CA Chain Construction | Manual | ☐ | | |
|
||||
| 34.4 | Sub-CA Validation — Non-CA Cert Rejected | Manual | ☐ | | |
|
||||
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
||||
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
||||
|
||||
### Part 35: ARI (RFC 9702) Scheduler Integration
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 35.a1 | ARI nil fallback — renewal jobs still created | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.a2 | No ARI errors with Local CA issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.a3 | Server healthy after ARI wiring (metrics) | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.1 | ARI defers renewal when CA says "not yet" (requires ACME+ARI) | Manual | ☐ | | |
|
||||
| 35.2 | ARI triggers renewal when CA says "now" (requires ACME+ARI) | Manual | ☐ | | |
|
||||
| 35.3 | ARI fallback on error — threshold-based (requires ACME+ARI) | Manual | ☐ | | |
|
||||
|
||||
### Part 36: Agent Work Routing (M31)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 36.a1 | Agent receives only its deployment jobs | Auto | ☐ | | |
|
||||
| 36.a2 | Agent with no targets gets empty work list | Auto | ☐ | | |
|
||||
| 36.a3 | Deployment jobs have agent_id populated | Auto | ☐ | | |
|
||||
| 36.1 | Multi-agent routing with 2 agents, 2 targets | Manual | ☐ | | |
|
||||
| 36.2 | Agent with no assigned targets gets empty work | Manual | ☐ | | |
|
||||
| 36.3 | Database agent_id populated on deployment jobs | Manual | ☐ | | |
|
||||
|
||||
### Part 37: GUI Completeness (Pre-2.1.0-E)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 37.1 | DigestPage renders preview iframe | Manual | ☐ | | |
|
||||
| 37.2 | DigestPage send button with confirmation modal | Manual | ☐ | | |
|
||||
| 37.3 | ObservabilityPage shows metrics gauges | Manual | ☐ | | |
|
||||
| 37.4 | ObservabilityPage Prometheus config block | Manual | ☐ | | |
|
||||
| 37.5 | ObservabilityPage live Prometheus output | Manual | ☐ | | |
|
||||
| 37.6 | JobDetailPage displays job info and timeline | Manual | ☐ | | |
|
||||
| 37.7 | JobDetailPage verification section for deployment jobs | Manual | ☐ | | |
|
||||
| 37.8 | IssuerDetailPage shows redacted config | Manual | ☐ | | |
|
||||
| 37.9 | IssuerDetailPage test connection button | Manual | ☐ | | |
|
||||
| 37.10 | IssuerDetailPage issued certificates list | Manual | ☐ | | |
|
||||
| 37.11 | TargetDetailPage shows config and agent link | Manual | ☐ | | |
|
||||
| 37.12 | TargetDetailPage deployment history table | Manual | ☐ | | |
|
||||
| 37.13 | JobsPage — job IDs clickable to /jobs/:id | Manual | ☐ | | |
|
||||
| 37.14 | JobsPage — verification column for deployment jobs | Manual | ☐ | | |
|
||||
| 37.15 | IssuersPage — issuer names clickable to /issuers/:id | Manual | ☐ | | |
|
||||
| 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | |
|
||||
| 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | |
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 127 |
|
||||
| — Skipped (preconditions not met in demo) | 5 |
|
||||
| ☐ Manual (requires hands-on verification) | 217 |
|
||||
| **Total** | **349** |
|
||||
|
||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ docker compose up -d
|
||||
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.
|
||||
The demo seeds 35 certificates across 5 issuers, 8 agents, 8 deployment targets, 90 days of job history, discovery scan data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
||||
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# certctl + NGINX + Let's Encrypt
|
||||
|
||||
This example demonstrates certctl's core use case: **automatically manage TLS certificates for NGINX using Let's Encrypt (ACME HTTP-01 challenges).**
|
||||
|
||||
## What This Does
|
||||
|
||||
- Deploys certctl server (control plane) with PostgreSQL
|
||||
- Deploys certctl agent on the same network (in production: on your NGINX server)
|
||||
- Configures Let's Encrypt as the certificate issuer via ACME v2
|
||||
- Demonstrates HTTP-01 challenge solving (requires port 80 open to the internet)
|
||||
- Shows how to set up 3 example domains for certificate enrollment and renewal
|
||||
- Automatically renews certificates 30 days before expiration
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Your Domain (example.com)
|
||||
↓ [HTTP-01 validation, port 80]
|
||||
Let's Encrypt ACME
|
||||
↓ [CSR submission]
|
||||
certctl Server (control plane)
|
||||
↓ [API polling]
|
||||
certctl Agent (on NGINX server)
|
||||
↓ [deploy cert+key]
|
||||
NGINX Reverse Proxy
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Docker & Docker Compose** (v20.10+)
|
||||
2. **A domain name** pointing to your server (e.g., `example.com`)
|
||||
3. **Ports 80 and 443 open** to the internet (ACME HTTP-01 needs port 80)
|
||||
4. **Valid email address** for Let's Encrypt account (errors and renewal notices)
|
||||
|
||||
If you don't have a real domain or can't open port 80, see [Customization Tips](#customization-tips) below.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone or copy this example
|
||||
|
||||
```bash
|
||||
cd examples/acme-nginx
|
||||
```
|
||||
|
||||
### 2. Create a `.env` file with your settings
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
# Your email for Let's Encrypt account
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# Database password (change this in production!)
|
||||
DB_PASSWORD=certctl-demo-password
|
||||
|
||||
# Agent API key (generate a real one in production)
|
||||
AGENT_API_KEY=agent-demo-key
|
||||
|
||||
# Server port (certctl listens here internally on 8443; expose as needed)
|
||||
SERVER_PORT=8443
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. (Optional) Create an NGINX config
|
||||
|
||||
If you have a real domain and want NGINX to route traffic:
|
||||
|
||||
```bash
|
||||
cat > nginx.conf <<'EOF'
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# HTTP block for ACME challenges
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com www.example.com api.example.com;
|
||||
|
||||
# ACME challenge directory (certctl writes validation files here)
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS block (certificates deployed here by certctl agent)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name example.com www.example.com api.example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/example.com.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/example.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass http://upstream-service;
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Or just accept the default empty NGINX config for demonstration.
|
||||
|
||||
### 4. Start the stack
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Monitor logs:
|
||||
```bash
|
||||
docker compose logs -f certctl-server certctl-agent
|
||||
```
|
||||
|
||||
### 5. Access the dashboard
|
||||
|
||||
Navigate to `http://localhost:8443` (or your `SERVER_PORT`)
|
||||
|
||||
You should see:
|
||||
- An empty certificate inventory (no certs issued yet)
|
||||
- One ACME issuer ("iss-acme") configured and ready
|
||||
- One agent ("nginx-agent-01") online and heartbeating
|
||||
|
||||
### 6. Create a certificate profile
|
||||
|
||||
In the certctl dashboard:
|
||||
1. Go to **Profiles** (sidebar)
|
||||
2. Click **New Profile**
|
||||
3. Set:
|
||||
- Name: `acme-prod`
|
||||
- Key Type: `RSA-2048` (or `ECDSA-P256`)
|
||||
- Max TTL: `90 days`
|
||||
- Allowed Key Types: `RSA-2048, ECDSA-P256`
|
||||
4. Save
|
||||
|
||||
### 7. Request a certificate
|
||||
|
||||
In the certctl dashboard:
|
||||
1. Go to **Certificates** (sidebar)
|
||||
2. Click **Request New Certificate**
|
||||
3. Set:
|
||||
- Common Name: `example.com`
|
||||
- SANs: `www.example.com`, `api.example.com` (optional)
|
||||
- Issuer: `iss-acme` (Let's Encrypt)
|
||||
- Profile: `acme-prod`
|
||||
4. Click **Request**
|
||||
|
||||
Behind the scenes:
|
||||
- Server creates an `Issuance` job
|
||||
- Agent polls for work, fetches the job
|
||||
- Agent generates a P-256 key (never sent to server)
|
||||
- Agent submits CSR to server
|
||||
- Server sends CSR to Let's Encrypt ACME
|
||||
- Let's Encrypt provides HTTP-01 challenge token
|
||||
- Server downloads ACME challenge, returns to agent
|
||||
- Agent deploys challenge file to NGINX `/.well-known/acme-challenge/`
|
||||
- Let's Encrypt validates (HTTP GET to `http://example.com/.well-known/acme-challenge/...`)
|
||||
- Let's Encrypt issues certificate
|
||||
- Server receives certificate, passes to agent
|
||||
- Agent deploys cert+key to `/etc/nginx/ssl/example.com.crt` + `.key`
|
||||
- Agent reloads NGINX (`nginx -s reload`)
|
||||
- Certificate is now active
|
||||
|
||||
### 8. View the certificate
|
||||
|
||||
In the dashboard:
|
||||
1. Go to **Certificates**
|
||||
2. Click the certificate to see:
|
||||
- Common name, SANs, serial number
|
||||
- Issuer (Let's Encrypt), not-before/after dates
|
||||
- Status (Active, Expiring in N days, Expired)
|
||||
- Deployment history (timestamps, agent name, target)
|
||||
- Next auto-renewal date (30 days before expiration)
|
||||
|
||||
### 9. Set up automatic renewal
|
||||
|
||||
The server automatically checks for certificates expiring within 30 days and triggers renewal. You can:
|
||||
- Adjust the threshold in the certificate's policy
|
||||
- Manually trigger renewal via dashboard button
|
||||
- View renewal job status and history
|
||||
|
||||
## How It Works
|
||||
|
||||
### Certificate Lifecycle
|
||||
|
||||
1. **Request** — Operator creates certificate request via dashboard or API
|
||||
2. **CSR Generation** — Agent generates private key locally, submits CSR to server
|
||||
3. **ACME Challenge** — Server communicates with Let's Encrypt ACME, obtains challenge
|
||||
4. **Challenge Proof** — Agent deploys challenge proof to NGINX
|
||||
5. **Issuance** — Let's Encrypt validates, issues certificate
|
||||
6. **Deployment** — Agent receives certificate, deploys to NGINX SSL directory
|
||||
7. **Reload** — Agent signals NGINX to reload (`nginx -s reload`)
|
||||
8. **Verification** — Agent optionally verifies the live TLS endpoint (handshake fingerprint)
|
||||
9. **Renewal** — 30 days before expiration, process repeats automatically
|
||||
|
||||
### HTTP-01 Challenge
|
||||
|
||||
ACME HTTP-01 works like this:
|
||||
1. Let's Encrypt generates random token (e.g., `abc123def456`)
|
||||
2. Server returns token to agent
|
||||
3. Agent writes file: `/.well-known/acme-challenge/abc123def456` with value (random key material)
|
||||
4. Let's Encrypt performs HTTP GET to `http://example.com/.well-known/acme-challenge/abc123def456`
|
||||
5. If content matches, domain ownership is proven
|
||||
6. Certificate is issued
|
||||
|
||||
**Requirements:**
|
||||
- Port 80 must be open to the internet
|
||||
- DNS must resolve your domain to your server
|
||||
- NGINX must serve `/.well-known/acme-challenge/` (or certctl mounts a separate directory)
|
||||
|
||||
### Agent Key Generation
|
||||
|
||||
Keys are generated **on the agent**, never on the server:
|
||||
1. Agent creates ECDSA P-256 keypair using `crypto/ecdsa`
|
||||
2. Private key is stored locally on agent at `/var/lib/certctl/keys/` (readable only by certctl process)
|
||||
3. Agent creates CSR (certificate signing request) with private key
|
||||
4. Agent submits CSR to server
|
||||
5. Server never sees the private key
|
||||
6. Certificate is returned, agent stores it alongside key
|
||||
7. Both key and cert used for NGINX deployment
|
||||
|
||||
This keeps private keys in the infrastructure where they're used, following zero-trust principles.
|
||||
|
||||
## Adding More Domains
|
||||
|
||||
### Option 1: Additional SANs on Same Certificate
|
||||
|
||||
Edit the existing certificate in the dashboard:
|
||||
1. Click the certificate
|
||||
2. Edit SANs to add `mail.example.com`, `ftp.example.com`, etc.
|
||||
3. Trigger renewal
|
||||
4. Agent generates new CSR with all SANs
|
||||
5. Let's Encrypt validates each SAN (HTTP-01 for each)
|
||||
6. Single certificate with multiple SANs is issued
|
||||
|
||||
### Option 2: Separate Certificates per Domain
|
||||
|
||||
If you want separate certificates (different issuance schedules, different targets):
|
||||
1. Dashboard → **Certificates** → **Request New Certificate**
|
||||
2. Common Name: `subdomain.example.com`
|
||||
3. Set same issuer and profile
|
||||
4. Request
|
||||
|
||||
Each domain gets its own cert, key, and renewal schedule.
|
||||
|
||||
### Wildcard Certificates (Not HTTP-01)
|
||||
|
||||
HTTP-01 does **not** support wildcard (`*.example.com`). To issue wildcards, use DNS-01 challenge (see [acme-wildcard-dns01](../acme-wildcard-dns01/) example).
|
||||
|
||||
## Customization Tips
|
||||
|
||||
### Using Let's Encrypt Staging (for testing)
|
||||
|
||||
Staging has higher rate limits and doesn't require real domains:
|
||||
|
||||
```bash
|
||||
# In .env or docker-compose.yml override:
|
||||
CERTCTL_ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
```
|
||||
|
||||
Staging certificates won't be trusted by browsers (fake CA), but you can test the full flow without hitting production rate limits.
|
||||
|
||||
### Disabling Port 80 Requirement (Demo Mode)
|
||||
|
||||
If you can't open port 80, use ACME DNS-01 instead (requires DNS provider integration). See [acme-wildcard-dns01](../acme-wildcard-dns01/) example.
|
||||
|
||||
Or use Local CA for internal testing:
|
||||
```bash
|
||||
# Switch issuer to Local CA (not public-trusted, but no challenge needed)
|
||||
CERTCTL_ACME_DIRECTORY_URL= # Leave empty to disable ACME
|
||||
# (then configure Local CA instead)
|
||||
```
|
||||
|
||||
### Custom NGINX Config
|
||||
|
||||
Replace `nginx.conf` with your own before `docker compose up`. The agent doesn't manage the NGINX config — it only deploys certificates. You're responsible for:
|
||||
- Configuring SSL paths (`ssl_certificate`, `ssl_certificate_key`)
|
||||
- Setting up challenge directory (`/.well-known/acme-challenge/`)
|
||||
- Pointing NGINX to agent-deployed certificates
|
||||
|
||||
### Database Persistence
|
||||
|
||||
PostgreSQL data is stored in the `postgres_data` volume. To reset:
|
||||
```bash
|
||||
docker compose down -v # Destroy all volumes
|
||||
```
|
||||
|
||||
### Viewing Agent Logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f certctl-agent
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `Heartbeat successful` — agent is communicating with server
|
||||
- `CSR submitted` — key generation and CSR submission worked
|
||||
- `Deployment succeeded` — certificate deployed to NGINX
|
||||
- `NGINX reload` — signal sent to reload
|
||||
|
||||
### Testing ACME Without Real Domain
|
||||
|
||||
Use `nip.io` (free DNS service):
|
||||
1. Deploy to a server with a public IP
|
||||
2. Use domain: `<your-ip>.nip.io` (e.g., `203.0.113.45.nip.io`)
|
||||
3. Let's Encrypt will validate to that IP
|
||||
4. Change ACME_EMAIL to a real email you control
|
||||
|
||||
## Production Checklist
|
||||
|
||||
Before running in production:
|
||||
|
||||
- [ ] Change `DB_PASSWORD` to a strong random password
|
||||
- [ ] Generate a real API key for the agent (don't use the demo key)
|
||||
- [ ] Enable `CERTCTL_AUTH_TYPE=api-key` and enforce authentication
|
||||
- [ ] Use Let's Encrypt production directory (not staging)
|
||||
- [ ] Configure `CERTCTL_CORS_ORIGINS` to restrict cross-origin access
|
||||
- [ ] Use `CERTCTL_KEYGEN_MODE=agent` (default, but verify)
|
||||
- [ ] Set `CERTCTL_LOG_LEVEL=warn` to reduce log noise
|
||||
- [ ] Configure email notifications for certificate expiration alerts
|
||||
- [ ] Set up log aggregation (Datadog, ELK, Splunk, etc.)
|
||||
- [ ] Use docker secrets or external secret manager for credentials (not .env)
|
||||
- [ ] Run agent on actual NGINX servers (not co-located with server for HA)
|
||||
- [ ] Set up monitoring and alerting on agent heartbeat and job completion
|
||||
- [ ] Implement backup/restore for PostgreSQL
|
||||
- [ ] Use TLS for certctl server (terminate at reverse proxy or load balancer)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent heartbeat failing
|
||||
```bash
|
||||
docker compose logs certctl-agent
|
||||
# Check: CERTCTL_SERVER_URL, CERTCTL_API_KEY, network connectivity
|
||||
```
|
||||
|
||||
### ACME challenge failing
|
||||
```bash
|
||||
# Ensure port 80 is open: curl http://example.com/.well-known/acme-challenge/test
|
||||
# Check NGINX is running and serving /.well-known/acme-challenge/
|
||||
# Verify DNS resolves domain to your server: dig example.com
|
||||
```
|
||||
|
||||
### NGINX reload failing
|
||||
Check agent permissions on NGINX socket and that NGINX is reachable from agent container.
|
||||
|
||||
### Let's Encrypt rate limited
|
||||
Let's Encrypt has rate limits (50 certs per domain per week). Use staging to test, or wait a week.
|
||||
|
||||
### Certificate not deployed to NGINX
|
||||
Check agent logs for deployment errors. Verify `/etc/nginx/ssl` volume is writable by agent container.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **Wildcard certificates**: See [acme-wildcard-dns01](../acme-wildcard-dns01/) example
|
||||
- **Multiple issuers**: See [multi-issuer](../multi-issuer/) example
|
||||
- **Private CA**: See [private-ca-traefik](../private-ca-traefik/) example
|
||||
- **Dashboard deep dive**: Read [docs/quickstart.md](../../docs/quickstart.md)
|
||||
- **REST API**: Explore [api/openapi.yaml](../../api/openapi.yaml)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check [docs/troubleshooting.md](../../docs/troubleshooting.md)
|
||||
- Open an issue on GitHub
|
||||
- Review server and agent logs: `docker compose logs -f`
|
||||
@@ -0,0 +1,146 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL database for certctl
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-postgres-acme-nginx
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl server (control plane)
|
||||
certctl-server:
|
||||
image: ghcr.io/shankar0123/certctl-server:latest
|
||||
container_name: certctl-server-acme-nginx
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
|
||||
# Auth (disabled for demo; production should use API keys)
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
|
||||
# CORS (allow agent communication)
|
||||
CERTCTL_CORS_ORIGINS: '*'
|
||||
|
||||
# Key generation mode (agent-side in production, server-side for demo)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
|
||||
# ACME issuer configuration
|
||||
# This registers the Let's Encrypt ACME issuer
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||
CERTCTL_ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||
|
||||
# Local CA as fallback for internal services (optional)
|
||||
CERTCTL_CA_CERT_PATH: /etc/certctl/ca.crt
|
||||
CERTCTL_CA_KEY_PATH: /etc/certctl/ca.key
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
ports:
|
||||
- '${SERVER_PORT:-8443}:8443'
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl agent (runs on the target machine with NGINX)
|
||||
# In this example, the agent is in the same compose file for simplicity.
|
||||
# In production, the agent runs on each server that needs certificates.
|
||||
certctl-agent:
|
||||
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||
container_name: certctl-agent-acme-nginx
|
||||
environment:
|
||||
# Control plane connection
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||
|
||||
# Key generation (agent-side keys, never sent to server)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||
|
||||
# Discovery (scan existing certs so operator knows what's already deployed)
|
||||
CERTCTL_DISCOVERY_DIRS: /etc/nginx/ssl
|
||||
|
||||
# Heartbeat interval
|
||||
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||
|
||||
# Agent metadata (self-reported)
|
||||
CERTCTL_AGENT_NAME: nginx-agent-01
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
volumes:
|
||||
# Mount NGINX config and cert directories
|
||||
# In production, these would be the actual NGINX paths
|
||||
- nginx_certs:/etc/nginx/ssl
|
||||
- nginx_conf:/etc/nginx/conf.d
|
||||
# Agent key storage (persisted across restarts)
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# NGINX reverse proxy / web server
|
||||
# This is where certificates will be deployed
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: certctl-nginx-acme-nginx
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- nginx_conf:/etc/nginx/conf.d
|
||||
- nginx_certs:/etc/nginx/ssl
|
||||
# Default NGINX config (if not provided by agent)
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- certctl-agent
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost/ || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
certctl-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
nginx_certs:
|
||||
driver: local
|
||||
nginx_conf:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
@@ -0,0 +1,306 @@
|
||||
# ACME Wildcard DNS-01 Example
|
||||
|
||||
**What this does:** Issues wildcard certificates (e.g., `*.example.com`) from Let's Encrypt using DNS-01 challenge validation.
|
||||
|
||||
This example is ideal for:
|
||||
- Issuing wildcard certificates (`*.example.com`)
|
||||
- Services behind NAT, firewalls, or non-public networks
|
||||
- Batch issuance of multiple domains in parallel
|
||||
- Internal PKI with public DNS names
|
||||
- Scenarios where you have programmatic access to your DNS provider's API
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this example, you need:
|
||||
|
||||
1. **A domain name** (e.g., `example.com`) that you control and can manage DNS records for
|
||||
2. **DNS provider credentials:**
|
||||
- **Cloudflare** (example included): API token with DNS:write permission + Zone ID
|
||||
- **Route53 (AWS)**: AWS access key + secret key
|
||||
- **Azure DNS**: Azure subscription ID + credentials
|
||||
- **Other providers**: See "Adapting for Other DNS Providers" below
|
||||
3. **Docker and Docker Compose** installed
|
||||
|
||||
## Quick Start (Cloudflare)
|
||||
|
||||
### Step 1: Get Cloudflare Credentials
|
||||
|
||||
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com)
|
||||
2. Select your domain (e.g., `example.com`)
|
||||
3. In the sidebar, find **Zone ID** (copy this)
|
||||
4. Go to **Account Settings > API Tokens**
|
||||
5. Create a new token with these scopes:
|
||||
- **Zone > Zone:Read** (to list DNS records)
|
||||
- **Zone > DNS:Write** (to create/delete challenge records)
|
||||
6. Copy the API token
|
||||
|
||||
### Step 2: Set Environment Variables
|
||||
|
||||
Create a `.env` file in this directory:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
CLOUDFLARE_API_TOKEN=your-api-token-here
|
||||
CLOUDFLARE_ZONE_ID=your-zone-id-here
|
||||
ACME_EMAIL=admin@example.com
|
||||
DB_PASSWORD=your-secure-db-password
|
||||
```
|
||||
|
||||
Or export them in your shell:
|
||||
|
||||
```bash
|
||||
export CLOUDFLARE_API_TOKEN="your-api-token-here"
|
||||
export CLOUDFLARE_ZONE_ID="your-zone-id-here"
|
||||
export ACME_EMAIL="admin@example.com"
|
||||
export DB_PASSWORD="your-secure-db-password"
|
||||
```
|
||||
|
||||
### Step 3: Make DNS Scripts Executable
|
||||
|
||||
```bash
|
||||
chmod +x dns-hooks/*.sh
|
||||
```
|
||||
|
||||
### Step 4: Start the Stack
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **certctl-server** (port 8443): Control plane and ACME orchestrator
|
||||
- **postgres**: Certificate metadata database
|
||||
- **certctl-agent**: Certificate deployment agent
|
||||
|
||||
### Step 5: Access the Dashboard
|
||||
|
||||
Open your browser to `http://localhost:8443`
|
||||
|
||||
### Step 6: Create a Wildcard Certificate
|
||||
|
||||
1. Go to **Issuers** page
|
||||
2. Verify the ACME issuer is registered
|
||||
3. Go to **Certificates** > **New Certificate**
|
||||
4. Fill in:
|
||||
- **Issuer:** ACME (Let's Encrypt)
|
||||
- **Common Name:** `*.example.com`
|
||||
- **Subject Alt Names:** `example.com` (to also cover the root domain)
|
||||
5. Click **Request**
|
||||
|
||||
The renewal job will:
|
||||
1. Send a request to Let's Encrypt
|
||||
2. Run `dns-hooks/cloudflare-present.sh` to create `_acme-challenge.example.com` TXT record
|
||||
3. Wait for Let's Encrypt to verify the TXT record
|
||||
4. Issue the certificate
|
||||
5. Run `dns-hooks/cloudflare-cleanup.sh` to delete the temporary TXT record
|
||||
|
||||
### Step 7: Monitor the Job
|
||||
|
||||
Go to **Jobs** page to see the renewal progress:
|
||||
- **AwaitingCSR**: Agent is generating the CSR
|
||||
- **Running**: ACME challenge in progress (DNS record being validated)
|
||||
- **Completed**: Certificate issued and stored
|
||||
- **Failed**: Check logs for errors (e.g., DNS provider API issues)
|
||||
|
||||
## How DNS-01 Works
|
||||
|
||||
The DNS-01 challenge proves you own a domain by creating a DNS TXT record:
|
||||
|
||||
```
|
||||
_acme-challenge.example.com TXT "acme-validation-token-xxxxx"
|
||||
```
|
||||
|
||||
Let's Encrypt then queries this TXT record. Once verified, it issues the certificate and certctl cleans up the TXT record.
|
||||
|
||||
**Why DNS-01 is better than HTTP-01 for wildcards:**
|
||||
- HTTP-01 requires a public web server; DNS-01 works anywhere
|
||||
- Wildcard certificates require DNS proof (not HTTP)
|
||||
- DNS challenges can be solved for multiple domains in parallel
|
||||
- No need for public IP or inbound port 80/443
|
||||
|
||||
## Adapting for Other DNS Providers
|
||||
|
||||
The example uses Cloudflare, but certctl supports **any DNS provider via pluggable shell scripts**.
|
||||
|
||||
### AWS Route53
|
||||
|
||||
Replace the `CERTCTL_ACME_DNS_PRESENT_SCRIPT` and `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` in `docker-compose.yml` with:
|
||||
- `./dns-hooks/route53-present.sh`
|
||||
- `./dns-hooks/route53-cleanup.sh`
|
||||
|
||||
Example script outline (using AWS CLI):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
DOMAIN="$1"
|
||||
VALIDATION_TOKEN="$2"
|
||||
|
||||
# Get Route53 hosted zone ID for the domain
|
||||
ZONE_ID=$(aws route53 list-hosted-zones --query \
|
||||
"HostedZones[?Name=='$DOMAIN.'].Id" --output text | cut -d'/' -f3)
|
||||
|
||||
# Create TXT record
|
||||
aws route53 change-resource-record-sets \
|
||||
--hosted-zone-id "$ZONE_ID" \
|
||||
--change-batch "{
|
||||
\"Changes\": [{
|
||||
\"Action\": \"CREATE\",
|
||||
\"ResourceRecordSet\": {
|
||||
\"Name\": \"_acme-challenge.$DOMAIN\",
|
||||
\"Type\": \"TXT\",
|
||||
\"TTL\": 120,
|
||||
\"ResourceRecords\": [{\"Value\": \"\\\"$VALIDATION_TOKEN\\\"\"}]
|
||||
}
|
||||
}]
|
||||
}"
|
||||
```
|
||||
|
||||
### Azure DNS
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
DOMAIN="$1"
|
||||
VALIDATION_TOKEN="$2"
|
||||
|
||||
# Set Azure credentials via environment variables
|
||||
# AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_TENANT_ID, etc.
|
||||
|
||||
az network dns record-set txt create \
|
||||
--resource-group "$AZURE_RESOURCE_GROUP" \
|
||||
--zone-name "$DOMAIN" \
|
||||
--name "_acme-challenge" \
|
||||
--ttl 120 \
|
||||
--txt-value "$VALIDATION_TOKEN"
|
||||
```
|
||||
|
||||
### Generic DNS Provider (using dig + TSIG)
|
||||
|
||||
If your DNS provider supports NSUPDATE (RFC 2136):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
DOMAIN="$1"
|
||||
VALIDATION_TOKEN="$2"
|
||||
|
||||
nsupdate <<EOF
|
||||
zone $DOMAIN
|
||||
update add _acme-challenge.$DOMAIN 120 TXT "$VALIDATION_TOKEN"
|
||||
send
|
||||
EOF
|
||||
```
|
||||
|
||||
### Manual DNS (for testing)
|
||||
|
||||
Replace scripts with no-ops during testing:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "Please create: _acme-challenge.$1 TXT $2"
|
||||
sleep 60 # Manual wait for you to create the record
|
||||
```
|
||||
|
||||
## Alternative: DNS-PERSIST-01 (Standing Records)
|
||||
|
||||
If your DNS provider supports it, use **DNS-PERSIST-01** for zero-maintenance renewals.
|
||||
|
||||
Instead of creating a new TXT record for each renewal, you create one standing record once:
|
||||
|
||||
```
|
||||
_validation-persist.example.com TXT "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345678"
|
||||
```
|
||||
|
||||
Then every renewal uses the same record — no cleanup scripts needed!
|
||||
|
||||
To enable in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: dns-persist-01
|
||||
CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN: letsencrypt.org
|
||||
```
|
||||
|
||||
Certctl will:
|
||||
1. Fetch your ACME account URI
|
||||
2. Create the standing `_validation-persist` record once
|
||||
3. Reuse it for all future renewals (no per-renewal DNS updates)
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **API Token Scope:** Restrict Cloudflare/AWS tokens to DNS:write only (not full account access)
|
||||
2. **Key Generation:** This example uses agent-side key generation (`CERTCTL_KEYGEN_MODE=agent`), which is production-standard. Private keys never leave the agent.
|
||||
3. **Script Safety:** The DNS scripts run in the certctl-server container. For production:
|
||||
- Validate script inputs (already done in certctl code)
|
||||
- Log all API calls
|
||||
- Monitor for failed DNS operations
|
||||
- Use a separate proxy agent for DNS operations if needed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### DNS record not created
|
||||
|
||||
Check the server logs:
|
||||
|
||||
```bash
|
||||
docker logs certctl-server-dns01
|
||||
```
|
||||
|
||||
Look for lines like:
|
||||
- `[certctl DNS-01] Creating DNS record: _acme-challenge.example.com`
|
||||
- `Error: Cloudflare API failed: ...`
|
||||
|
||||
**Common issues:**
|
||||
- Missing or invalid `CLOUDFLARE_API_TOKEN`
|
||||
- Invalid `CLOUDFLARE_ZONE_ID`
|
||||
- API token doesn't have DNS:write permission
|
||||
- Domain not in your Cloudflare account
|
||||
|
||||
### DNS propagation timeout
|
||||
|
||||
If the TLS negotiation fails, it might be DNS caching. Increase the wait time in the script:
|
||||
|
||||
```bash
|
||||
sleep 30 # Increase from 10 to 30 seconds
|
||||
```
|
||||
|
||||
### Let's Encrypt rate limits
|
||||
|
||||
Let's Encrypt has strict rate limits:
|
||||
- 50 certificates per registered domain per week
|
||||
- 5 duplicate certificates per domain per week
|
||||
|
||||
For testing, use the **staging directory**:
|
||||
|
||||
```yaml
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
```
|
||||
|
||||
(Staging certs won't be trusted by browsers, but don't count against rate limits.)
|
||||
|
||||
### Job fails with "CSR generation timeout"
|
||||
|
||||
If your DNS provider is very slow, increase the timeout in the cleanup script or add a longer wait time:
|
||||
|
||||
```bash
|
||||
sleep 60 # Wait 1 minute for DNS propagation
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Monitor renewals:** Set up notifications (email, Slack, PagerDuty) for renewal events
|
||||
2. **Deploy certificates:** Configure target connectors (NGINX, HAProxy, Traefik) to automatically deploy issued certs
|
||||
3. **Multi-domain:** Use certificate profiles to group wildcard + subdomain certs
|
||||
4. **Backup DNS scripts:** Version control your DNS provider scripts in git
|
||||
|
||||
## Files in This Example
|
||||
|
||||
- **docker-compose.yml** — Container stack definition with ACME DNS-01 configuration
|
||||
- **dns-hooks/cloudflare-present.sh** — Creates `_acme-challenge` TXT record (Cloudflare)
|
||||
- **dns-hooks/cloudflare-cleanup.sh** — Deletes `_acme-challenge` TXT record (Cloudflare)
|
||||
- **README.md** — This file
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [certctl Documentation](../../docs/)
|
||||
- [ACME Specification (RFC 8555)](https://tools.ietf.org/html/rfc8555)
|
||||
- [DNS-01 Challenge Details](https://letsencrypt.org/docs/challenge-types/#dns-01)
|
||||
- [DNS-PERSIST-01 (IETF Draft)](https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist)
|
||||
- [Let's Encrypt Documentation](https://letsencrypt.org/docs/)
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Cloudflare DNS-01 Challenge Script (CLEANUP)
|
||||
#
|
||||
# This script removes a DNS TXT record after ACME DNS-01 challenge validation.
|
||||
# Called by certctl after certificate issuance to clean up temporary challenge records.
|
||||
#
|
||||
# certctl sets these environment variables before invoking this script:
|
||||
# CERTCTL_DNS_DOMAIN - Base domain (e.g., "example.com")
|
||||
# CERTCTL_DNS_FQDN - Full challenge FQDN (e.g., "_acme-challenge.example.com")
|
||||
# CERTCTL_DNS_VALUE - Challenge value/token that was in the TXT record
|
||||
#
|
||||
# You must set these environment variables before running:
|
||||
# CLOUDFLARE_API_TOKEN - Cloudflare API token with DNS:write permission
|
||||
# CLOUDFLARE_ZONE_ID - Cloudflare zone ID for your domain
|
||||
#
|
||||
# Error Handling:
|
||||
# This script exits 0 on success, non-zero on failure.
|
||||
# If cleanup fails, certctl logs the error but doesn't block renewals.
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Get values from certctl environment variables
|
||||
DOMAIN="${CERTCTL_DNS_DOMAIN:-}"
|
||||
RECORD_NAME="${CERTCTL_DNS_FQDN:-}"
|
||||
VALIDATION_TOKEN="${CERTCTL_DNS_VALUE:-}"
|
||||
|
||||
# Validate inputs
|
||||
if [[ -z "$DOMAIN" || -z "$RECORD_NAME" || -z "$VALIDATION_TOKEN" ]]; then
|
||||
echo "Error: Required certctl environment variables not set (CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate environment
|
||||
if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then
|
||||
echo "Error: CLOUDFLARE_API_TOKEN environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${CLOUDFLARE_ZONE_ID:-}" ]]; then
|
||||
echo "Error: CLOUDFLARE_ZONE_ID environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate RECORD_NAME (set by certctl above)
|
||||
RECORD_TYPE="TXT"
|
||||
|
||||
# Cloudflare API endpoint
|
||||
CF_API="https://api.cloudflare.com/client/v4"
|
||||
CF_ZONE="$CLOUDFLARE_ZONE_ID"
|
||||
CF_TOKEN="$CLOUDFLARE_API_TOKEN"
|
||||
|
||||
echo "[certctl DNS-01] Cleaning up DNS record: $RECORD_NAME"
|
||||
|
||||
# Step 1: Find the record ID
|
||||
RECORD_ID=$(curl -s -X GET \
|
||||
"$CF_API/zones/$CF_ZONE/dns_records?name=$RECORD_NAME&type=$RECORD_TYPE" \
|
||||
-H "Authorization: Bearer $CF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
| jq -r '.result | if length > 0 then .[0].id else "" end')
|
||||
|
||||
if [[ -z "$RECORD_ID" ]]; then
|
||||
echo "[certctl DNS-01] Record not found (already deleted?). Skipping cleanup."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Step 2: Delete the record (DELETE /zones/{zone_id}/dns_records/{record_id})
|
||||
echo "[certctl DNS-01] Deleting DNS record (ID: $RECORD_ID)..."
|
||||
RESPONSE=$(curl -s -X DELETE \
|
||||
"$CF_API/zones/$CF_ZONE/dns_records/$RECORD_ID" \
|
||||
-H "Authorization: Bearer $CF_TOKEN" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
# Check response success
|
||||
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
||||
if [[ "$SUCCESS" != "true" ]]; then
|
||||
ERROR=$(echo "$RESPONSE" | jq -r '.errors[0].message // "Unknown error"')
|
||||
echo "Warning: Cloudflare API failed to delete record: $ERROR" >&2
|
||||
# Don't exit 1 here — DNS cleanup is best-effort; cleanup failures shouldn't block certs
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[certctl DNS-01] Successfully deleted DNS record"
|
||||
exit 0
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Cloudflare DNS-01 Challenge Script (PRESENT)
|
||||
#
|
||||
# This script creates a DNS TXT record for ACME DNS-01 challenge validation.
|
||||
# Called by certctl during the renewal process to prove domain ownership.
|
||||
#
|
||||
# certctl sets these environment variables before invoking this script:
|
||||
# CERTCTL_DNS_DOMAIN - Base domain (e.g., "example.com")
|
||||
# CERTCTL_DNS_FQDN - Full challenge FQDN (e.g., "_acme-challenge.example.com")
|
||||
# CERTCTL_DNS_VALUE - Challenge value/token to place in the TXT record
|
||||
#
|
||||
# You must set these environment variables before running:
|
||||
# CLOUDFLARE_API_TOKEN - Cloudflare API token with DNS:write permission
|
||||
# CLOUDFLARE_ZONE_ID - Cloudflare zone ID for your domain
|
||||
# (Find at: https://dash.cloudflare.com > Select Domain > Zone ID in sidebar)
|
||||
#
|
||||
# Error Handling:
|
||||
# This script exits 0 on success, non-zero on failure.
|
||||
# certctl will retry the renewal if this script fails.
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Get values from certctl environment variables
|
||||
DOMAIN="${CERTCTL_DNS_DOMAIN:-}"
|
||||
RECORD_NAME="${CERTCTL_DNS_FQDN:-}"
|
||||
VALIDATION_TOKEN="${CERTCTL_DNS_VALUE:-}"
|
||||
|
||||
# Validate inputs
|
||||
if [[ -z "$DOMAIN" || -z "$RECORD_NAME" || -z "$VALIDATION_TOKEN" ]]; then
|
||||
echo "Error: Required certctl environment variables not set (CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate environment
|
||||
if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then
|
||||
echo "Error: CLOUDFLARE_API_TOKEN environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${CLOUDFLARE_ZONE_ID:-}" ]]; then
|
||||
echo "Error: CLOUDFLARE_ZONE_ID environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate RECORD_NAME (set by certctl above)
|
||||
RECORD_TYPE="TXT"
|
||||
RECORD_TTL=120 # Short TTL for challenge records (1-2 min)
|
||||
|
||||
# Cloudflare API endpoint
|
||||
CF_API="https://api.cloudflare.com/client/v4"
|
||||
CF_ZONE="$CLOUDFLARE_ZONE_ID"
|
||||
CF_TOKEN="$CLOUDFLARE_API_TOKEN"
|
||||
|
||||
echo "[certctl DNS-01] Creating DNS record: $RECORD_NAME = $VALIDATION_TOKEN"
|
||||
|
||||
# Step 1: Check if record already exists (GET /zones/{zone_id}/dns_records)
|
||||
# This is optional but helps with idempotency
|
||||
EXISTING=$(curl -s -X GET \
|
||||
"$CF_API/zones/$CF_ZONE/dns_records?name=$RECORD_NAME&type=$RECORD_TYPE" \
|
||||
-H "Authorization: Bearer $CF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
| jq -r '.result | if length > 0 then .[0].id else "" end')
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
echo "[certctl DNS-01] Record already exists (ID: $EXISTING). Updating..."
|
||||
# Update existing record
|
||||
RESPONSE=$(curl -s -X PUT \
|
||||
"$CF_API/zones/$CF_ZONE/dns_records/$EXISTING" \
|
||||
-H "Authorization: Bearer $CF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"$RECORD_TYPE\",
|
||||
\"name\": \"$RECORD_NAME\",
|
||||
\"content\": \"$VALIDATION_TOKEN\",
|
||||
\"ttl\": $RECORD_TTL
|
||||
}")
|
||||
else
|
||||
echo "[certctl DNS-01] Creating new DNS record..."
|
||||
# Create new record
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
"$CF_API/zones/$CF_ZONE/dns_records" \
|
||||
-H "Authorization: Bearer $CF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"$RECORD_TYPE\",
|
||||
\"name\": \"$RECORD_NAME\",
|
||||
\"content\": \"$VALIDATION_TOKEN\",
|
||||
\"ttl\": $RECORD_TTL
|
||||
}")
|
||||
fi
|
||||
|
||||
# Check response success
|
||||
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
||||
if [[ "$SUCCESS" != "true" ]]; then
|
||||
ERROR=$(echo "$RESPONSE" | jq -r '.errors[0].message // "Unknown error"')
|
||||
echo "Error: Cloudflare API failed: $ERROR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RECORD_ID=$(echo "$RESPONSE" | jq -r '.result.id')
|
||||
echo "[certctl DNS-01] Successfully created/updated DNS record (ID: $RECORD_ID)"
|
||||
echo "[certctl DNS-01] Waiting for DNS propagation..."
|
||||
sleep 10
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,171 @@
|
||||
version: '3.8'
|
||||
|
||||
# ACME Wildcard DNS-01 Example
|
||||
#
|
||||
# This example demonstrates how to use certctl with Let's Encrypt to issue wildcard
|
||||
# certificates (*.example.com) using DNS-01 challenge validation.
|
||||
#
|
||||
# DNS-01 is ideal for:
|
||||
# - Wildcard certificates (*.domain.com)
|
||||
# - Services behind NAT or non-public networks
|
||||
# - Batch certificate issuance (multiple domains in parallel)
|
||||
#
|
||||
# It works by:
|
||||
# 1. certctl creates a renewal job for a wildcard certificate
|
||||
# 2. Let's Encrypt sends an ACME challenge: "create _acme-challenge TXT record with value X"
|
||||
# 3. certctl runs the dns-present.sh script to create the TXT record via your DNS provider API
|
||||
# 4. Let's Encrypt verifies the TXT record exists
|
||||
# 5. Certificate is issued
|
||||
# 6. certctl runs dns-cleanup.sh to remove the TXT record
|
||||
#
|
||||
# This compose file also demonstrates:
|
||||
# - ACME issuer with DNS-01 challenge type
|
||||
# - Pluggable DNS provider scripts (Cloudflare example included; adapt for Route53, Azure DNS, etc.)
|
||||
# - Wildcard and multi-SAN certificate support
|
||||
# - Agent-side key generation (production-ready)
|
||||
|
||||
services:
|
||||
# PostgreSQL database for certctl metadata
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-postgres-dns01
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl server (control plane + ACME orchestration)
|
||||
certctl-server:
|
||||
image: ghcr.io/shankar0123/certctl-server:latest
|
||||
container_name: certctl-server-dns01
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
|
||||
# Auth (disabled for demo; production should use API keys with CERTCTL_AUTH_TYPE=api-key)
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
|
||||
# CORS (allow agent communication)
|
||||
CERTCTL_CORS_ORIGINS: '*'
|
||||
|
||||
# Key generation mode (agent-side: keys never leave agents; production standard)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
|
||||
# ===== ACME Issuer Configuration (DNS-01 Wildcard) =====
|
||||
# Let's Encrypt production directory (ACME v2)
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||
|
||||
# Email for certificate expiration notices and account recovery
|
||||
CERTCTL_ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
|
||||
|
||||
# Challenge type: dns-01 (not http-01, which doesn't support wildcards)
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: dns-01
|
||||
|
||||
# DNS present script: creates _acme-challenge TXT record
|
||||
# The script is mounted from ./dns-hooks/cloudflare-present.sh
|
||||
# Arguments: $1 = domain (e.g., "example.com"), $2 = validation token
|
||||
CERTCTL_ACME_DNS_PRESENT_SCRIPT: /etc/certctl/dns-hooks/cloudflare-present.sh
|
||||
|
||||
# DNS cleanup script: removes _acme-challenge TXT record
|
||||
# Arguments: $1 = domain, $2 = validation token
|
||||
CERTCTL_ACME_DNS_CLEANUP_SCRIPT: /etc/certctl/dns-hooks/cloudflare-cleanup.sh
|
||||
|
||||
# Optional: DNS propagation wait time (seconds) before proceeding to next challenge
|
||||
# Default is 30s; increase if your DNS propagates slowly
|
||||
# Set via CERTCTL_ACME_DNS_PROPAGATION_WAIT in code, or rely on default
|
||||
|
||||
# Optional: Let's Encrypt Renewal Information (RFC 9702) for CA-directed renewal timing
|
||||
# CERTCTL_ACME_ARI_ENABLED: "true"
|
||||
|
||||
# Local CA as fallback for internal services (optional)
|
||||
CERTCTL_CA_CERT_PATH: /etc/certctl/ca.crt
|
||||
CERTCTL_CA_KEY_PATH: /etc/certctl/ca.key
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
|
||||
ports:
|
||||
- '${SERVER_PORT:-8443}:8443'
|
||||
|
||||
volumes:
|
||||
# Mount DNS provider scripts (adapt these for your DNS provider)
|
||||
- ./dns-hooks:/etc/certctl/dns-hooks:ro
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
- certctl-network
|
||||
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl agent (manages certificate deployment on target hosts)
|
||||
# In production, run agents on each host that needs certificates.
|
||||
# For demo, we include one agent in this compose.
|
||||
certctl-agent:
|
||||
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||
container_name: certctl-agent-dns01
|
||||
environment:
|
||||
# Control plane connection
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||
|
||||
# Key generation (agent-side keys: production-standard security model)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||
|
||||
# Discovery (scan existing certs so operator knows what's already deployed)
|
||||
CERTCTL_DISCOVERY_DIRS: /etc/letsencrypt/live:/etc/ssl/certs
|
||||
|
||||
# Heartbeat interval (how often agent checks for work)
|
||||
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||
|
||||
# Agent metadata (self-reported to server)
|
||||
CERTCTL_AGENT_NAME: wildcard-agent-01
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
|
||||
volumes:
|
||||
# Agent persistent key storage (survives restarts)
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
- certctl-network
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
certctl-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
@@ -0,0 +1,150 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL database for certctl
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-postgres-multi-issuer
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl server (control plane)
|
||||
# Configured with BOTH ACME (Let's Encrypt) and Local CA issuers
|
||||
certctl-server:
|
||||
image: ghcr.io/shankar0123/certctl-server:latest
|
||||
container_name: certctl-server-multi-issuer
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
|
||||
# Auth (disabled for demo; production should use API keys)
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
|
||||
# CORS (allow agent communication)
|
||||
CERTCTL_CORS_ORIGINS: '*'
|
||||
|
||||
# Key generation mode (agent-side in production, server-side for demo)
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
|
||||
# ACME issuer (Let's Encrypt for public-facing services)
|
||||
# Change CERTCTL_ACME_EMAIL to your email and CERTCTL_ACME_CHALLENGE_TYPE as needed
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||
CERTCTL_ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||
|
||||
# Local CA issuer (for internal services - self-signed or sub-CA)
|
||||
# Set these paths if you have an existing CA cert+key for sub-CA mode
|
||||
# Otherwise, leave empty for self-signed CA generation
|
||||
CERTCTL_CA_CERT_PATH: ${CA_CERT_PATH:-}
|
||||
CERTCTL_CA_KEY_PATH: ${CA_KEY_PATH:-}
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
ports:
|
||||
- '${SERVER_PORT:-8443}:8443'
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl agent (manages certificates on NGINX and application servers)
|
||||
certctl-agent:
|
||||
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||
container_name: certctl-agent-multi-issuer
|
||||
environment:
|
||||
# Control plane connection
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||
|
||||
# Key generation (agent-side keys, never sent to server)
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||
|
||||
# Discovery (scan existing certs to track what's already deployed)
|
||||
CERTCTL_DISCOVERY_DIRS: /etc/nginx/ssl:/etc/app/ssl
|
||||
|
||||
# Heartbeat interval
|
||||
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||
|
||||
# Agent metadata
|
||||
CERTCTL_AGENT_NAME: multi-issuer-agent-01
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
volumes:
|
||||
# Mount NGINX cert directories
|
||||
- nginx_certs:/etc/nginx/ssl
|
||||
- nginx_conf:/etc/nginx/conf.d
|
||||
# Mount application service cert directory
|
||||
- app_certs:/etc/app/ssl
|
||||
# Agent key storage (persisted across restarts)
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# NGINX reverse proxy / web server
|
||||
# This is where public TLS certs (from ACME) will be deployed
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: certctl-nginx-multi-issuer
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- nginx_conf:/etc/nginx/conf.d
|
||||
- nginx_certs:/etc/nginx/ssl
|
||||
# Default NGINX config
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- certctl-agent
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost/ || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
certctl-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
nginx_certs:
|
||||
driver: local
|
||||
nginx_conf:
|
||||
driver: local
|
||||
app_certs:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
@@ -0,0 +1,242 @@
|
||||
# Multi-Issuer Example: ACME + Local CA
|
||||
|
||||
This example demonstrates certctl managing **both public and internal certificates from a single dashboard**. Public-facing services use Let's Encrypt (ACME), while internal services use a private Local CA — all visible and managed in one place.
|
||||
|
||||
## The Use Case
|
||||
|
||||
You have:
|
||||
- **Public-facing services** (web app, API, etc.) that need TLS certs signed by a trusted public CA (Let's Encrypt)
|
||||
- **Internal services** (databases, microservices, middleware) that need TLS certs but don't require public trust
|
||||
- **One team** managing certs across both, needing unified visibility and automated renewal
|
||||
|
||||
With certctl, both issuer types are configured and available. You assign each certificate to the appropriate issuer via its profile or at enrollment time. The dashboard shows all certs together, with renewal status, expiration timelines, and audit trails — regardless of which CA issued them.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ certctl Server (Control Plane) │
|
||||
│ - Let's Encrypt ACME issuer (HTTP-01 challenges) │
|
||||
│ - Local CA issuer (self-signed or sub-CA mode) │
|
||||
│ - PostgreSQL database (cert inventory, audit, jobs) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ API polling
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ certctl Agent │
|
||||
│ - Discovers existing certs in /etc/nginx/ssl and /etc/app/ssl │
|
||||
│ - Polls server for renewal/issuance/deployment jobs │
|
||||
│ - Generates keys locally (agent-side crypto) │
|
||||
│ - Deploys certs to NGINX and app service directories │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
NGINX (public TLS) App Services (internal TLS)
|
||||
(Let's Encrypt certs) (Local CA certs)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker & Docker Compose** — containers run everything
|
||||
- **Port access** — 80 (HTTP-01 challenges) and 443 (HTTPS) for Let's Encrypt
|
||||
- **Domain for ACME** (optional) — if using real Let's Encrypt, not needed for demo
|
||||
- **Internet connectivity** — to reach Let's Encrypt's API (demo can use staging directory)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone or navigate to this directory
|
||||
|
||||
```bash
|
||||
cd examples/multi-issuer
|
||||
```
|
||||
|
||||
### 2. Set environment variables (optional, defaults provided)
|
||||
|
||||
```bash
|
||||
# Email for Let's Encrypt account
|
||||
export ACME_EMAIL="your-email@example.com"
|
||||
|
||||
# Database password (for demo, default is fine)
|
||||
export DB_PASSWORD="certctl-dev-password"
|
||||
|
||||
# Agent API key
|
||||
export AGENT_API_KEY="agent-demo-key"
|
||||
|
||||
# Server port (default 8443)
|
||||
export SERVER_PORT="8443"
|
||||
```
|
||||
|
||||
### 3. Start the services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This spins up:
|
||||
- **PostgreSQL** database (certctl data store)
|
||||
- **certctl server** with ACME and Local CA issuers configured
|
||||
- **certctl agent** discovering existing certs and polling for work
|
||||
- **NGINX** web server (target for public TLS certs)
|
||||
|
||||
### 4. Access the dashboard
|
||||
|
||||
Open your browser to **http://localhost:8443** (or your configured SERVER_PORT)
|
||||
|
||||
You should see:
|
||||
- Empty cert inventory (fresh start)
|
||||
- Two configured issuers: "ACME" and "Local CA"
|
||||
- One registered agent ("multi-issuer-agent-01")
|
||||
|
||||
### 5. Create test certificates
|
||||
|
||||
In the dashboard:
|
||||
|
||||
**For a public cert (Let's Encrypt):**
|
||||
1. Go to **Certificates** > **+ New Certificate**
|
||||
2. Common Name: `example.com` (or a test domain you control)
|
||||
3. Issuer: Select "ACME"
|
||||
4. Profile: Select default or create one (key type: RSA 2048, TTL: 90 days)
|
||||
5. Create → The server submits an ACME order
|
||||
|
||||
**For an internal cert (Local CA):**
|
||||
1. Go to **Certificates** > **+ New Certificate**
|
||||
2. Common Name: `internal-api.internal` (or any internal name)
|
||||
3. Issuer: Select "Local CA"
|
||||
4. Profile: Select default
|
||||
5. Create → The server issues immediately from the private CA
|
||||
|
||||
### 6. Monitor in the dashboard
|
||||
|
||||
- **Dashboard** — see cert counts by status and issuer
|
||||
- **Certificates** page — filter by issuer, see renewal status, expiration timeline
|
||||
- **Audit Trail** — track all operations (issuance, renewals, deployments)
|
||||
- **Agents** — view agent health and pending work
|
||||
|
||||
## How Issuer Assignment Works
|
||||
|
||||
### Via Profiles
|
||||
Create a profile for each issuer type:
|
||||
- Profile **public-tls** → Issuer: ACME, TTL: 90 days, allowed domains: `*.example.com`
|
||||
- Profile **internal-tls** → Issuer: Local CA, TTL: 1 year, allowed SANs: internal DNS names
|
||||
|
||||
Then create certificates using the appropriate profile.
|
||||
|
||||
### Via Direct Assignment
|
||||
When creating a certificate, explicitly select the issuer. The certificate remembers which issuer it belongs to.
|
||||
|
||||
## ACME Configuration
|
||||
|
||||
The server is configured with Let's Encrypt's production directory:
|
||||
|
||||
```yaml
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||
CERTCTL_ACME_EMAIL: admin@example.com
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||
```
|
||||
|
||||
**For testing without a real domain**, use Let's Encrypt's staging directory:
|
||||
|
||||
```bash
|
||||
# Edit docker-compose.yml and change:
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
```
|
||||
|
||||
Staging certs are untrusted (for testing only) but unlimited rate limits.
|
||||
|
||||
## Local CA Configuration
|
||||
|
||||
The Local CA issuer can operate in two modes:
|
||||
|
||||
### Mode 1: Self-Signed (Default)
|
||||
Leave `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` empty. The server generates a self-signed root CA on first run.
|
||||
|
||||
```yaml
|
||||
CERTCTL_CA_CERT_PATH: ""
|
||||
CERTCTL_CA_KEY_PATH: ""
|
||||
```
|
||||
|
||||
**Use case:** Development, testing, internal services that trust a self-signed root.
|
||||
|
||||
### Mode 2: Sub-CA (Enterprise)
|
||||
Provide an existing CA cert + key (e.g., from your organization's PKI). The Local CA issues certs signed by that intermediate.
|
||||
|
||||
```bash
|
||||
# In docker-compose.yml, volume-mount your CA cert+key:
|
||||
volumes:
|
||||
- /path/to/ca.crt:/etc/certctl/ca.crt:ro
|
||||
- /path/to/ca.key:/etc/certctl/ca.key:ro
|
||||
|
||||
# And set env vars:
|
||||
CERTCTL_CA_CERT_PATH: /etc/certctl/ca.crt
|
||||
CERTCTL_CA_KEY_PATH: /etc/certctl/ca.key
|
||||
```
|
||||
|
||||
**Use case:** Enterprise internal PKI where certs need to chain to a trusted root (e.g., Windows ADCS, OpenSSL, Vault PKI).
|
||||
|
||||
## Deployment Flow
|
||||
|
||||
When you create a certificate and assign it for deployment:
|
||||
|
||||
1. **Issuance** — Server calls the issuer connector (ACME or Local CA)
|
||||
- ACME: submit challenge, poll until DNS/HTTP validated, retrieve cert
|
||||
- Local CA: generate and sign immediately
|
||||
|
||||
2. **Agent picks up work** — Agent polls `/api/v1/agents/{id}/work`
|
||||
|
||||
3. **Agent deployment** — Agent places cert+key in the target directory
|
||||
- NGINX: `/etc/nginx/ssl/` (mounted volume)
|
||||
- App services: `/etc/app/ssl/` (mounted volume)
|
||||
|
||||
4. **Service reload** — Agent triggers reload (NGINX: `nginx -s reload`, etc.)
|
||||
|
||||
5. **Dashboard reflects status** — Job transitions from `Running` → `Completed`, cert shows as `Active`
|
||||
|
||||
## Scaling Beyond Docker Compose
|
||||
|
||||
In production:
|
||||
|
||||
- **Deploy certctl server** on a single node (or HA cluster with external PostgreSQL)
|
||||
- **Deploy certctl agents** on each server needing cert management
|
||||
- **Point agents to server URL** via `CERTCTL_SERVER_URL` env var
|
||||
- **Configure issuers on server** via env vars or (in V3+) the dashboard UI
|
||||
- **Use profiles to segment issuers** — operators select a profile at cert creation time
|
||||
|
||||
Each agent independently manages its local cert inventory and deployments. The server coordinates all agent work and provides the unified dashboard.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certs aren't being issued
|
||||
- Check server logs: `docker compose logs certctl-server`
|
||||
- Verify issuer configuration: Dashboard → Issuers, click "Test Connection"
|
||||
- For ACME, ensure ports 80/443 are open and your domain resolves
|
||||
|
||||
### Agent can't reach server
|
||||
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health`
|
||||
- Verify `CERTCTL_SERVER_URL` environment variable
|
||||
|
||||
### No issuers showing up
|
||||
- Ensure env vars are set on the server container
|
||||
- Restart server: `docker compose restart certctl-server`
|
||||
- Check server logs for validation errors
|
||||
|
||||
### Let's Encrypt rate limits
|
||||
- Use the staging directory for testing (unlimited, untrusted certs)
|
||||
- Production directory: 50 certs per domain per week
|
||||
- Read more: https://letsencrypt.org/docs/rate-limits/
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **Create a certificate profile** — Dashboard → Profiles → + New Profile
|
||||
- **Configure team ownership** — Dashboard → Owners/Teams (assign certs to teams)
|
||||
- **Set renewal policies** — Dashboard → Policies (expiration thresholds, auto-renewal)
|
||||
- **Enable notifications** — Configure Slack/Teams webhook to get alerts on renewals and expirations
|
||||
- **Explore discovery** — Agent scans `/etc/nginx/ssl` and `/etc/app/ssl`, Dashboard → Discovery shows what's already deployed
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [certctl Architecture](../../docs/architecture.md)
|
||||
- [ACME Connector Docs](../../docs/connectors.md#acme-letsencrypt)
|
||||
- [Local CA Connector Docs](../../docs/connectors.md#local-ca)
|
||||
- [Agent Configuration](../../docs/agent.md)
|
||||
- [Deployment Targets](../../docs/connectors.md#deployment-targets)
|
||||
@@ -0,0 +1,182 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL database for certctl
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-postgres-private-ca
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl server (control plane) with Local CA in sub-CA mode
|
||||
certctl-server:
|
||||
image: ghcr.io/shankar0123/certctl-server:latest
|
||||
container_name: certctl-server-private-ca
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
|
||||
# Auth (disabled for demo; production should use API keys)
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
|
||||
# CORS (allow agent and Traefik communication)
|
||||
CERTCTL_CORS_ORIGINS: '*'
|
||||
|
||||
# Key generation mode (agent-side in production, server-side for demo)
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
|
||||
# Local CA configuration
|
||||
# For self-signed CA (default, no paths set):
|
||||
# - CA generates a self-signed root certificate
|
||||
# - All issued certificates chain to this root
|
||||
#
|
||||
# For sub-CA mode (provide both paths):
|
||||
# - Load pre-signed CA certificate and key from these paths
|
||||
# - All issued certificates chain to your enterprise root CA
|
||||
# - Requires: CA cert must have IsCA=true and KeyUsageCertSign
|
||||
# - Supports: RSA, ECDSA, PKCS#8 key formats
|
||||
#
|
||||
# To use sub-CA mode:
|
||||
# 1. Place your enterprise CA cert at ./ca-cert.pem
|
||||
# 2. Place your enterprise CA key at ./ca-key.pem
|
||||
# 3. Uncomment the two lines below
|
||||
# 4. Restart the service
|
||||
#
|
||||
# CERTCTL_CA_CERT_PATH: /etc/certctl/ca-cert.pem
|
||||
# CERTCTL_CA_KEY_PATH: /etc/certctl/ca-key.pem
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
ports:
|
||||
- '${SERVER_PORT:-8443}:8443'
|
||||
volumes:
|
||||
# Mount directory for CA cert/key (for sub-CA mode)
|
||||
# Copy your enterprise CA cert+key here:
|
||||
# cp /path/to/your/ca.pem ./ca-cert.pem
|
||||
# cp /path/to/your/ca-key.pem ./ca-key.pem
|
||||
- ./ca-certs:/etc/certctl:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl agent (deploys certs to Traefik)
|
||||
certctl-agent:
|
||||
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||
container_name: certctl-agent-private-ca
|
||||
environment:
|
||||
# Control plane connection
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||
|
||||
# Key generation (agent-side keys, never sent to server)
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||
|
||||
# Discovery (scan for existing certs in Traefik's directory)
|
||||
CERTCTL_DISCOVERY_DIRS: /etc/traefik/certs
|
||||
|
||||
# Heartbeat interval
|
||||
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||
|
||||
# Agent metadata (self-reported)
|
||||
CERTCTL_AGENT_NAME: traefik-agent-01
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
volumes:
|
||||
# Mount Traefik cert directory for deployment
|
||||
- traefik_certs:/etc/traefik/certs
|
||||
# Agent key storage (persisted across restarts)
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Traefik reverse proxy / edge router
|
||||
# Certificates deployed by certctl-agent are automatically loaded from the certs directory
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: certctl-traefik-private-ca
|
||||
command:
|
||||
# Enable dashboard and API
|
||||
- '--api.insecure=true'
|
||||
- '--api.dashboard=true'
|
||||
|
||||
# File provider: watch the certs directory for dynamic config updates
|
||||
- '--providers.file.directory=/etc/traefik/dynamic'
|
||||
- '--providers.file.watch=true'
|
||||
|
||||
# Entry points (HTTP and HTTPS)
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
- '--entrypoints.websecure.http.tls=true'
|
||||
|
||||
# Global TLS settings
|
||||
- '--entryPoints.websecure.http.tls.certResolver=internal'
|
||||
|
||||
# Logging
|
||||
- '--log.level=info'
|
||||
- '--accesslog=true'
|
||||
ports:
|
||||
# HTTP
|
||||
- '80:80'
|
||||
# HTTPS
|
||||
- '443:443'
|
||||
# Dashboard (http://localhost:8080)
|
||||
- '8080:8080'
|
||||
volumes:
|
||||
# Mount Traefik config directory
|
||||
- ./traefik-config:/etc/traefik/dynamic:ro
|
||||
# Mount cert directory (where certctl deploys certs)
|
||||
- traefik_certs:/etc/traefik/certs:ro
|
||||
# Allow Traefik to read Docker socket (optional, for container labeling)
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- certctl-network
|
||||
depends_on:
|
||||
- certctl-agent
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8080/ping || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
certctl-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
traefik_certs:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
@@ -0,0 +1,358 @@
|
||||
# Private CA + Traefik Example
|
||||
|
||||
This example demonstrates certctl managing certificates for **internal services without public CA dependency**. Ideal for enterprise environments where:
|
||||
|
||||
- All services are internal (VPN, private networks)
|
||||
- You need unified certificate lifecycle management across multiple internal apps
|
||||
- You want automatic cert deployment to your reverse proxy
|
||||
- You may have an existing enterprise root CA (ADCS, OpenCA, etc.)
|
||||
|
||||
## What's Included
|
||||
|
||||
- **certctl server** with Local CA issuer (self-signed or sub-CA mode)
|
||||
- **certctl agent** that deploys certificates to Traefik
|
||||
- **Traefik** reverse proxy with file provider for dynamic cert discovery
|
||||
- **PostgreSQL** database for certificate storage and audit trail
|
||||
- Automatic certificate discovery for existing certs in Traefik
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ certctl-server │ (Local CA issuer)
|
||||
│ (control │
|
||||
│ plane) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
│ REST API (job polling)
|
||||
│
|
||||
┌────────▼──────────┐
|
||||
│ certctl-agent │ (certificate deployer)
|
||||
└────────┬──────────┘
|
||||
│
|
||||
│ Write cert/key files
|
||||
│
|
||||
┌────────▼──────────────────────┐
|
||||
│ Traefik │
|
||||
│ (watches cert directory) │
|
||||
└────────────────────────────────┘
|
||||
│
|
||||
│ TLS handshakes
|
||||
│
|
||||
[Internal Services]
|
||||
```
|
||||
|
||||
## Quick Start (Self-Signed CA)
|
||||
|
||||
The simplest way to get running in 2 minutes:
|
||||
|
||||
```bash
|
||||
# 1. Create directory structure
|
||||
mkdir -p traefik-config ca-certs
|
||||
|
||||
# 2. Create a minimal Traefik dynamic config
|
||||
cat > traefik-config/default.yaml << 'EOF'
|
||||
# Traefik will auto-load certificates from /etc/traefik/certs
|
||||
# Certctl deploys {cert-id}.crt and {cert-id}.key files here
|
||||
http:
|
||||
routers:
|
||||
api:
|
||||
rule: "Host(`api.internal.local`)"
|
||||
service: api-service
|
||||
tls: {}
|
||||
services:
|
||||
api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://localhost:3000"
|
||||
EOF
|
||||
|
||||
# 3. Start the stack
|
||||
docker compose up -d
|
||||
|
||||
# 4. Access the dashboards
|
||||
# - certctl: http://localhost:8443 (API only, use the CLI or direct HTTP calls)
|
||||
# - Traefik dashboard: http://localhost:8080
|
||||
```
|
||||
|
||||
The self-signed CA will be automatically generated on first startup.
|
||||
|
||||
## Using Sub-CA Mode (Enterprise Root CA)
|
||||
|
||||
If you have an existing enterprise CA (ADCS, OpenCA, etc.) and want issued certs to chain to your root:
|
||||
|
||||
```bash
|
||||
# 1. Create directory structure
|
||||
mkdir -p traefik-config ca-certs
|
||||
|
||||
# 2. Copy your enterprise CA cert and key
|
||||
cp /path/to/your/enterprise-ca.crt ca-certs/ca-cert.pem
|
||||
cp /path/to/your/enterprise-ca-key.pem ca-certs/ca-key.pem
|
||||
|
||||
# 3. Edit docker-compose.yml and uncomment the sub-CA env vars:
|
||||
# CERTCTL_CA_CERT_PATH: /etc/certctl/ca-cert.pem
|
||||
# CERTCTL_CA_KEY_PATH: /etc/certctl/ca-key.pem
|
||||
|
||||
# 4. Create the dynamic config (same as above)
|
||||
mkdir -p traefik-config
|
||||
cat > traefik-config/default.yaml << 'EOF'
|
||||
http:
|
||||
routers:
|
||||
api:
|
||||
rule: "Host(`api.internal.local`)"
|
||||
service: api-service
|
||||
tls: {}
|
||||
services:
|
||||
api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://localhost:3000"
|
||||
EOF
|
||||
|
||||
# 5. Start the stack
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Requirements for sub-CA mode:**
|
||||
- CA certificate must have `X509v3 Basic Constraints: CA:TRUE`
|
||||
- CA certificate must have `X509v3 Key Usage: Certificate Sign`
|
||||
- Key format: RSA, ECDSA, or PKCS#8
|
||||
- Paths: must be absolute paths to mounted files
|
||||
|
||||
## Creating a Certificate
|
||||
|
||||
Once the stack is running:
|
||||
|
||||
```bash
|
||||
# 1. Create a certificate profile in certctl (defines allowed key types, TTL, etc.)
|
||||
curl -X POST http://localhost:8443/api/v1/profiles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "prof-internal",
|
||||
"name": "Internal Services",
|
||||
"description": "For internal APIs and web apps",
|
||||
"max_ttl_hours": 8760,
|
||||
"key_types": ["rsa-2048", "ecdsa-p256"]
|
||||
}'
|
||||
|
||||
# 2. Create a renewal policy (defines issuer, renewal thresholds, etc.)
|
||||
curl -X POST http://localhost:8443/api/v1/policies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "pol-internal",
|
||||
"name": "Internal Renewal Policy",
|
||||
"issuer_id": "iss-local",
|
||||
"profile_id": "prof-internal",
|
||||
"renewal_threshold_days": 30,
|
||||
"alert_thresholds_days": [30, 14, 7, 0]
|
||||
}'
|
||||
|
||||
# 3. Create a certificate (triggers issuance immediately)
|
||||
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"common_name": "api.internal.local",
|
||||
"sans": ["app.internal.local", "www.internal.local"],
|
||||
"policy_id": "pol-internal"
|
||||
}'
|
||||
|
||||
# 4. Create a Traefik target (agent will deploy to this)
|
||||
curl -X POST http://localhost:8443/api/v1/targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "target-traefik-01",
|
||||
"name": "Traefik Primary",
|
||||
"type": "traefik",
|
||||
"config": {
|
||||
"cert_dir": "/etc/traefik/certs"
|
||||
}
|
||||
}'
|
||||
|
||||
# 5. Create a deployment job (agent picks this up and deploys)
|
||||
curl -X POST http://localhost:8443/api/v1/certificates/{cert-id}/deploy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"target_ids": ["target-traefik-01"]
|
||||
}'
|
||||
```
|
||||
|
||||
Once deployed, Traefik automatically loads the new certificate from the certs directory.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Certificate Lifecycle
|
||||
|
||||
1. **Issue** — certctl-server generates certificate from Local CA (self-signed or sub-CA)
|
||||
2. **Store** — certificate stored in PostgreSQL with full audit trail
|
||||
3. **Deploy** — certctl-agent writes `{cert-id}.crt` + `{cert-id}.key` to `/etc/traefik/certs`
|
||||
4. **Reload** — Traefik file provider detects new files and hot-loads them (zero downtime)
|
||||
5. **Monitor** — certctl tracks deployment status and renewal timelines
|
||||
|
||||
### Self-Signed CA
|
||||
|
||||
- Generated automatically on first startup if `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` are not set
|
||||
- Certificate stored in server's in-memory state (not persisted)
|
||||
- All issued certs chain to this self-signed root
|
||||
- Use this for: demos, development, internal labs
|
||||
|
||||
### Sub-CA Mode
|
||||
|
||||
- Requires you to provide an existing CA certificate and key
|
||||
- Issued certificates chain to your enterprise root CA
|
||||
- All issued certs are trustworthy to systems with your root CA in their trust store
|
||||
- Use this for: production internal services, compliance requirements, enterprise PKI
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
private-ca-traefik/
|
||||
├── docker-compose.yml # Stack definition
|
||||
├── traefik-config/ # Traefik dynamic config (you create)
|
||||
│ └── default.yaml # Routing rules and TLS settings
|
||||
├── ca-certs/ # CA certificate and key (for sub-CA mode)
|
||||
│ ├── ca-cert.pem # Your enterprise CA certificate
|
||||
│ └── ca-key.pem # Your enterprise CA private key
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### certctl Dashboard
|
||||
The server provides a REST API on port 8443. Example queries:
|
||||
|
||||
```bash
|
||||
# List all certificates
|
||||
curl http://localhost:8443/api/v1/certificates
|
||||
|
||||
# Check certificate status
|
||||
curl http://localhost:8443/api/v1/certificates/{cert-id}
|
||||
|
||||
# View audit trail
|
||||
curl http://localhost:8443/api/v1/audit
|
||||
|
||||
# Check renewal policy compliance
|
||||
curl http://localhost:8443/api/v1/policies/{policy-id}
|
||||
```
|
||||
|
||||
### Traefik Dashboard
|
||||
http://localhost:8080 shows:
|
||||
- HTTP routers and services
|
||||
- TLS certificates currently loaded
|
||||
- Request/response metrics
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
# certctl server logs
|
||||
docker compose logs certctl-server
|
||||
|
||||
# certctl agent logs
|
||||
docker compose logs certctl-agent
|
||||
|
||||
# Traefik logs
|
||||
docker compose logs traefik
|
||||
```
|
||||
|
||||
## Customizing Traefik Config
|
||||
|
||||
Edit `traefik-config/default.yaml` to add routers for your services:
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
# Internal API
|
||||
api:
|
||||
rule: "Host(`api.internal.local`)"
|
||||
service: api-service
|
||||
tls: {}
|
||||
|
||||
# Web application
|
||||
webapp:
|
||||
rule: "Host(`app.internal.local`)"
|
||||
service: webapp-service
|
||||
tls: {}
|
||||
|
||||
services:
|
||||
api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://api-backend:3000"
|
||||
|
||||
webapp-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://webapp-backend:3001"
|
||||
```
|
||||
|
||||
Changes are picked up automatically (file watcher enabled).
|
||||
|
||||
## Production Considerations
|
||||
|
||||
1. **Use sub-CA mode** — chain to your enterprise root for full trust
|
||||
2. **Enable API key authentication** — set `CERTCTL_AUTH_TYPE: api-key` and `CERTCTL_API_KEY`
|
||||
3. **Use agent-side key generation** — set `CERTCTL_KEYGEN_MODE: agent` (keys never leave agents)
|
||||
4. **Back up PostgreSQL** — certificate data is authoritative; database loss means certificate loss
|
||||
5. **Monitor renewal windows** — set up alerts on policy thresholds
|
||||
6. **Rotate CA keys regularly** — plan for future CA refresh (sub-CA mode)
|
||||
7. **Audit certificate usage** — review `certctl_audit_events` for compliance
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificates not deploying
|
||||
```bash
|
||||
# Check agent is healthy
|
||||
docker compose logs certctl-agent | grep heartbeat
|
||||
|
||||
# Check deployment job status
|
||||
curl http://localhost:8443/api/v1/jobs | jq '.[] | select(.type == "Deployment")'
|
||||
|
||||
# Check Traefik is watching the directory
|
||||
docker compose exec traefik ls -la /etc/traefik/certs/
|
||||
```
|
||||
|
||||
### Traefik not reloading certs
|
||||
```bash
|
||||
# Verify file provider is enabled (check docker-compose.yml command)
|
||||
# Verify certs volume is mounted at /etc/traefik/certs
|
||||
# Check Traefik logs
|
||||
docker compose logs traefik | grep "file"
|
||||
```
|
||||
|
||||
### CA cert not loading in sub-CA mode
|
||||
```bash
|
||||
# Verify file permissions
|
||||
docker compose exec certctl-server ls -la /etc/certctl/
|
||||
|
||||
# Check server logs for CA loading errors
|
||||
docker compose logs certctl-server | grep -i "ca\|cert"
|
||||
|
||||
# Verify CA certificate format
|
||||
openssl x509 -in ca-certs/ca-cert.pem -text -noout | grep -A 3 "Basic Constraints"
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Stop all services
|
||||
docker compose down
|
||||
|
||||
# Remove all data (certificates, database, etc.)
|
||||
docker compose down -v
|
||||
|
||||
# Remove CA cert files (if using custom CA)
|
||||
rm -rf ca-certs/
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Add more services** — create additional routers and backends in `traefik-config/default.yaml`
|
||||
2. **Set up renewal automation** — configure renewal policies with thresholds
|
||||
3. **Integrate with monitoring** — expose certctl metrics to Prometheus
|
||||
4. **Enable notifications** — configure email/Slack alerts on certificate events
|
||||
5. **Scale to multiple environments** — deploy separate certctl stacks per environment (dev/staging/prod)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [certctl Architecture](../../docs/architecture.md)
|
||||
- [Traefik File Provider](https://doc.traefik.io/traefik/providers/file/)
|
||||
- [Local CA Sub-CA Mode](../../docs/connectors.md#local-ca)
|
||||
- [Certificate Profiles](../../docs/quickstart.md#profiles)
|
||||
@@ -0,0 +1,204 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL database for certctl
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-postgres-stepca-haproxy
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Smallstep step-ca (internal private CA)
|
||||
# Initialized with default admin token and provisioner configuration
|
||||
step-ca:
|
||||
image: smallstep/step-ca:latest
|
||||
container_name: step-ca-stepca-haproxy
|
||||
environment:
|
||||
# step-ca root password (for key encryption)
|
||||
STEPPATH: /home/step/step-ca
|
||||
# Provisioner password will be set up below
|
||||
volumes:
|
||||
# Persist step-ca configuration and keys
|
||||
- step_ca_data:/home/step/step-ca
|
||||
- ./step-ca-init.sh:/opt/step-ca-init.sh:ro
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
# Initialize step-ca if not already done
|
||||
if [ ! -f /home/step/step-ca/config/ca.json ]; then
|
||||
echo "Initializing step-ca..."
|
||||
step ca init \
|
||||
--name="certctl-demo-ca" \
|
||||
--dns=step-ca \
|
||||
--address=0.0.0.0:9000 \
|
||||
--provisioner=admin \
|
||||
--provisioner-password-file=<(echo "${STEP_CA_PASSWORD:-stepca-demo-password}") \
|
||||
--password-file=<(echo "${STEP_CA_PASSWORD:-stepca-demo-password}") \
|
||||
--deployment-type=standalone \
|
||||
--acme 2>&1 || true
|
||||
fi
|
||||
|
||||
# Add a JWK provisioner for certctl if not present
|
||||
if ! step ca provisioner list 2>/dev/null | grep -q "certctl"; then
|
||||
echo "Adding certctl JWK provisioner..."
|
||||
step ca provisioner add certctl \
|
||||
--type=JWK \
|
||||
--password-file=<(echo "${STEP_CA_PROVISIONER_PASSWORD:-certctl-provisioner-demo}") \
|
||||
2>&1 || true
|
||||
fi
|
||||
|
||||
# Start step-ca
|
||||
echo "Starting step-ca..."
|
||||
step-ca /home/step/step-ca/config/ca.json \
|
||||
--password-file=<(echo "${STEP_CA_PASSWORD:-stepca-demo-password}")
|
||||
ports:
|
||||
- '9000:9000'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'step ca health --insecure || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl server (control plane)
|
||||
certctl-server:
|
||||
image: ghcr.io/shankar0123/certctl-server:latest
|
||||
container_name: certctl-server-stepca-haproxy
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
|
||||
# Auth (disabled for demo; production should use API keys)
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
|
||||
# CORS (allow agent communication)
|
||||
CERTCTL_CORS_ORIGINS: '*'
|
||||
|
||||
# Key generation mode (agent-side in production, server-side for demo)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
|
||||
# step-ca issuer configuration
|
||||
# step-ca runs on step-ca:9000 in this compose network
|
||||
CERTCTL_STEPCA_URL: https://step-ca:9000
|
||||
CERTCTL_STEPCA_ROOT_CERT_PATH: /etc/certctl/step-ca-root.crt
|
||||
CERTCTL_STEPCA_PROVISIONER: certctl
|
||||
CERTCTL_STEPCA_KEY_PATH: /etc/certctl/step-ca-provisioner.json
|
||||
CERTCTL_STEPCA_PASSWORD: ${STEP_CA_PROVISIONER_PASSWORD:-certctl-provisioner-demo}
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
volumes:
|
||||
# Mount step-ca certs for TLS verification (auto-generated by step-ca init)
|
||||
- step_ca_data:/home/step/step-ca/config:ro
|
||||
ports:
|
||||
- '${SERVER_PORT:-8443}:8443'
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
step-ca:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# certctl agent (runs on the target machine with HAProxy)
|
||||
certctl-agent:
|
||||
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||
container_name: certctl-agent-stepca-haproxy
|
||||
environment:
|
||||
# Control plane connection
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||
|
||||
# Key generation (agent-side keys, never sent to server)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||
|
||||
# Discovery (scan existing certs so operator knows what's already deployed)
|
||||
CERTCTL_DISCOVERY_DIRS: /etc/haproxy/ssl
|
||||
|
||||
# Heartbeat interval
|
||||
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||
|
||||
# Agent metadata (self-reported)
|
||||
CERTCTL_AGENT_NAME: haproxy-agent-01
|
||||
|
||||
# Logging
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
volumes:
|
||||
# Mount HAProxy config and cert directories
|
||||
# In production, these would be the actual HAProxy paths
|
||||
- haproxy_certs:/etc/haproxy/ssl
|
||||
- haproxy_conf:/etc/haproxy
|
||||
# Agent key storage (persisted across restarts)
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- certctl-network
|
||||
restart: unless-stopped
|
||||
|
||||
# HAProxy reverse proxy / load balancer
|
||||
# This is where certificates will be deployed
|
||||
haproxy:
|
||||
image: haproxy:2.9-alpine
|
||||
container_name: certctl-haproxy-stepca-haproxy
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- haproxy_conf:/etc/haproxy
|
||||
- haproxy_certs:/etc/haproxy/ssl
|
||||
# Default HAProxy config
|
||||
- ./haproxy.cfg:/etc/haproxy/haproxy.cfg:ro
|
||||
depends_on:
|
||||
- certctl-agent
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:8080/stats || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
certctl-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
step_ca_data:
|
||||
driver: local
|
||||
haproxy_certs:
|
||||
driver: local
|
||||
haproxy_conf:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
@@ -0,0 +1,69 @@
|
||||
global
|
||||
log stdout local0
|
||||
log stdout local1 notice
|
||||
chroot /var/lib/haproxy
|
||||
stats socket /run/haproxy/admin.sock mode 660 level admin
|
||||
stats timeout 30s
|
||||
user haproxy
|
||||
group haproxy
|
||||
daemon
|
||||
|
||||
# Default SSL options for modern TLS
|
||||
tune.ssl.default-dh-param 2048
|
||||
ssl-default-bind-ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384
|
||||
ssl-default-bind-options ssl-min-ver TLSv1.2
|
||||
|
||||
defaults
|
||||
mode http
|
||||
log global
|
||||
option httplog
|
||||
option dontlognull
|
||||
timeout connect 5000
|
||||
timeout client 50000
|
||||
timeout server 50000
|
||||
errorfile 400 /etc/haproxy/errors/400.http
|
||||
errorfile 403 /etc/haproxy/errors/403.http
|
||||
errorfile 408 /etc/haproxy/errors/408.http
|
||||
errorfile 500 /etc/haproxy/errors/500.http
|
||||
errorfile 502 /etc/haproxy/errors/502.http
|
||||
errorfile 503 /etc/haproxy/errors/503.http
|
||||
errorfile 504 /etc/haproxy/errors/504.http
|
||||
|
||||
# Statistics endpoint (accessible on port 8080)
|
||||
listen stats
|
||||
bind *:8080
|
||||
stats enable
|
||||
stats uri /stats
|
||||
stats refresh 30s
|
||||
stats admin if TRUE
|
||||
|
||||
# Example HTTPS frontend with certificate from certctl
|
||||
# This frontend will serve HTTPS on port 443 using a combined PEM file
|
||||
# deployed by certctl to /etc/haproxy/ssl/cert.pem
|
||||
frontend https_in
|
||||
# HTTP redirect to HTTPS
|
||||
bind *:80
|
||||
mode http
|
||||
acl is_http hdr(X-Forwarded-Proto) http
|
||||
redirect scheme https code 301 if !is_https
|
||||
|
||||
# HTTPS with certificate
|
||||
# In production, certctl will manage cert.pem and reload HAProxy after deployment
|
||||
bind *:443 ssl crt /etc/haproxy/ssl/cert.pem strict-sni
|
||||
mode http
|
||||
option httplog
|
||||
|
||||
# Default backend
|
||||
default_backend http_backend
|
||||
|
||||
# Example backend (simple web service placeholder)
|
||||
backend http_backend
|
||||
mode http
|
||||
option httpchk GET /
|
||||
server local_app 127.0.0.1:8000 check disabled
|
||||
|
||||
# Health endpoint (useful for certctl agent deployment verification)
|
||||
frontend health
|
||||
bind *:9999
|
||||
mode http
|
||||
monitor-uri /health
|
||||
@@ -0,0 +1,355 @@
|
||||
# step-ca + HAProxy Example
|
||||
|
||||
This example demonstrates certctl managing certificates issued by **Smallstep step-ca** and deploying them to **HAProxy**.
|
||||
|
||||
## Scenario
|
||||
|
||||
You're a Smallstep user running step-ca as your internal PKI. You have HAProxy load balancers that need certificates. This setup:
|
||||
|
||||
1. **step-ca** issues certificates (via JWK provisioner, no challenge solving)
|
||||
2. **certctl** manages the certificate lifecycle (renewal policies, deployment, audit)
|
||||
3. **HAProxy** serves HTTPS with certificates managed by certctl
|
||||
|
||||
This is the natural choice if you're already invested in step-ca and want to consolidate certificate lifecycle management without learning Let's Encrypt, DNS-01 challenges, or external integrations.
|
||||
|
||||
## What's Included
|
||||
|
||||
| Service | Image | Purpose |
|
||||
|---------|-------|---------|
|
||||
| **step-ca** | `smallstep/step-ca:latest` | Private internal CA |
|
||||
| **certctl-server** | `ghcr.io/shankar0123/certctl-server:latest` | Certificate management control plane |
|
||||
| **certctl-agent** | `ghcr.io/shankar0123/certctl-agent:latest` | Agent running on HAProxy server |
|
||||
| **haproxy** | `haproxy:2.9-alpine` | Reverse proxy / load balancer |
|
||||
| **postgres** | `postgres:16-alpine` | certctl audit trail + config storage |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Curl (to interact with APIs)
|
||||
|
||||
### 1. Start Everything
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will:
|
||||
- Initialize step-ca with a self-signed root CA
|
||||
- Create a JWK provisioner named `certctl` (pre-configured credentials)
|
||||
- Start certctl-server (connected to step-ca)
|
||||
- Start the certctl-agent (ready to deploy certs to HAProxy)
|
||||
- Start HAProxy with a placeholder config
|
||||
|
||||
Monitor logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f certctl-server
|
||||
```
|
||||
|
||||
Wait for all services to reach healthy state:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
NAME STATUS
|
||||
certctl-postgres-... healthy
|
||||
certctl-server-... healthy
|
||||
step-ca-... healthy
|
||||
certctl-agent-... running
|
||||
certctl-haproxy-... healthy
|
||||
```
|
||||
|
||||
### 2. Access certctl Dashboard
|
||||
|
||||
Open your browser to:
|
||||
|
||||
```
|
||||
http://localhost:8443
|
||||
```
|
||||
|
||||
You should see an empty dashboard. This is expected — no certificates issued yet.
|
||||
|
||||
### 3. Create a Certificate Profile
|
||||
|
||||
This defines what certificates certctl can issue (key algorithm, max TTL, allowed names).
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/profiles \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "internal-web",
|
||||
"key_type": "rsa-2048",
|
||||
"max_ttl_days": 90,
|
||||
"description": "Internal web services"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. Create an HAProxy Deployment Target
|
||||
|
||||
This tells certctl where to deploy certificates on the HAProxy server.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/targets \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "haproxy-01",
|
||||
"type": "haproxy",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"pem_path": "/etc/haproxy/ssl/cert.pem",
|
||||
"reload_command": "systemctl reload haproxy",
|
||||
"validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Note: In the Docker Compose environment, reload command can be `kill -HUP $(pidof haproxy)` instead of `systemctl reload haproxy`.
|
||||
|
||||
### 5. Create a Renewal Policy
|
||||
|
||||
This ties a certificate profile to a deployment target and sets renewal thresholds.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/renewal-policies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "haproxy-internal-web",
|
||||
"profile_id": "<profile_id_from_step_3>",
|
||||
"issuer_id": "iss-stepca",
|
||||
"enabled": true,
|
||||
"renewal_days_before_expiry": 30,
|
||||
"alert_thresholds_days": [30, 14, 7, 0]
|
||||
}'
|
||||
```
|
||||
|
||||
Get the issuer ID:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8443/api/v1/issuers | jq '.'
|
||||
```
|
||||
|
||||
You should see `iss-stepca` in the list.
|
||||
|
||||
### 6. Issue a Certificate
|
||||
|
||||
Request a certificate via the API. The server will sign it via step-ca.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"common_name": "api.internal.example.com",
|
||||
"sans": ["api.internal.example.com", "api.staging.example.com"],
|
||||
"issuer_id": "iss-stepca",
|
||||
"profile_id": "<profile_id_from_step_3>"
|
||||
}'
|
||||
```
|
||||
|
||||
### 7. Deploy to HAProxy
|
||||
|
||||
Get the certificate ID and trigger deployment:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8443/api/v1/certificates/<cert_id>/deploy \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"target_id": "<target_id_from_step_4>"
|
||||
}'
|
||||
```
|
||||
|
||||
The agent will:
|
||||
1. Fetch the deployment job
|
||||
2. Generate a combined PEM (cert + chain + key) locally
|
||||
3. Write it to `/etc/haproxy/ssl/cert.pem` on HAProxy
|
||||
4. Reload HAProxy
|
||||
5. Report status back to certctl
|
||||
|
||||
### 8. Verify in Dashboard
|
||||
|
||||
Refresh http://localhost:8443 and you should see:
|
||||
- 1 certificate (status: Active, expiry in 90 days)
|
||||
- 1 deployment job (status: Completed)
|
||||
- 1 agent (heartbeat: recent)
|
||||
|
||||
## Configuration Details
|
||||
|
||||
### step-ca Integration
|
||||
|
||||
step-ca is configured with:
|
||||
|
||||
- **Root CA Name**: `certctl-demo-ca`
|
||||
- **Provisioner**: `certctl` (JWK type)
|
||||
- **Default Password**: `certctl-provisioner-demo` (override with `STEP_CA_PROVISIONER_PASSWORD`)
|
||||
|
||||
To inspect step-ca:
|
||||
|
||||
```bash
|
||||
docker compose exec step-ca step ca provisioner list
|
||||
docker compose exec step-ca step ca health --insecure
|
||||
```
|
||||
|
||||
### HAProxy Combined PEM Format
|
||||
|
||||
HAProxy requires a single file with certificate, chain, and key concatenated:
|
||||
|
||||
```
|
||||
-----BEGIN CERTIFICATE-----
|
||||
[leaf certificate]
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
[intermediate CA]
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
[private key]
|
||||
-----END RSA PRIVATE KEY-----
|
||||
```
|
||||
|
||||
The agent automatically constructs this file from the issued certificate and step-ca-provided chain.
|
||||
|
||||
**Security**: The combined PEM is written with `0600` permissions (owner-readable only) because it contains the private key.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Customize behavior with:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `DB_PASSWORD` | `certctl-dev-password` | PostgreSQL password |
|
||||
| `STEP_CA_PASSWORD` | `stepca-demo-password` | step-ca root key password |
|
||||
| `STEP_CA_PROVISIONER_PASSWORD` | `certctl-provisioner-demo` | certctl JWK provisioner password |
|
||||
| `AGENT_API_KEY` | `agent-demo-key` | Agent authentication token |
|
||||
| `SERVER_PORT` | `8443` | certctl server external port |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
STEP_CA_PASSWORD=myca-password AGENT_API_KEY=secret-key docker compose up -d
|
||||
```
|
||||
|
||||
## Integrating with an Existing step-ca Instance
|
||||
|
||||
If you already run step-ca elsewhere (not in this Compose file):
|
||||
|
||||
1. **Extract the root certificate** from your step-ca:
|
||||
|
||||
```bash
|
||||
step ca root /tmp/step-ca-root.crt --ca-url https://ca.internal:9000 --insecure
|
||||
```
|
||||
|
||||
2. **Create or retrieve the certctl JWK provisioner key**:
|
||||
|
||||
```bash
|
||||
step ca provisioner list --ca-url https://ca.internal:9000 --insecure
|
||||
step ca provisioner describe certctl --ca-url https://ca.internal:9000 --insecure
|
||||
```
|
||||
|
||||
3. **Update docker-compose.yml**:
|
||||
|
||||
```yaml
|
||||
certctl-server:
|
||||
environment:
|
||||
CERTCTL_STEPCA_URL: https://ca.internal:9000
|
||||
CERTCTL_STEPCA_ROOT_CERT_PATH: /etc/certctl/step-ca-root.crt
|
||||
CERTCTL_STEPCA_PROVISIONER_NAME: certctl
|
||||
CERTCTL_STEPCA_PROVISIONER_KEY_PATH: /etc/certctl/step-ca-provisioner.json
|
||||
CERTCTL_STEPCA_PROVISIONER_PASSWORD: <your-password>
|
||||
```
|
||||
|
||||
4. **Mount the cert and key**:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/step-ca-root.crt:/etc/certctl/step-ca-root.crt:ro
|
||||
- /path/to/provisioner.json:/etc/certctl/step-ca-provisioner.json:ro
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
This removes all containers and volumes (step-ca config, certificates, database).
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Production Deployment
|
||||
|
||||
- Replace image tags (`latest` → specific version)
|
||||
- Use real TLS certificates for step-ca (self-signed is fine internally, but use proper roots for verification)
|
||||
- Configure persistent storage for step-ca keys (HSM or encrypted filesystem)
|
||||
- Set `CERTCTL_AUTH_TYPE: api-key` and rotate API keys regularly
|
||||
- Enable audit trail export for compliance
|
||||
- Configure renewal alerts (Slack, email, PagerDuty)
|
||||
- Run agents on separate machines (not in Compose)
|
||||
|
||||
### Advanced Features
|
||||
|
||||
- **Multiple HAProxy instances**: Create additional targets and agents
|
||||
- **Policy-based renewal**: Set different renewal windows per environment (staging vs. production)
|
||||
- **Approval workflows**: Require manual approval before deploying to production
|
||||
- **Discovery**: Scan existing HAProxy certs and bring them under management
|
||||
- **Network scanning**: Discover TLS endpoints in your network and inventory them
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### step-ca fails to initialize
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs step-ca
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Permissions on `/home/step/step-ca` volume
|
||||
- Port 9000 already in use
|
||||
|
||||
### Agent can't reach server
|
||||
|
||||
Verify network:
|
||||
|
||||
```bash
|
||||
docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health
|
||||
```
|
||||
|
||||
### HAProxy config validation fails
|
||||
|
||||
Check HAProxy config syntax:
|
||||
|
||||
```bash
|
||||
docker compose exec haproxy haproxy -c -f /etc/haproxy/haproxy.cfg
|
||||
```
|
||||
|
||||
### Deployment job stays in "Running" state
|
||||
|
||||
Check agent logs:
|
||||
|
||||
```bash
|
||||
docker compose logs certctl-agent
|
||||
```
|
||||
|
||||
Likely causes:
|
||||
- Agent can't write to `/etc/haproxy/ssl/cert.pem` (permissions)
|
||||
- Reload command is misconfigured
|
||||
- HAProxy container is not accessible
|
||||
|
||||
## Documentation
|
||||
|
||||
- [certctl Architecture](../../docs/architecture.md)
|
||||
- [step-ca Connector Docs](../../docs/connectors.md#step-ca)
|
||||
- [HAProxy Target Docs](../../docs/connectors.md#haproxy)
|
||||
- [API Reference](../../api/openapi.yaml)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the [troubleshooting guide](../../docs/troubleshooting.md)
|
||||
2. Review service logs: `docker compose logs <service>`
|
||||
3. Open an issue on GitHub
|
||||
@@ -0,0 +1,473 @@
|
||||
#!/bin/bash
|
||||
# certctl Agent Install Script
|
||||
# Detects OS (Linux/macOS) and architecture, downloads binary from GitHub Releases,
|
||||
# installs to system path, configures service (systemd/launchd), and prompts for config.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
GITHUB_REPO="shankar0123/certctl"
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPO}/releases/latest/download"
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
SERVICE_NAME="certctl-agent"
|
||||
|
||||
# Detect OS and architecture
|
||||
detect_platform() {
|
||||
local os="$(uname -s)"
|
||||
local arch="$(uname -m)"
|
||||
|
||||
case "$os" in
|
||||
Linux*)
|
||||
OS_TYPE="linux"
|
||||
;;
|
||||
Darwin*)
|
||||
OS_TYPE="darwin"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Error: Unsupported OS: $os${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$arch" in
|
||||
x86_64)
|
||||
ARCH_TYPE="amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
ARCH_TYPE="arm64"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Error: Unsupported architecture: $arch${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Print usage information
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Install and configure the certctl agent on your system.
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Show this help message
|
||||
--server-url URL Set CERTCTL_SERVER_URL (skips interactive prompt)
|
||||
--api-key KEY Set CERTCTL_API_KEY (skips interactive prompt)
|
||||
--no-start Install but don't start the service
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse command-line arguments
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--server-url)
|
||||
SERVER_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--api-key)
|
||||
API_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-start)
|
||||
NO_START=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Error: Unknown option: $1${NC}"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Check if running as root/sudo on Linux
|
||||
check_privileges() {
|
||||
if [[ "$OS_TYPE" == "linux" && "$EUID" -ne 0 ]]; then
|
||||
echo -e "${RED}Error: This script must be run as root on Linux. Try: sudo $0${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Download agent binary from GitHub Releases
|
||||
download_binary() {
|
||||
local binary_name="certctl-agent-${OS_TYPE}-${ARCH_TYPE}"
|
||||
local download_url="${RELEASE_URL}/${binary_name}"
|
||||
|
||||
echo -e "${YELLOW}Downloading certctl agent (${OS_TYPE}-${ARCH_TYPE})...${NC}"
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
echo -e "${RED}Error: curl is required but not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local temp_file=$(mktemp)
|
||||
trap "rm -f $temp_file" EXIT
|
||||
|
||||
if ! curl -sSL -f "$download_url" -o "$temp_file"; then
|
||||
echo -e "${RED}Error: Failed to download binary from $download_url${NC}"
|
||||
echo "Make sure the latest release exists on GitHub with the binary asset for ${OS_TYPE}-${ARCH_TYPE}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$temp_file"
|
||||
echo "$temp_file"
|
||||
}
|
||||
|
||||
# Install binary to system path
|
||||
install_binary() {
|
||||
local binary_path="$1"
|
||||
|
||||
echo -e "${YELLOW}Installing to $INSTALL_DIR/$SERVICE_NAME...${NC}"
|
||||
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME"
|
||||
else
|
||||
# macOS: use sudo if not already running as root
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
sudo cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME"
|
||||
else
|
||||
cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
chmod +x "$INSTALL_DIR/$SERVICE_NAME"
|
||||
echo -e "${GREEN}Binary installed: $INSTALL_DIR/$SERVICE_NAME${NC}"
|
||||
}
|
||||
|
||||
# Prompt for configuration (unless --server-url and --api-key provided)
|
||||
prompt_for_config() {
|
||||
if [[ -z "${SERVER_URL:-}" ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Enter certctl server URL (e.g., https://certctl.example.com):${NC}"
|
||||
read -r SERVER_URL
|
||||
if [[ -z "$SERVER_URL" ]]; then
|
||||
echo -e "${RED}Error: Server URL is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${API_KEY:-}" ]]; then
|
||||
echo -e "${YELLOW}Enter certctl API key:${NC}"
|
||||
read -sr API_KEY
|
||||
echo ""
|
||||
if [[ -z "$API_KEY" ]]; then
|
||||
echo -e "${RED}Error: API key is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${AGENT_ID:-}" ]]; then
|
||||
local default_agent_id="$(hostname)"
|
||||
echo -e "${YELLOW}Enter agent ID (default: $default_agent_id):${NC}"
|
||||
read -r AGENT_ID
|
||||
if [[ -z "$AGENT_ID" ]]; then
|
||||
AGENT_ID="$default_agent_id"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Create configuration directory and env file (Linux)
|
||||
setup_linux_config() {
|
||||
local config_dir="/etc/certctl"
|
||||
local config_file="$config_dir/agent.env"
|
||||
local key_dir="/var/lib/certctl/keys"
|
||||
|
||||
echo -e "${YELLOW}Creating configuration directory...${NC}"
|
||||
|
||||
# Create /etc/certctl with restrictive permissions
|
||||
mkdir -p "$config_dir"
|
||||
chmod 755 "$config_dir"
|
||||
|
||||
# Create key storage directory with 0700 permissions
|
||||
mkdir -p "$key_dir"
|
||||
chmod 700 "$key_dir"
|
||||
|
||||
# Write agent configuration (overwrite if exists)
|
||||
cat > "$config_file" <<EOF
|
||||
# certctl Agent Configuration
|
||||
# Generated by install-agent.sh on $(date)
|
||||
|
||||
# Agent ID (unique identifier in the fleet)
|
||||
CERTCTL_AGENT_ID=$AGENT_ID
|
||||
|
||||
# Control plane server URL
|
||||
CERTCTL_SERVER_URL=$SERVER_URL
|
||||
|
||||
# API authentication key
|
||||
CERTCTL_API_KEY=$API_KEY
|
||||
|
||||
# Key generation mode (agent = agent-side keygen, server = server-side for demo only)
|
||||
CERTCTL_KEYGEN_MODE=agent
|
||||
|
||||
# Key storage directory (agent-side keygen)
|
||||
CERTCTL_KEY_DIR=$key_dir
|
||||
|
||||
# Logging level (debug, info, warn, error)
|
||||
# CERTCTL_LOG_LEVEL=info
|
||||
|
||||
# Discovery directories (comma-separated paths to scan for existing certs)
|
||||
# CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live,/etc/ssl/certs
|
||||
|
||||
# Enable deployment verification (TLS endpoint check post-deployment)
|
||||
# CERTCTL_VERIFY_DEPLOYMENT=true
|
||||
EOF
|
||||
|
||||
# Restrict permissions on env file (contains API key)
|
||||
chmod 600 "$config_file"
|
||||
echo -e "${GREEN}Configuration written to: $config_file${NC}"
|
||||
}
|
||||
|
||||
# Create configuration directory and env file (macOS)
|
||||
setup_macos_config() {
|
||||
local config_dir="$HOME/.certctl"
|
||||
local config_file="$config_dir/agent.env"
|
||||
local key_dir="$config_dir/keys"
|
||||
|
||||
echo -e "${YELLOW}Creating configuration directory...${NC}"
|
||||
|
||||
# Create ~/.certctl with restrictive permissions
|
||||
mkdir -p "$config_dir"
|
||||
chmod 700 "$config_dir"
|
||||
|
||||
# Create key storage directory
|
||||
mkdir -p "$key_dir"
|
||||
chmod 700 "$key_dir"
|
||||
|
||||
# Write agent configuration (overwrite if exists)
|
||||
cat > "$config_file" <<EOF
|
||||
# certctl Agent Configuration
|
||||
# Generated by install-agent.sh on $(date)
|
||||
|
||||
# Agent ID (unique identifier in the fleet)
|
||||
CERTCTL_AGENT_ID=$AGENT_ID
|
||||
|
||||
# Control plane server URL
|
||||
CERTCTL_SERVER_URL=$SERVER_URL
|
||||
|
||||
# API authentication key
|
||||
CERTCTL_API_KEY=$API_KEY
|
||||
|
||||
# Key generation mode (agent = agent-side keygen, server = server-side for demo only)
|
||||
CERTCTL_KEYGEN_MODE=agent
|
||||
|
||||
# Key storage directory (agent-side keygen)
|
||||
CERTCTL_KEY_DIR=$key_dir
|
||||
|
||||
# Logging level (debug, info, warn, error)
|
||||
# CERTCTL_LOG_LEVEL=info
|
||||
|
||||
# Discovery directories (comma-separated paths to scan for existing certs)
|
||||
# CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live,/etc/ssl/certs
|
||||
|
||||
# Enable deployment verification (TLS endpoint check post-deployment)
|
||||
# CERTCTL_VERIFY_DEPLOYMENT=true
|
||||
EOF
|
||||
|
||||
# Restrict permissions on env file (contains API key)
|
||||
chmod 600 "$config_file"
|
||||
echo -e "${GREEN}Configuration written to: $config_file${NC}"
|
||||
}
|
||||
|
||||
# Create and enable systemd service (Linux only)
|
||||
setup_systemd_service() {
|
||||
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
echo -e "${YELLOW}Creating systemd service file...${NC}"
|
||||
|
||||
cat > "$service_file" <<'EOF'
|
||||
[Unit]
|
||||
Description=certctl Agent - Certificate Lifecycle Management
|
||||
Documentation=https://github.com/shankar0123/certctl
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Load environment from /etc/certctl/agent.env
|
||||
EnvironmentFile=/etc/certctl/agent.env
|
||||
|
||||
# Command to start the agent
|
||||
ExecStart=/usr/local/bin/certctl-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "$service_file"
|
||||
echo -e "${GREEN}Service file created: $service_file${NC}"
|
||||
|
||||
# Reload systemd daemon
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
# Create and enable launchd plist (macOS only)
|
||||
setup_launchd_service() {
|
||||
local plist_file="$HOME/Library/LaunchAgents/com.certctl.agent.plist"
|
||||
local config_file="$HOME/.certctl/agent.env"
|
||||
local launcher_script="$HOME/.certctl/launcher.sh"
|
||||
local home_dir="$HOME"
|
||||
|
||||
echo -e "${YELLOW}Creating launchd service file...${NC}"
|
||||
|
||||
mkdir -p "$(dirname "$plist_file")"
|
||||
|
||||
# Create wrapper script that sources env file before executing agent
|
||||
cat > "$launcher_script" <<'LAUNCHER_SCRIPT'
|
||||
#!/bin/bash
|
||||
set -a
|
||||
source "$HOME/.certctl/agent.env"
|
||||
set +a
|
||||
exec /usr/local/bin/certctl-agent
|
||||
LAUNCHER_SCRIPT
|
||||
|
||||
chmod 755 "$launcher_script"
|
||||
|
||||
# Create plist that references the launcher script
|
||||
cat > "$plist_file" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.certctl.agent</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$home_dir/.certctl/launcher.sh</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
<key>HOME</key>
|
||||
<string>$home_dir</string>
|
||||
</dict>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$home_dir/.certctl/agent.log</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$home_dir/.certctl/agent.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
chmod 644 "$plist_file"
|
||||
echo -e "${GREEN}Service file created: $plist_file${NC}"
|
||||
echo -e "${GREEN}Launcher script created: $launcher_script${NC}"
|
||||
}
|
||||
|
||||
# Start the agent service
|
||||
start_service() {
|
||||
if [[ "${NO_START:-false}" == "true" ]]; then
|
||||
echo -e "${YELLOW}Service not started (--no-start flag used)${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Starting certctl agent service...${NC}"
|
||||
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl start "$SERVICE_NAME"
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
echo -e "${GREEN}Service started successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}Warning: Service may not have started. Check logs with: systemctl status $SERVICE_NAME${NC}"
|
||||
fi
|
||||
else
|
||||
# macOS: load launchd service for current user
|
||||
launchctl load "$HOME/Library/LaunchAgents/com.certctl.agent.plist" 2>/dev/null || true
|
||||
sleep 1
|
||||
echo -e "${GREEN}Service loaded into launchd${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Print success message with next steps
|
||||
print_summary() {
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}certctl Agent Installation Complete${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
echo " Config file: /etc/certctl/agent.env"
|
||||
echo " Key storage: /var/lib/certctl/keys"
|
||||
echo " Service: /etc/systemd/system/${SERVICE_NAME}.service"
|
||||
echo " View logs: journalctl -u ${SERVICE_NAME} -f"
|
||||
else
|
||||
echo " Config file: $HOME/.certctl/agent.env"
|
||||
echo " Key storage: $HOME/.certctl/keys"
|
||||
echo " Service: $HOME/Library/LaunchAgents/com.certctl.agent.plist"
|
||||
echo " View logs: tail -f $HOME/.certctl/agent.log"
|
||||
fi
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Verify the service is running"
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
echo " systemctl status ${SERVICE_NAME}"
|
||||
else
|
||||
echo " launchctl list | grep certctl"
|
||||
fi
|
||||
echo ""
|
||||
echo " 2. Visit your certctl dashboard: $SERVER_URL"
|
||||
echo " 3. The agent should appear in the fleet overview within 30 seconds"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
main() {
|
||||
parse_args "$@"
|
||||
detect_platform
|
||||
check_privileges
|
||||
|
||||
echo -e "${GREEN}certctl Agent Installer${NC}"
|
||||
echo "Detected platform: ${OS_TYPE}-${ARCH_TYPE}"
|
||||
echo ""
|
||||
|
||||
prompt_for_config
|
||||
|
||||
# Download and install binary
|
||||
local binary_path
|
||||
binary_path=$(download_binary)
|
||||
install_binary "$binary_path"
|
||||
|
||||
# Setup OS-specific configuration
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
setup_linux_config
|
||||
setup_systemd_service
|
||||
else
|
||||
setup_macos_config
|
||||
setup_launchd_service
|
||||
fi
|
||||
|
||||
# Start the service
|
||||
start_service
|
||||
|
||||
# Print summary
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -134,6 +135,11 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
created, err := h.svc.RegisterAgent(r.Context(), agent)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, "Agent with this name already exists", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||
return
|
||||
}
|
||||
@@ -184,6 +190,11 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.Heartbeat(r.Context(), agentID, metadata); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("Heartbeat failed", "agent_id", agentID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -353,11 +353,12 @@ func TestCreateCertificate_Success(t *testing.T) {
|
||||
handler := NewCertificateHandler(mock)
|
||||
|
||||
certBody := domain.ManagedCertificate{
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
}
|
||||
body, _ := json.Marshal(certBody)
|
||||
|
||||
@@ -410,11 +411,12 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
|
||||
handler := NewCertificateHandler(mock)
|
||||
|
||||
certBody := domain.ManagedCertificate{
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
}
|
||||
body, _ := json.Marshal(certBody)
|
||||
|
||||
@@ -534,8 +536,8 @@ func TestArchiveCertificate_NotFound(t *testing.T) {
|
||||
|
||||
handler.ArchiveCertificate(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -231,6 +232,14 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("name", cert.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("renewal_policy_id", cert.RenewalPolicyID); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateCertificate(cert)
|
||||
if err != nil {
|
||||
@@ -287,6 +296,11 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
updated, err := h.svc.UpdateCertificate(id, cert)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
||||
return
|
||||
}
|
||||
@@ -311,6 +325,10 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
if err := h.svc.ArchiveCertificate(id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
|
||||
return
|
||||
}
|
||||
@@ -353,7 +371,12 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
|
||||
|
||||
versions, total, err := h.svc.GetCertificateVersions(certID, page, perPage)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate versions", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -387,6 +410,19 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
|
||||
certID := parts[0]
|
||||
|
||||
if err := h.svc.TriggerRenewal(certID); err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "cannot renew") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "already in progress") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
|
||||
return
|
||||
}
|
||||
@@ -480,7 +516,7 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") {
|
||||
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") || strings.Contains(errMsg, "failed to get") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -49,6 +50,7 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
||||
return
|
||||
}
|
||||
@@ -96,6 +98,11 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot be parsed") || strings.Contains(err.Error(), "no certificates found") {
|
||||
ErrorWithRequestID(w, http.StatusUnprocessableEntity, "Certificate data cannot be parsed as X.509", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,13 +178,17 @@ type ACMEConfig struct {
|
||||
|
||||
// DNSPresentScript is the path to a shell script that creates DNS TXT records.
|
||||
// Required for dns-01 and dns-persist-01 challenge types.
|
||||
// Script receives: DOMAIN_NAME, VALIDATION_TOKEN, RECORD_NAME as env vars.
|
||||
// Script receives these environment variables:
|
||||
// - CERTCTL_DNS_DOMAIN: domain being validated (e.g., "example.com")
|
||||
// - CERTCTL_DNS_FQDN: full record name (e.g., "_acme-challenge.example.com" or "_validation-persist.example.com")
|
||||
// - CERTCTL_DNS_VALUE: TXT record value (key authorization digest for dns-01, or issuer domain info for dns-persist-01)
|
||||
// - CERTCTL_DNS_TOKEN: ACME challenge token
|
||||
// Example: /opt/dns-scripts/add-record.sh
|
||||
DNSPresentScript string
|
||||
|
||||
// DNSCleanUpScript is the path to a shell script that removes DNS TXT records.
|
||||
// Used only for dns-01 challenges to clean up temporary validation records.
|
||||
// Script receives: DOMAIN_NAME, RECORD_NAME as env vars.
|
||||
// Script receives the same environment variables as DNSPresentScript.
|
||||
// Leave empty if cleanup is not needed (e.g., dns-persist-01).
|
||||
DNSCleanUpScript string
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ type Job struct {
|
||||
Type JobType `json:"type"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
AgentID *string `json:"agent_id,omitempty"`
|
||||
Status JobStatus `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
|
||||
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
var result []*domain.Job
|
||||
for _, j := range m.jobs {
|
||||
if j.AgentID != nil && *j.AgentID == agentID {
|
||||
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||
result = append(result, j)
|
||||
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||
result = append(result, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type mockAuditRepository struct {
|
||||
events []*domain.AuditEvent
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_create_certificate",
|
||||
Description: "Create a new managed certificate. Requires common_name and issuer_id at minimum.",
|
||||
Description: "Create a new managed certificate. Requires name, common_name, renewal_policy_id, issuer_id, owner_id, and team_id.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateCertificateInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/certificates", input)
|
||||
if err != nil {
|
||||
@@ -144,7 +144,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_trigger_renewal",
|
||||
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202).",
|
||||
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202). Returns 404 if certificate not found, 400 if certificate is archived/expired, 409 if renewal already in progress.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/certificates/"+input.ID+"/renew", nil)
|
||||
if err != nil {
|
||||
@@ -385,7 +385,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_register_agent",
|
||||
Description: "Register a new agent. Requires name and hostname.",
|
||||
Description: "Register a new agent. Requires name and hostname. Returns 409 if an agent with the same name already exists.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input RegisterAgentInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/agents", input)
|
||||
if err != nil {
|
||||
@@ -396,7 +396,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_agent_heartbeat",
|
||||
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version).",
|
||||
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version). Returns 404 if agent not found.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input struct {
|
||||
ID string `json:"id" jsonschema:"Agent ID"`
|
||||
Version string `json:"version,omitempty" jsonschema:"Agent version"`
|
||||
|
||||
@@ -111,6 +111,8 @@ type JobRepository interface {
|
||||
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
||||
// GetPendingJobs returns jobs not yet processed of a specific type.
|
||||
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
||||
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||
ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error)
|
||||
}
|
||||
|
||||
// RenewalPolicyRepository defines operations for managing renewal policies.
|
||||
|
||||
@@ -349,7 +349,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
|
||||
func (r *CertificateRepository) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, certificate_id, serial_number, not_before, not_after,
|
||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
||||
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||
FROM certificate_versions
|
||||
WHERE certificate_id = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -363,10 +363,12 @@ func (r *CertificateRepository) ListVersions(ctx context.Context, certID string)
|
||||
var versions []*domain.CertificateVersion
|
||||
for rows.Next() {
|
||||
var v domain.CertificateVersion
|
||||
var csrPEM sql.NullString
|
||||
if err := rows.Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
||||
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt); err != nil {
|
||||
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan certificate version: %w", err)
|
||||
}
|
||||
v.CSRPEM = csrPEM.String
|
||||
versions = append(versions, &v)
|
||||
}
|
||||
|
||||
@@ -386,11 +388,11 @@ func (r *CertificateRepository) CreateVersion(ctx context.Context, version *doma
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO certificate_versions (
|
||||
id, certificate_id, serial_number, not_before, not_after,
|
||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`, version.ID, version.CertificateID, version.SerialNumber, version.NotBefore, version.NotAfter,
|
||||
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.KeyAlgorithm, version.KeySize, version.CreatedAt).Scan(&version.ID)
|
||||
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.CreatedAt).Scan(&version.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create certificate version: %w", err)
|
||||
@@ -433,15 +435,17 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
|
||||
// GetLatestVersion returns the most recent certificate version for a certificate.
|
||||
func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
||||
var v domain.CertificateVersion
|
||||
var csrPEM sql.NullString
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, certificate_id, serial_number, not_before, not_after,
|
||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
||||
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||
FROM certificate_versions
|
||||
WHERE certificate_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, certID).Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
||||
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt)
|
||||
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt)
|
||||
v.CSRPEM = csrPEM.String
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get latest certificate version: %w", err)
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewJobRepository(db *sql.DB) *JobRepository {
|
||||
// List returns all jobs
|
||||
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
ORDER BY created_at DESC
|
||||
@@ -52,7 +52,7 @@ func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
||||
// Get retrieves a job by ID
|
||||
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE id = $1
|
||||
@@ -77,11 +77,11 @@ func (r *JobRepository) Create(ctx context.Context, job *domain.Job) error {
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO jobs (
|
||||
id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id
|
||||
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
||||
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
|
||||
job.CreatedAt).Scan(&job.ID)
|
||||
|
||||
@@ -99,15 +99,16 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
|
||||
type = $1,
|
||||
certificate_id = $2,
|
||||
target_id = $3,
|
||||
status = $4,
|
||||
attempts = $5,
|
||||
max_attempts = $6,
|
||||
last_error = $7,
|
||||
scheduled_at = $8,
|
||||
started_at = $9,
|
||||
completed_at = $10
|
||||
WHERE id = $11
|
||||
`, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
||||
agent_id = $4,
|
||||
status = $5,
|
||||
attempts = $6,
|
||||
max_attempts = $7,
|
||||
last_error = $8,
|
||||
scheduled_at = $9,
|
||||
started_at = $10,
|
||||
completed_at = $11
|
||||
WHERE id = $12
|
||||
`, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
||||
job.CompletedAt, job.ID)
|
||||
|
||||
@@ -150,7 +151,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
|
||||
// ListByStatus returns jobs with a specific status
|
||||
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE status = $1
|
||||
@@ -181,7 +182,7 @@ func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatu
|
||||
// ListByCertificate returns all jobs for a certificate
|
||||
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE certificate_id = $1
|
||||
@@ -239,7 +240,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
|
||||
// GetPendingJobs returns jobs not yet processed of a specific type
|
||||
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE type = $1 AND status = $2
|
||||
@@ -267,13 +268,71 @@ func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobTy
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||
// Deployment jobs are matched by agent_id directly (set at creation time), with a fallback
|
||||
// for legacy jobs where agent_id is NULL but target_id resolves to the agent via deployment_targets.
|
||||
// AwaitingCSR jobs are matched through certificate → target mappings → agent ownership.
|
||||
func (r *JobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE agent_id = $1 AND status = 'Pending' AND type = 'Deployment'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||
FROM jobs j
|
||||
INNER JOIN deployment_targets dt ON j.target_id = dt.id
|
||||
WHERE j.agent_id IS NULL AND j.status = 'Pending' AND j.type = 'Deployment'
|
||||
AND dt.agent_id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||
FROM jobs j
|
||||
WHERE j.status = 'AwaitingCSR'
|
||||
AND j.type IN ('Renewal', 'Issuance')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM certificate_target_mappings ctm
|
||||
INNER JOIN deployment_targets dt ON ctm.target_id = dt.id
|
||||
WHERE ctm.certificate_id = j.certificate_id
|
||||
AND dt.agent_id = $1
|
||||
)
|
||||
|
||||
ORDER BY created_at ASC
|
||||
`, agentID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query pending jobs for agent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []*domain.Job
|
||||
for rows.Next() {
|
||||
job, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating pending agent job rows: %w", err)
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// scanJob scans a job from a row or rows
|
||||
func scanJob(scanner interface {
|
||||
Scan(...interface{}) error
|
||||
}) (*domain.Job, error) {
|
||||
var job domain.Job
|
||||
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
|
||||
&job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
||||
&job.AgentID, &job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
||||
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -251,38 +251,17 @@ func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID strin
|
||||
|
||||
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
||||
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
||||
// Jobs are scoped to the requesting agent via agent_id (set at job creation) or
|
||||
// through target→agent relationships for legacy jobs and AwaitingCSR routing.
|
||||
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
// Fetch agent to verify it exists
|
||||
// Verify agent exists
|
||||
_, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
var workForAgent []*domain.Job
|
||||
|
||||
// Get pending deployment jobs
|
||||
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
|
||||
}
|
||||
for _, job := range pendingJobs {
|
||||
if job.Type == domain.JobTypeDeployment {
|
||||
workForAgent = append(workForAgent, job)
|
||||
}
|
||||
}
|
||||
|
||||
// Get AwaitingCSR jobs (agent keygen mode — agent needs to generate key + submit CSR)
|
||||
awaitingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusAwaitingCSR)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list awaiting CSR jobs: %w", err)
|
||||
}
|
||||
for _, job := range awaitingJobs {
|
||||
if job.Type == domain.JobTypeRenewal || job.Type == domain.JobTypeIssuance {
|
||||
workForAgent = append(workForAgent, job)
|
||||
}
|
||||
}
|
||||
|
||||
return workForAgent, nil
|
||||
// Return only jobs assigned to this agent (via agent_id or target→agent relationship)
|
||||
return s.jobRepo.ListPendingByAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
// ReportJobStatus updates a job's status based on agent feedback.
|
||||
|
||||
@@ -131,8 +131,9 @@ func TestHeartbeat_NotFound(t *testing.T) {
|
||||
func TestGetPendingWork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentID := "agent-001"
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
ID: agentID,
|
||||
Name: "prod-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
@@ -146,6 +147,7 @@ func TestGetPendingWork(t *testing.T) {
|
||||
Type: domain.JobTypeDeployment,
|
||||
CertificateID: "cert-001",
|
||||
Status: domain.JobStatusPending,
|
||||
AgentID: &agentID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
job2 := &domain.Job{
|
||||
@@ -157,7 +159,7 @@ func TestGetPendingWork(t *testing.T) {
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
||||
Agents: map[string]*domain.Agent{agentID: agent},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
@@ -177,7 +179,7 @@ func TestGetPendingWork(t *testing.T) {
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||
|
||||
jobs, err := agentService.GetPendingWork(ctx, "agent-001")
|
||||
jobs, err := agentService.GetPendingWork(ctx, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork failed: %v", err)
|
||||
}
|
||||
@@ -185,11 +187,132 @@ func TestGetPendingWork(t *testing.T) {
|
||||
if len(jobs) != 1 {
|
||||
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
||||
}
|
||||
if jobs[0].Type != domain.JobTypeDeployment {
|
||||
if len(jobs) > 0 && jobs[0].Type != domain.JobTypeDeployment {
|
||||
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentA := "agent-A"
|
||||
agentB := "agent-B"
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{
|
||||
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||
agentB: {ID: agentB, Name: "agent-B", Hostname: "host-b", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashB"},
|
||||
},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
jobA := &domain.Job{ID: "job-A", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-002", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-A": jobA, "job-B": jobB},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditService := NewAuditService(&mockAuditRepo{})
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||
|
||||
// Agent A should only see its job
|
||||
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork for agent-A failed: %v", err)
|
||||
}
|
||||
if len(jobsA) != 1 {
|
||||
t.Fatalf("expected 1 job for agent-A, got %d", len(jobsA))
|
||||
}
|
||||
if jobsA[0].ID != "job-A" {
|
||||
t.Errorf("expected job-A, got %s", jobsA[0].ID)
|
||||
}
|
||||
|
||||
// Agent B should only see its job
|
||||
jobsB, err := agentService.GetPendingWork(ctx, agentB)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork for agent-B failed: %v", err)
|
||||
}
|
||||
if len(jobsB) != 1 {
|
||||
t.Fatalf("expected 1 job for agent-B, got %d", len(jobsB))
|
||||
}
|
||||
if jobsB[0].ID != "job-B" {
|
||||
t.Errorf("expected job-B, got %s", jobsB[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentA := "agent-A"
|
||||
agentB := "agent-B"
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{
|
||||
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||
},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// All jobs belong to agent-B
|
||||
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-B": jobB},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditService := NewAuditService(&mockAuditRepo{})
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||
|
||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork failed: %v", err)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Errorf("expected 0 jobs for agent-A (all jobs are for agent-B), got %d", len(jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentA := "agent-A"
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{
|
||||
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||
},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
deployJob := &domain.Job{ID: "job-deploy", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||
csrJob := &domain.Job{ID: "job-csr", Type: domain.JobTypeRenewal, CertificateID: "cert-002", Status: domain.JobStatusAwaitingCSR, AgentID: &agentA, CreatedAt: now}
|
||||
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-deploy": deployJob, "job-csr": csrJob},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditService := NewAuditService(&mockAuditRepo{})
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||
|
||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork failed: %v", err)
|
||||
}
|
||||
if len(jobs) != 2 {
|
||||
t.Fatalf("expected 2 jobs (deployment + AwaitingCSR), got %d", len(jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportJobStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
@@ -311,12 +311,56 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
||||
}
|
||||
|
||||
// UpdateCertificate modifies a certificate (handler interface method).
|
||||
func (s *CertificateService) UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
cert.ID = id
|
||||
if err := s.certRepo.Update(context.Background(), &cert); err != nil {
|
||||
func (s *CertificateService) UpdateCertificate(id string, patch domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Fetch existing certificate so partial updates don't zero out fields
|
||||
existing, err := s.certRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certificate not found: %w", err)
|
||||
}
|
||||
|
||||
// Merge non-zero fields from patch into existing
|
||||
if patch.Name != "" {
|
||||
existing.Name = patch.Name
|
||||
}
|
||||
if patch.CommonName != "" {
|
||||
existing.CommonName = patch.CommonName
|
||||
}
|
||||
if len(patch.SANs) > 0 {
|
||||
existing.SANs = patch.SANs
|
||||
}
|
||||
if patch.Environment != "" {
|
||||
existing.Environment = patch.Environment
|
||||
}
|
||||
if patch.OwnerID != "" {
|
||||
existing.OwnerID = patch.OwnerID
|
||||
}
|
||||
if patch.TeamID != "" {
|
||||
existing.TeamID = patch.TeamID
|
||||
}
|
||||
if patch.IssuerID != "" {
|
||||
existing.IssuerID = patch.IssuerID
|
||||
}
|
||||
if patch.RenewalPolicyID != "" {
|
||||
existing.RenewalPolicyID = patch.RenewalPolicyID
|
||||
}
|
||||
if patch.CertificateProfileID != "" {
|
||||
existing.CertificateProfileID = patch.CertificateProfileID
|
||||
}
|
||||
if patch.Status != "" {
|
||||
existing.Status = patch.Status
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
existing.Tags = patch.Tags
|
||||
}
|
||||
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.certRepo.Update(ctx, existing); err != nil {
|
||||
return nil, fmt.Errorf("failed to update certificate: %w", err)
|
||||
}
|
||||
return &cert, nil
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// ArchiveCertificate marks a certificate as archived (handler interface method).
|
||||
|
||||
@@ -67,6 +67,11 @@ func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID str
|
||||
if target.ID != "" {
|
||||
job.TargetID = &target.ID
|
||||
}
|
||||
// Route job to the target's assigned agent
|
||||
if target.AgentID != "" {
|
||||
agentID := target.AgentID
|
||||
job.AgentID = &agentID
|
||||
}
|
||||
|
||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
||||
|
||||
@@ -85,6 +85,45 @@ func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
|
||||
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
||||
t.Errorf("expected job to have TargetID set")
|
||||
}
|
||||
|
||||
// M31: Verify AgentID is set from target's agent assignment
|
||||
if job.AgentID == nil {
|
||||
t.Errorf("expected job to have AgentID set (M31 agent routing)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentService_CreateDeploymentJobs_SetsAgentID verifies AgentID is populated from target.
|
||||
func TestDeploymentService_CreateDeploymentJobs_SetsAgentID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "tgt-nginx-1",
|
||||
Name: "NGINX Server 1",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-web-01",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDeploymentJobs failed: %v", err)
|
||||
}
|
||||
|
||||
if len(jobIDs) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(jobIDs))
|
||||
}
|
||||
|
||||
job := jobRepo.Jobs[jobIDs[0]]
|
||||
if job.AgentID == nil {
|
||||
t.Fatal("expected AgentID to be set on deployment job")
|
||||
}
|
||||
if *job.AgentID != "agent-web-01" {
|
||||
t.Errorf("expected AgentID 'agent-web-01', got '%s'", *job.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
|
||||
return nil, fmt.Errorf("report must contain at least one certificate or error")
|
||||
}
|
||||
|
||||
// Ensure directories is never nil (PostgreSQL TEXT[] NOT NULL)
|
||||
if report.Directories == nil {
|
||||
report.Directories = []string{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
scan := &domain.DiscoveryScan{
|
||||
ID: generateID("dscan"),
|
||||
@@ -52,6 +57,11 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
|
||||
CompletedAt: &now,
|
||||
}
|
||||
|
||||
// Store the scan record first (discovered certs reference scan via FK)
|
||||
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
|
||||
return nil, fmt.Errorf("failed to create scan record: %w", err)
|
||||
}
|
||||
|
||||
// Upsert each discovered certificate
|
||||
newCount := 0
|
||||
for _, entry := range report.Certificates {
|
||||
@@ -105,11 +115,6 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
|
||||
|
||||
scan.CertificatesNew = newCount
|
||||
|
||||
// Store the scan record
|
||||
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
|
||||
return nil, fmt.Errorf("failed to create scan record: %w", err)
|
||||
}
|
||||
|
||||
// Audit trail
|
||||
if err := s.auditService.RecordEvent(ctx, report.AgentID, domain.ActorTypeSystem,
|
||||
"discovery_scan_completed", "discovery_scan", scan.ID,
|
||||
|
||||
@@ -88,7 +88,7 @@ func (s *ExportService) ExportPKCS12(ctx context.Context, certID string, passwor
|
||||
// Parse PEM chain into x509.Certificate objects
|
||||
certs, err := parsePEMCertificates(version.PEMChain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate chain: %w", err)
|
||||
return nil, fmt.Errorf("certificate data cannot be parsed as X.509: %w", err)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
|
||||
@@ -26,12 +26,18 @@ type RenewalService struct {
|
||||
jobRepo repository.JobRepository
|
||||
renewalPolicyRepo repository.RenewalPolicyRepository
|
||||
profileRepo repository.CertificateProfileRepository
|
||||
targetRepo repository.TargetRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
keygenMode string // "agent" (default) or "server" (demo only)
|
||||
}
|
||||
|
||||
// SetTargetRepo sets the target repository for resolving agent_id on deployment jobs.
|
||||
func (s *RenewalService) SetTargetRepo(repo repository.TargetRepository) {
|
||||
s.targetRepo = repo
|
||||
}
|
||||
|
||||
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
|
||||
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
|
||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
||||
@@ -163,10 +169,39 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
||||
|
||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
||||
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer {
|
||||
connector, hasIssuer := s.issuerRegistry[cert.IssuerID]
|
||||
if !hasIssuer {
|
||||
continue
|
||||
}
|
||||
|
||||
// ARI check (RFC 9702): if the issuer supports ARI, let the CA direct renewal timing.
|
||||
// Fetch the latest cert version to get the PEM chain for the ARI query.
|
||||
ariChecked := false
|
||||
if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" {
|
||||
if ariResult, ariErr := connector.GetRenewalInfo(ctx, version.PEMChain); ariErr != nil {
|
||||
// ARI error is non-fatal — log and fall through to threshold-based renewal
|
||||
slog.Warn("ARI check failed, falling back to threshold-based renewal",
|
||||
"cert_id", cert.ID, "issuer_id", cert.IssuerID, "error", ariErr)
|
||||
} else if ariResult != nil {
|
||||
ariChecked = true
|
||||
now := time.Now()
|
||||
if now.Before(ariResult.SuggestedWindowStart) {
|
||||
// CA says it's too early to renew — skip this cert
|
||||
slog.Debug("ARI: renewal not yet suggested by CA",
|
||||
"cert_id", cert.ID,
|
||||
"suggested_start", ariResult.SuggestedWindowStart,
|
||||
"suggested_end", ariResult.SuggestedWindowEnd)
|
||||
continue
|
||||
}
|
||||
slog.Info("ARI: CA suggests renewal now",
|
||||
"cert_id", cert.ID,
|
||||
"suggested_start", ariResult.SuggestedWindowStart,
|
||||
"suggested_end", ariResult.SuggestedWindowEnd)
|
||||
}
|
||||
// ariResult == nil means issuer doesn't support ARI — fall through to threshold logic
|
||||
}
|
||||
_ = ariChecked // used for audit metadata below
|
||||
|
||||
// Check for existing pending/running renewal jobs to avoid duplicates
|
||||
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
|
||||
if err == nil {
|
||||
@@ -206,9 +241,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
auditMeta := map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}
|
||||
if ariChecked {
|
||||
auditMeta["renewal_trigger"] = "ari"
|
||||
}
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"renewal_job_created", "certificate", cert.ID,
|
||||
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}); auditErr != nil {
|
||||
"renewal_job_created", "certificate", cert.ID, auditMeta); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
@@ -604,12 +642,26 @@ func (s *RenewalService) createDeploymentJobs(ctx context.Context, cert *domain.
|
||||
}
|
||||
for _, targetID := range cert.TargetIDs {
|
||||
tid := targetID
|
||||
|
||||
// Resolve agent_id from target for job routing
|
||||
var agentIDPtr *string
|
||||
if s.targetRepo != nil {
|
||||
target, err := s.targetRepo.Get(ctx, tid)
|
||||
if err != nil {
|
||||
slog.Warn("failed to resolve agent for deployment job", "target_id", tid, "error", err)
|
||||
} else if target.AgentID != "" {
|
||||
agentID := target.AgentID
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
}
|
||||
|
||||
deployJob := &domain.Job{
|
||||
ID: generateID("job"),
|
||||
CertificateID: cert.ID,
|
||||
Type: domain.JobTypeDeployment,
|
||||
Status: domain.JobStatusPending,
|
||||
TargetID: &tid,
|
||||
AgentID: agentIDPtr,
|
||||
MaxAttempts: 3,
|
||||
ScheduledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
|
||||
@@ -863,4 +863,283 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ARI (RFC 9702) Scheduler Integration Tests ---
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI says renew now: window started in the past
|
||||
ariConnector := &mockIssuerConnector{
|
||||
getRenewalInfoResult: &RenewalInfoResult{
|
||||
SuggestedWindowStart: time.Now().Add(-24 * time.Hour),
|
||||
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
||||
},
|
||||
}
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-acme": ariConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create cert expiring in 20 days with a cert version (needed for ARI lookup)
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-renew",
|
||||
Name: "ARI Cert",
|
||||
CommonName: "ari.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-acme",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-1", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI says renew now, so a renewal job should be created
|
||||
hasRenewalJob := false
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
hasRenewalJob = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRenewalJob {
|
||||
t.Errorf("expected renewal job when ARI ShouldRenewNow is true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI says NOT yet: window starts in the future
|
||||
ariConnector := &mockIssuerConnector{
|
||||
getRenewalInfoResult: &RenewalInfoResult{
|
||||
SuggestedWindowStart: time.Now().Add(72 * time.Hour),
|
||||
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
||||
},
|
||||
}
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-acme": ariConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Cert is within the 30-day threshold window (would normally trigger renewal),
|
||||
// but ARI says "not yet"
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-wait",
|
||||
Name: "ARI Wait Cert",
|
||||
CommonName: "ari-wait.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-acme",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-2", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI says not yet, so NO renewal job should be created
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
t.Errorf("expected no renewal job when ARI says not yet, but found one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-local": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-nil",
|
||||
Name: "No ARI Cert",
|
||||
CommonName: "no-ari.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-3", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI is nil (not supported), so threshold-based logic applies; cert is within 30-day window
|
||||
hasRenewalJob := false
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
hasRenewalJob = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRenewalJob {
|
||||
t.Errorf("expected renewal job via threshold fallback when ARI returns nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI returns an error — should fall through to threshold-based renewal
|
||||
ariConnector := &mockIssuerConnector{
|
||||
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
||||
}
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-acme": ariConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-err",
|
||||
Name: "ARI Error Cert",
|
||||
CommonName: "ari-err.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-acme",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 15),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-4", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI failed but renewal should still happen via threshold fallback
|
||||
hasRenewalJob := false
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
hasRenewalJob = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRenewalJob {
|
||||
t.Errorf("expected renewal job via threshold fallback when ARI errors")
|
||||
}
|
||||
}
|
||||
|
||||
// stringPtr is defined in notification_test.go
|
||||
|
||||
@@ -321,8 +321,8 @@ func TestTeamService_Create_EmptyName(t *testing.T) {
|
||||
t.Fatalf("expected validation error for empty name, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, errors.New("team name is required")) {
|
||||
t.Logf("error: %v", err)
|
||||
if !strings.Contains(err.Error(), "team name is required") {
|
||||
t.Errorf("expected error containing 'team name is required', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,25 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.ListErr != nil {
|
||||
return nil, m.ListErr
|
||||
}
|
||||
var result []*domain.Job
|
||||
for _, j := range m.Jobs {
|
||||
if j.AgentID != nil && *j.AgentID == agentID {
|
||||
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||
result = append(result, j)
|
||||
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||
result = append(result, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepo) AddJob(job *domain.Job) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -660,8 +679,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
|
||||
|
||||
// mockIssuerConnector is a test implementation of IssuerConnector
|
||||
type mockIssuerConnector struct {
|
||||
Result *IssuanceResult
|
||||
Err error
|
||||
Result *IssuanceResult
|
||||
Err error
|
||||
getRenewalInfoResult *RenewalInfoResult
|
||||
getRenewalInfoErr error
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
@@ -717,14 +738,14 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
if m.getRenewalInfoErr != nil {
|
||||
return nil, m.getRenewalInfoErr
|
||||
}
|
||||
now := time.Now()
|
||||
return &RenewalInfoResult{
|
||||
SuggestedWindowStart: now,
|
||||
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
|
||||
}, nil
|
||||
if m.getRenewalInfoResult != nil {
|
||||
return m.getRenewalInfoResult, nil
|
||||
}
|
||||
// Default: return nil, nil (issuer does not support ARI)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Constructor functions for mocks
|
||||
|
||||
@@ -65,6 +65,10 @@ func (m *mockVerificationJobRepo) GetPendingJobs(ctx context.Context, jobType do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVerificationJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newVerificationTestService creates a VerificationService wired with test doubles.
|
||||
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
|
||||
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
|
||||
|
||||
+495
-164
@@ -1,60 +1,87 @@
|
||||
-- =============================================================================
|
||||
-- Demo Seed Data for certctl
|
||||
-- Run after schema migration to populate a realistic demo environment
|
||||
-- Demo Seed Data for certctl v2.0.14
|
||||
-- Run after schema migration to populate a realistic demo environment.
|
||||
-- Simulates 90 days of certificate lifecycle activity so the dashboard
|
||||
-- looks like a system that has been running in production for months.
|
||||
-- =============================================================================
|
||||
|
||||
-- Teams
|
||||
-- ============================================================
|
||||
-- 1. Organizations: Teams & Owners
|
||||
-- ============================================================
|
||||
INSERT INTO teams (id, name, description, created_at, updated_at) VALUES
|
||||
('t-platform', 'Platform Engineering', 'Core infrastructure and platform services', NOW(), NOW()),
|
||||
('t-security', 'Security Operations', 'Security tooling and compliance', NOW(), NOW()),
|
||||
('t-payments', 'Payments', 'Payment processing services', NOW(), NOW()),
|
||||
('t-frontend', 'Frontend', 'Web and mobile applications', NOW(), NOW()),
|
||||
('t-data', 'Data Engineering', 'Data pipelines and analytics', NOW(), NOW())
|
||||
('t-platform', 'Platform Engineering', 'Core infrastructure and platform services', NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('t-security', 'Security Operations', 'Security tooling and compliance', NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('t-payments', 'Payments', 'Payment processing services', NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||
('t-frontend', 'Frontend', 'Web and mobile applications', NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||
('t-data', 'Data Engineering', 'Data pipelines and analytics', NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
|
||||
('t-devops', 'DevOps', 'CI/CD and release engineering', NOW() - INTERVAL '90 days', NOW() - INTERVAL '90 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Owners
|
||||
INSERT INTO owners (id, name, email, team_id, created_at, updated_at) VALUES
|
||||
('o-alice', 'Alice Chen', 'alice@example.com', 't-platform', NOW(), NOW()),
|
||||
('o-bob', 'Bob Martinez', 'bob@example.com', 't-security', NOW(), NOW()),
|
||||
('o-carol', 'Carol Williams', 'carol@example.com', 't-payments', NOW(), NOW()),
|
||||
('o-dave', 'Dave Kim', 'dave@example.com', 't-frontend', NOW(), NOW()),
|
||||
('o-eve', 'Eve Johnson', 'eve@example.com', 't-data', NOW(), NOW())
|
||||
('o-alice', 'Alice Chen', 'alice@example.com', 't-platform', NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('o-bob', 'Bob Martinez', 'bob@example.com', 't-security', NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('o-carol', 'Carol Williams', 'carol@example.com', 't-payments', NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||
('o-dave', 'Dave Kim', 'dave@example.com', 't-frontend', NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||
('o-eve', 'Eve Johnson', 'eve@example.com', 't-data', NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
|
||||
('o-frank', 'Frank Torres', 'frank@example.com', 't-devops', NOW() - INTERVAL '90 days', NOW() - INTERVAL '90 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Renewal Policies
|
||||
-- ============================================================
|
||||
-- 2. Policies
|
||||
-- ============================================================
|
||||
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, alert_thresholds_days, created_at, updated_at) VALUES
|
||||
('rp-standard', 'Standard 30-day', 30, true, 3, 60, '[30, 14, 7, 0]'::jsonb, NOW(), NOW()),
|
||||
('rp-urgent', 'Urgent 14-day', 14, true, 5, 30, '[14, 7, 3, 0]'::jsonb, NOW(), NOW()),
|
||||
('rp-manual', 'Manual Only', 30, false, 0, 0, '[30, 14, 7, 0]'::jsonb, NOW(), NOW())
|
||||
('rp-standard', 'Standard 30-day', 30, true, 3, 60, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('rp-urgent', 'Urgent 14-day', 14, true, 5, 30, '[14, 7, 3, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('rp-manual', 'Manual Only', 30, false, 0, 0, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Issuers
|
||||
-- ============================================================
|
||||
-- 3. Issuers
|
||||
-- ============================================================
|
||||
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VALUES
|
||||
('iss-local', 'Local Dev CA', 'local', '{"ca_common_name": "CertCtl Demo CA", "validity_days": 90}', true, NOW(), NOW()),
|
||||
('iss-acme-le', 'Let''s Encrypt Staging', 'acme', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com"}', true, NOW(), NOW()),
|
||||
('iss-stepca', 'step-ca Internal', 'stepca', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', false, NOW(), NOW()),
|
||||
('iss-digicert', 'DigiCert (disabled)', 'generic_ca', '{"api_url": "https://api.digicert.com", "api_key": "REDACTED"}', false, NOW(), NOW())
|
||||
('iss-local', 'Local Dev CA', 'local', '{"ca_common_name": "CertCtl Demo CA", "validity_days": 90}', true, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('iss-acme-le', 'Let''s Encrypt Staging', 'acme', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||
('iss-stepca', 'step-ca Internal', 'stepca', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', true, NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
|
||||
('iss-acme-zs', 'ZeroSSL (EAB)', 'acme', '{"directory_url": "https://acme.zerossl.com/v2/DV90", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
|
||||
('iss-openssl', 'Custom OpenSSL CA', 'openssl', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Agents
|
||||
-- ============================================================
|
||||
-- 4. Agents (8 agents across multiple platforms)
|
||||
-- ============================================================
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '90 days', 'demo_hash_1', 'linux', 'amd64', '10.0.1.10', '1.0.0'),
|
||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '60 days', 'demo_hash_2', 'linux', 'amd64', '10.0.2.20', '1.0.0'),
|
||||
('ag-lb-prod', 'lb-prod-agent', 'f5-prod-01.internal', 'online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '120 days', 'demo_hash_3', 'linux', 'amd64', '10.0.1.50', '1.0.0'),
|
||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '30 days', 'demo_hash_4', 'windows', 'amd64', '10.0.3.15', '1.0.0'),
|
||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '45 days', 'demo_hash_5', 'linux', 'arm64', '10.0.4.30', '1.0.0')
|
||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '120 days', 'demo_hash_1', 'linux', 'amd64', '10.0.1.10', '2.0.14'),
|
||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '90 days', 'demo_hash_2', 'linux', 'amd64', '10.0.2.20', '2.0.14'),
|
||||
('ag-lb-prod', 'lb-prod-agent', 'lb-prod-01.internal', 'online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '150 days', 'demo_hash_3', 'linux', 'amd64', '10.0.1.50', '2.0.14'),
|
||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '60 days', 'demo_hash_4', 'windows', 'amd64', '10.0.3.15', '2.0.12'),
|
||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '90 days', 'demo_hash_5', 'linux', 'arm64', '10.0.4.30', '2.0.14'),
|
||||
('ag-edge-01', 'edge-eu-agent', 'edge-eu-01.internal', 'online', NOW() - INTERVAL '50 seconds', NOW() - INTERVAL '45 days', 'demo_hash_6', 'linux', 'arm64', '10.0.5.10', '2.0.14'),
|
||||
('ag-k8s-prod', 'k8s-prod-agent', 'k8s-node-01.internal', 'online', NOW() - INTERVAL '10 seconds', NOW() - INTERVAL '30 days', 'demo_hash_7', 'linux', 'amd64', '10.0.6.10', '2.0.14'),
|
||||
('ag-mac-dev', 'mac-dev-agent', 'dev-mac-01.internal', 'online', NOW() - INTERVAL '60 seconds', NOW() - INTERVAL '15 days', 'demo_hash_8', 'darwin', 'arm64', '10.0.7.5', '2.0.14')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Deployment Targets
|
||||
-- Sentinel agent for network-discovered certificates
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'online', NOW(), NOW() - INTERVAL '90 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.14')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Deployment Targets (8 targets across multiple connector types)
|
||||
-- ============================================================
|
||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at) VALUES
|
||||
('tgt-nginx-prod', 'NGINX Production', 'nginx', 'ag-web-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW(), NOW()),
|
||||
('tgt-nginx-staging', 'NGINX Staging', 'nginx', 'ag-web-staging', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW(), NOW()),
|
||||
('tgt-f5-prod', 'F5 BIG-IP Production','f5', 'ag-lb-prod', '{"host": "f5-prod-01.internal", "partition": "Common", "ssl_profile": "clientssl"}', true, NOW(), NOW()),
|
||||
('tgt-iis-prod', 'IIS Production', 'iis', 'ag-iis-prod', '{"site_name": "Default Web Site", "binding_info": "*:443:"}', true, NOW(), NOW()),
|
||||
('tgt-nginx-data', 'NGINX Data Services', 'nginx', 'ag-data-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW(), NOW())
|
||||
('tgt-nginx-prod', 'NGINX Production', 'nginx', 'ag-web-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '120 days', NOW()),
|
||||
('tgt-nginx-staging', 'NGINX Staging', 'nginx', 'ag-web-staging', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW()),
|
||||
('tgt-haproxy-prod', 'HAProxy Production', 'haproxy', 'ag-lb-prod', '{"combined_pem_path": "/etc/haproxy/ssl/site.pem", "reload_command": "systemctl reload haproxy"}', true, NOW() - INTERVAL '150 days', NOW()),
|
||||
('tgt-apache-prod', 'Apache Production', 'apache', 'ag-web-prod', '{"cert_path": "/etc/httpd/ssl/cert.pem", "key_path": "/etc/httpd/ssl/key.pem", "chain_path": "/etc/httpd/ssl/chain.pem", "reload_command": "apachectl graceful"}', true, NOW() - INTERVAL '100 days', NOW()),
|
||||
('tgt-iis-prod', 'IIS Production', 'iis', 'ag-iis-prod', '{"site_name": "Default Web Site", "binding_info": "*:443:"}', true, NOW() - INTERVAL '60 days', NOW()),
|
||||
('tgt-traefik-prod', 'Traefik Production', 'traefik', 'ag-k8s-prod', '{"watch_dir": "/etc/traefik/dynamic/certs"}', true, NOW() - INTERVAL '30 days', NOW()),
|
||||
('tgt-caddy-prod', 'Caddy Production', 'caddy', 'ag-edge-01', '{"mode": "api", "admin_url": "http://localhost:2019"}', true, NOW() - INTERVAL '45 days', NOW()),
|
||||
('tgt-nginx-data', 'NGINX Data Services', 'nginx', 'ag-data-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Certificate Profiles
|
||||
-- ============================================================
|
||||
-- 6. Certificate Profiles
|
||||
-- ============================================================
|
||||
INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms, max_ttl_seconds, allowed_ekus, required_san_patterns, spiffe_uri_pattern, allow_short_lived, enabled, created_at, updated_at) VALUES
|
||||
('prof-standard-tls', 'Standard TLS',
|
||||
'Default profile for web-facing TLS certificates. Requires ECDSA P-256+ or RSA 2048+.',
|
||||
@@ -62,7 +89,7 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
7776000, -- 90 days
|
||||
'["serverAuth"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'', false, true, NOW(), NOW()),
|
||||
'', false, true, NOW() - INTERVAL '180 days', NOW()),
|
||||
|
||||
('prof-internal-mtls', 'Internal mTLS',
|
||||
'Mutual TLS profile for internal service-to-service communication.',
|
||||
@@ -70,7 +97,7 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
2592000, -- 30 days
|
||||
'["serverAuth", "clientAuth"]'::jsonb,
|
||||
'[".*\\.internal\\.example\\.com$"]'::jsonb,
|
||||
'', false, true, NOW(), NOW()),
|
||||
'', false, true, NOW() - INTERVAL '150 days', NOW()),
|
||||
|
||||
('prof-short-lived', 'Short-Lived Credential',
|
||||
'Ephemeral certificates for CI/CD pipelines and container workloads. TTL under 1 hour, expiry = revocation.',
|
||||
@@ -79,7 +106,7 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
'["serverAuth", "clientAuth"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'spiffe://example.com/workload/*',
|
||||
true, true, NOW(), NOW()),
|
||||
true, true, NOW() - INTERVAL '120 days', NOW()),
|
||||
|
||||
('prof-high-security', 'High Security',
|
||||
'For PCI-DSS and compliance-sensitive workloads. RSA 4096+ or ECDSA P-384+ only.',
|
||||
@@ -87,7 +114,7 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
4060800, -- 47 days (Ballot SC-081v3 target)
|
||||
'["serverAuth"]'::jsonb,
|
||||
'[".*\\.example\\.com$"]'::jsonb,
|
||||
'', false, true, NOW(), NOW()),
|
||||
'', false, true, NOW() - INTERVAL '90 days', NOW()),
|
||||
|
||||
('prof-smime', 'S/MIME Email',
|
||||
'S/MIME certificate profile for email signing and encryption. Requires emailProtection EKU.',
|
||||
@@ -95,147 +122,446 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
31536000, -- 365 days
|
||||
'["emailProtection"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'', false, true, NOW(), NOW())
|
||||
'', false, true, NOW() - INTERVAL '60 days', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Managed Certificates — varied statuses and expiry dates for realistic dashboard
|
||||
-- ============================================================
|
||||
-- 7. Managed Certificates (35 certs across multiple issuers and environments)
|
||||
-- ============================================================
|
||||
INSERT INTO managed_certificates (id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at) VALUES
|
||||
-- Active, healthy certs
|
||||
('mc-api-prod', 'api-production', 'api.example.com', ARRAY['api.example.com', 'api-v2.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '75 days', '{"service": "api-gateway", "tier": "critical"}', NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days', NOW() - INTERVAL '180 days', NOW()),
|
||||
('mc-web-prod', 'web-production', 'www.example.com', ARRAY['www.example.com', 'example.com'], 'production', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '60 days', '{"service": "web-app", "tier": "critical"}', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '365 days', NOW()),
|
||||
('mc-pay-prod', 'payments-production', 'pay.example.com', ARRAY['pay.example.com', 'checkout.example.com'], 'production', 'o-carol', 't-payments', 'iss-local', 'rp-urgent', 'Active', NOW() + INTERVAL '45 days', '{"service": "payments", "tier": "critical", "pci": "true"}', NOW() - INTERVAL '45 days', NOW() - INTERVAL '45 days', NOW() - INTERVAL '200 days', NOW()),
|
||||
('mc-dash-prod', 'dashboard-production', 'dashboard.example.com', ARRAY['dashboard.example.com'], 'production', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '82 days', '{"service": "dashboard", "tier": "high"}', NOW() - INTERVAL '8 days', NOW() - INTERVAL '8 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
('mc-data-prod', 'data-api-production', 'data.example.com', ARRAY['data.example.com', 'analytics.example.com'], 'production', 'o-eve', 't-data', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '55 days', '{"service": "data-api", "tier": "high"}', NOW() - INTERVAL '35 days', NOW() - INTERVAL '35 days', NOW() - INTERVAL '150 days', NOW()),
|
||||
-- ---- Active, healthy production certs (Local CA) ----
|
||||
('mc-api-prod', 'api-production', 'api.example.com', ARRAY['api.example.com', 'api-v2.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '75 days', '{"service": "api-gateway", "tier": "critical"}', NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days', NOW() - INTERVAL '180 days', NOW()),
|
||||
('mc-web-prod', 'web-production', 'www.example.com', ARRAY['www.example.com', 'example.com'], 'production', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '60 days', '{"service": "web-app", "tier": "critical"}', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '365 days', NOW()),
|
||||
('mc-pay-prod', 'payments-production', 'pay.example.com', ARRAY['pay.example.com', 'checkout.example.com'], 'production', 'o-carol', 't-payments', 'iss-local', 'rp-urgent', 'Active', NOW() + INTERVAL '40 days', '{"service": "payments", "tier": "critical", "pci": "true"}', NOW() - INTERVAL '50 days', NOW() - INTERVAL '50 days', NOW() - INTERVAL '200 days', NOW()),
|
||||
('mc-dash-prod', 'dashboard-production', 'dashboard.example.com', ARRAY['dashboard.example.com'], 'production', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '82 days', '{"service": "dashboard", "tier": "high"}', NOW() - INTERVAL '8 days', NOW() - INTERVAL '8 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
('mc-data-prod', 'data-api-production', 'data.example.com', ARRAY['data.example.com', 'analytics.example.com'], 'production', 'o-eve', 't-data', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '55 days', '{"service": "data-api", "tier": "high"}', NOW() - INTERVAL '35 days', NOW() - INTERVAL '35 days', NOW() - INTERVAL '150 days', NOW()),
|
||||
('mc-search-prod', 'search-production', 'search.example.com', ARRAY['search.example.com', 'es.example.com'], 'production', 'o-eve', 't-data', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '68 days', '{"service": "search", "tier": "high"}', NOW() - INTERVAL '22 days', NOW() - INTERVAL '22 days', NOW() - INTERVAL '130 days', NOW()),
|
||||
('mc-admin-prod', 'admin-production', 'admin.example.com', ARRAY['admin.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-urgent', 'Active', NOW() + INTERVAL '35 days', '{"service": "admin-panel", "tier": "critical"}', NOW() - INTERVAL '55 days', NOW() - INTERVAL '55 days', NOW() - INTERVAL '200 days', NOW()),
|
||||
|
||||
-- Expiring soon (< 30 days)
|
||||
('mc-auth-prod', 'auth-production', 'auth.example.com', ARRAY['auth.example.com', 'login.example.com', 'sso.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-urgent', 'Expiring', NOW() + INTERVAL '12 days', '{"service": "auth", "tier": "critical"}', NOW() - INTERVAL '78 days', NOW() - INTERVAL '78 days', NOW() - INTERVAL '300 days', NOW()),
|
||||
('mc-cdn-prod', 'cdn-production', 'cdn.example.com', ARRAY['cdn.example.com', 'static.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Expiring', NOW() + INTERVAL '8 days', '{"service": "cdn", "tier": "high"}', NOW() - INTERVAL '82 days', NOW() - INTERVAL '82 days', NOW() - INTERVAL '250 days', NOW()),
|
||||
('mc-mail-prod', 'mail-production', 'mail.example.com', ARRAY['mail.example.com', 'smtp.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-standard', 'Expiring', NOW() + INTERVAL '5 days', '{"service": "email", "tier": "medium"}', NOW() - INTERVAL '85 days', NOW() - INTERVAL '85 days', NOW() - INTERVAL '400 days', NOW()),
|
||||
-- ---- Active certs via ACME (Let's Encrypt) ----
|
||||
('mc-blog-prod', 'blog-production', 'blog.example.com', ARRAY['blog.example.com'], 'production', 'o-dave', 't-frontend', 'iss-acme-le', 'rp-standard', 'Active', NOW() + INTERVAL '52 days', '{"service": "blog", "tier": "medium"}', NOW() - INTERVAL '38 days', NOW() - INTERVAL '38 days', NOW() - INTERVAL '160 days', NOW()),
|
||||
('mc-docs-prod', 'docs-production', 'docs.example.com', ARRAY['docs.example.com', 'help.example.com'], 'production', 'o-dave', 't-frontend', 'iss-acme-le', 'rp-standard', 'Active', NOW() + INTERVAL '47 days', '{"service": "docs", "tier": "medium"}', NOW() - INTERVAL '43 days', NOW() - INTERVAL '43 days', NOW() - INTERVAL '140 days', NOW()),
|
||||
('mc-status-prod', 'status-production', 'status.example.com', ARRAY['status.example.com'], 'production', 'o-frank', 't-devops', 'iss-acme-le', 'rp-standard', 'Active', NOW() + INTERVAL '71 days', '{"service": "status-page", "tier": "high"}', NOW() - INTERVAL '19 days', NOW() - INTERVAL '19 days', NOW() - INTERVAL '80 days', NOW()),
|
||||
|
||||
-- Expired
|
||||
('mc-legacy-prod', 'legacy-app', 'legacy.example.com', ARRAY['legacy.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-manual', 'Expired', NOW() - INTERVAL '3 days', '{"service": "legacy", "tier": "low", "decom": "planned"}', NOW() - INTERVAL '93 days', NOW() - INTERVAL '93 days', NOW() - INTERVAL '500 days', NOW()),
|
||||
('mc-old-api', 'old-api-v1', 'api-v1.example.com', ARRAY['api-v1.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-manual', 'Expired', NOW() - INTERVAL '15 days', '{"service": "api-v1", "tier": "low", "deprecated": "true"}', NULL, NULL, NOW() - INTERVAL '600 days', NOW()),
|
||||
-- ---- Active certs via step-ca (internal services) ----
|
||||
('mc-grpc-prod', 'grpc-internal', 'grpc.internal.example.com', ARRAY['grpc.internal.example.com'], 'production', 'o-alice', 't-platform', 'iss-stepca', 'rp-standard', 'Active', NOW() + INTERVAL '58 days', '{"service": "grpc-gateway", "tier": "high"}', NOW() - INTERVAL '32 days', NOW() - INTERVAL '32 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
('mc-vault-prod', 'vault-internal', 'vault.internal.example.com', ARRAY['vault.internal.example.com'], 'production', 'o-bob', 't-security', 'iss-stepca', 'rp-urgent', 'Active', NOW() + INTERVAL '25 days', '{"service": "vault", "tier": "critical"}', NOW() - INTERVAL '65 days', NOW() - INTERVAL '65 days', NOW() - INTERVAL '120 days', NOW()),
|
||||
('mc-consul-prod', 'consul-internal', 'consul.internal.example.com', ARRAY['consul.internal.example.com'], 'production', 'o-alice', 't-platform', 'iss-stepca', 'rp-standard', 'Active', NOW() + INTERVAL '63 days', '{"service": "consul", "tier": "high"}', NOW() - INTERVAL '27 days', NOW() - INTERVAL '27 days', NOW() - INTERVAL '90 days', NOW()),
|
||||
|
||||
-- Staging certs
|
||||
('mc-api-stg', 'api-staging', 'api.staging.example.com', ARRAY['api.staging.example.com'], 'staging', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '65 days', '{"service": "api-gateway", "tier": "low"}', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '120 days', NOW()),
|
||||
('mc-web-stg', 'web-staging', 'www.staging.example.com', ARRAY['www.staging.example.com', 'staging.example.com'], 'staging', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '70 days', '{"service": "web-app", "tier": "low"}', NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
-- ---- Active certs via ZeroSSL ----
|
||||
('mc-shop-prod', 'shop-production', 'shop.example.com', ARRAY['shop.example.com', 'store.example.com'], 'production', 'o-carol', 't-payments', 'iss-acme-zs', 'rp-urgent', 'Active', NOW() + INTERVAL '44 days', '{"service": "shop", "tier": "critical", "pci": "true"}', NOW() - INTERVAL '46 days', NOW() - INTERVAL '46 days', NOW() - INTERVAL '60 days', NOW()),
|
||||
|
||||
-- Renewal in progress
|
||||
('mc-grafana-prod', 'grafana-production', 'grafana.example.com', ARRAY['grafana.example.com', 'metrics.example.com'], 'production', 'o-eve', 't-data', 'iss-local', 'rp-standard', 'RenewalInProgress', NOW() + INTERVAL '3 days', '{"service": "monitoring", "tier": "high"}', NOW() - INTERVAL '87 days', NOW() - INTERVAL '87 days', NOW() - INTERVAL '180 days', NOW()),
|
||||
-- ---- Expiring soon (< 30 days) ----
|
||||
('mc-auth-prod', 'auth-production', 'auth.example.com', ARRAY['auth.example.com', 'login.example.com', 'sso.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-urgent', 'Expiring', NOW() + INTERVAL '12 days', '{"service": "auth", "tier": "critical"}', NOW() - INTERVAL '78 days', NOW() - INTERVAL '78 days', NOW() - INTERVAL '300 days', NOW()),
|
||||
('mc-cdn-prod', 'cdn-production', 'cdn.example.com', ARRAY['cdn.example.com', 'static.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Expiring', NOW() + INTERVAL '8 days', '{"service": "cdn", "tier": "high"}', NOW() - INTERVAL '82 days', NOW() - INTERVAL '82 days', NOW() - INTERVAL '250 days', NOW()),
|
||||
('mc-mail-prod', 'mail-production', 'mail.example.com', ARRAY['mail.example.com', 'smtp.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-standard', 'Expiring', NOW() + INTERVAL '5 days', '{"service": "email", "tier": "medium"}', NOW() - INTERVAL '85 days', NOW() - INTERVAL '85 days', NOW() - INTERVAL '400 days', NOW()),
|
||||
('mc-ci-prod', 'ci-production', 'ci.example.com', ARRAY['ci.example.com', 'jenkins.example.com'], 'production', 'o-frank', 't-devops', 'iss-acme-le', 'rp-standard', 'Expiring', NOW() + INTERVAL '18 days', '{"service": "ci", "tier": "high"}', NOW() - INTERVAL '72 days', NOW() - INTERVAL '72 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
|
||||
-- Failed
|
||||
('mc-vpn-prod', 'vpn-production', 'vpn.example.com', ARRAY['vpn.example.com'], 'production', 'o-bob', 't-security', 'iss-acme-le', 'rp-urgent', 'Failed', NOW() + INTERVAL '1 day', '{"service": "vpn", "tier": "critical"}', NULL, NULL, NOW() - INTERVAL '90 days', NOW()),
|
||||
-- ---- Expired ----
|
||||
('mc-legacy-prod', 'legacy-app', 'legacy.example.com', ARRAY['legacy.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-manual', 'Expired', NOW() - INTERVAL '3 days', '{"service": "legacy", "tier": "low", "decom": "planned"}', NOW() - INTERVAL '93 days', NOW() - INTERVAL '93 days', NOW() - INTERVAL '500 days', NOW()),
|
||||
('mc-old-api', 'old-api-v1', 'api-v1.example.com', ARRAY['api-v1.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-manual', 'Expired', NOW() - INTERVAL '15 days', '{"service": "api-v1", "tier": "low", "deprecated": "true"}', NULL, NULL, NOW() - INTERVAL '600 days', NOW()),
|
||||
('mc-wiki-prod', 'wiki-production', 'wiki.example.com', ARRAY['wiki.example.com'], 'production', 'o-dave', 't-frontend', 'iss-acme-le', 'rp-manual', 'Expired', NOW() - INTERVAL '7 days', '{"service": "wiki", "tier": "low"}', NOW() - INTERVAL '97 days', NOW() - INTERVAL '97 days', NOW() - INTERVAL '300 days', NOW()),
|
||||
|
||||
-- Wildcard
|
||||
('mc-wildcard-prod', 'wildcard-production', '*.example.com', ARRAY['*.example.com', 'example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '50 days', '{"service": "wildcard", "tier": "critical"}', NOW() - INTERVAL '40 days', NOW() - INTERVAL '40 days', NOW() - INTERVAL '365 days', NOW())
|
||||
-- ---- Staging certs ----
|
||||
('mc-api-stg', 'api-staging', 'api.staging.example.com', ARRAY['api.staging.example.com'], 'staging', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '65 days', '{"service": "api-gateway", "tier": "low"}', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '120 days', NOW()),
|
||||
('mc-web-stg', 'web-staging', 'www.staging.example.com', ARRAY['www.staging.example.com', 'staging.example.com'], 'staging', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '70 days', '{"service": "web-app", "tier": "low"}', NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
('mc-pay-stg', 'payments-staging', 'pay.staging.example.com', ARRAY['pay.staging.example.com'], 'staging', 'o-carol', 't-payments', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '72 days', '{"service": "payments", "tier": "low"}', NOW() - INTERVAL '18 days', NOW() - INTERVAL '18 days', NOW() - INTERVAL '80 days', NOW()),
|
||||
|
||||
-- ---- Development certs ----
|
||||
('mc-api-dev', 'api-development', 'api.dev.example.com', ARRAY['api.dev.example.com'], 'development', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '85 days', '{"service": "api-gateway", "tier": "low"}', NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days', NOW() - INTERVAL '45 days', NOW()),
|
||||
|
||||
-- ---- Renewal in progress ----
|
||||
('mc-grafana-prod', 'grafana-production', 'grafana.example.com', ARRAY['grafana.example.com', 'metrics.example.com'], 'production', 'o-eve', 't-data', 'iss-local', 'rp-standard', 'RenewalInProgress', NOW() + INTERVAL '3 days', '{"service": "monitoring", "tier": "high"}', NOW() - INTERVAL '87 days', NOW() - INTERVAL '87 days', NOW() - INTERVAL '180 days', NOW()),
|
||||
|
||||
-- ---- Failed ----
|
||||
('mc-vpn-prod', 'vpn-production', 'vpn.example.com', ARRAY['vpn.example.com'], 'production', 'o-bob', 't-security', 'iss-acme-le', 'rp-urgent', 'Failed', NOW() + INTERVAL '1 day', '{"service": "vpn", "tier": "critical"}', NULL, NULL, NOW() - INTERVAL '90 days', NOW()),
|
||||
|
||||
-- ---- Wildcard ----
|
||||
('mc-wildcard-prod', 'wildcard-production', '*.example.com', ARRAY['*.example.com', 'example.com'], 'production', 'o-alice', 't-platform', 'iss-acme-le', 'rp-standard', 'Active', NOW() + INTERVAL '50 days', '{"service": "wildcard", "tier": "critical"}', NOW() - INTERVAL '40 days', NOW() - INTERVAL '40 days', NOW() - INTERVAL '365 days', NOW()),
|
||||
|
||||
-- ---- Revoked ----
|
||||
('mc-compromised', 'compromised-cert', 'old-service.example.com', ARRAY['old-service.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-standard', 'Revoked', NOW() + INTERVAL '30 days', '{"service": "decommissioned", "tier": "low"}', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '120 days', NOW()),
|
||||
|
||||
-- ---- Edge/CDN certs (Traefik + Caddy targets) ----
|
||||
('mc-edge-eu', 'edge-eu-production', 'eu.cdn.example.com', ARRAY['eu.cdn.example.com', 'eu-assets.example.com'], 'production', 'o-alice', 't-platform', 'iss-acme-le', 'rp-standard', 'Active', NOW() + INTERVAL '61 days', '{"service": "cdn-eu", "tier": "high", "region": "eu-west-1"}', NOW() - INTERVAL '29 days', NOW() - INTERVAL '29 days', NOW() - INTERVAL '45 days', NOW()),
|
||||
('mc-k8s-ingress', 'k8s-ingress', 'ingress.example.com', ARRAY['ingress.example.com', 'app.example.com'], 'production', 'o-frank', 't-devops', 'iss-acme-le', 'rp-standard', 'Active', NOW() + INTERVAL '56 days', '{"service": "k8s-ingress", "tier": "critical"}', NOW() - INTERVAL '34 days', NOW() - INTERVAL '34 days', NOW() - INTERVAL '30 days', NOW()),
|
||||
|
||||
-- ---- S/MIME cert ----
|
||||
('mc-smime-bob', 'bob-email-signing', 'bob@example.com', ARRAY['bob@example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-standard', 'Active', NOW() + INTERVAL '300 days', '{"type": "smime", "tier": "medium"}', NOW() - INTERVAL '65 days', NULL, NOW() - INTERVAL '65 days', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Certificate-Target Mappings
|
||||
-- Mark revoked cert
|
||||
UPDATE managed_certificates SET revoked_at = NOW() - INTERVAL '14 days', revocation_reason = 'keyCompromise' WHERE id = 'mc-compromised';
|
||||
|
||||
-- ============================================================
|
||||
-- 8. Certificate-Target Mappings
|
||||
-- ============================================================
|
||||
INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES
|
||||
('mc-api-prod', 'tgt-nginx-prod'),
|
||||
('mc-api-prod', 'tgt-f5-prod'),
|
||||
('mc-api-prod', 'tgt-haproxy-prod'),
|
||||
('mc-web-prod', 'tgt-nginx-prod'),
|
||||
('mc-web-prod', 'tgt-f5-prod'),
|
||||
('mc-web-prod', 'tgt-haproxy-prod'),
|
||||
('mc-pay-prod', 'tgt-nginx-prod'),
|
||||
('mc-pay-prod', 'tgt-f5-prod'),
|
||||
('mc-pay-prod', 'tgt-haproxy-prod'),
|
||||
('mc-dash-prod', 'tgt-nginx-prod'),
|
||||
('mc-data-prod', 'tgt-nginx-data'),
|
||||
('mc-auth-prod', 'tgt-nginx-prod'),
|
||||
('mc-auth-prod', 'tgt-f5-prod'),
|
||||
('mc-cdn-prod', 'tgt-f5-prod'),
|
||||
('mc-auth-prod', 'tgt-haproxy-prod'),
|
||||
('mc-cdn-prod', 'tgt-haproxy-prod'),
|
||||
('mc-mail-prod', 'tgt-nginx-prod'),
|
||||
('mc-legacy-prod', 'tgt-iis-prod'),
|
||||
('mc-blog-prod', 'tgt-nginx-prod'),
|
||||
('mc-docs-prod', 'tgt-nginx-prod'),
|
||||
('mc-status-prod', 'tgt-nginx-prod'),
|
||||
('mc-grpc-prod', 'tgt-nginx-prod'),
|
||||
('mc-vault-prod', 'tgt-nginx-prod'),
|
||||
('mc-search-prod', 'tgt-nginx-data'),
|
||||
('mc-admin-prod', 'tgt-nginx-prod'),
|
||||
('mc-shop-prod', 'tgt-nginx-prod'),
|
||||
('mc-shop-prod', 'tgt-haproxy-prod'),
|
||||
('mc-ci-prod', 'tgt-nginx-prod'),
|
||||
('mc-edge-eu', 'tgt-caddy-prod'),
|
||||
('mc-k8s-ingress', 'tgt-traefik-prod'),
|
||||
('mc-api-stg', 'tgt-nginx-staging'),
|
||||
('mc-web-stg', 'tgt-nginx-staging'),
|
||||
('mc-pay-stg', 'tgt-nginx-staging'),
|
||||
('mc-grafana-prod', 'tgt-nginx-data'),
|
||||
('mc-vpn-prod', 'tgt-f5-prod'),
|
||||
('mc-vpn-prod', 'tgt-haproxy-prod'),
|
||||
('mc-wildcard-prod', 'tgt-nginx-prod'),
|
||||
('mc-wildcard-prod', 'tgt-f5-prod'),
|
||||
('mc-wildcard-prod', 'tgt-nginx-staging')
|
||||
('mc-wildcard-prod', 'tgt-haproxy-prod'),
|
||||
('mc-wildcard-prod', 'tgt-nginx-staging'),
|
||||
('mc-compromised', 'tgt-nginx-prod')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Certificate Versions (latest version for each active cert)
|
||||
-- ============================================================
|
||||
-- 9. Certificate Versions (latest version for active/expiring certs)
|
||||
-- ============================================================
|
||||
INSERT INTO certificate_versions (id, certificate_id, serial_number, not_before, not_after, fingerprint_sha256, pem_chain, csr_pem, created_at) VALUES
|
||||
('cv-api-1', 'mc-api-prod', '0A:1B:2C:3D:4E:5F:00:01', NOW() - INTERVAL '15 days', NOW() + INTERVAL '75 days', 'sha256:ab12cd34ef56', '-----BEGIN CERTIFICATE-----\nMIIDemoAPI...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '15 days'),
|
||||
('cv-web-1', 'mc-web-prod', '0A:1B:2C:3D:4E:5F:00:02', NOW() - INTERVAL '30 days', NOW() + INTERVAL '60 days', 'sha256:cd34ef56ab12', '-----BEGIN CERTIFICATE-----\nMIIDemoWeb...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '30 days'),
|
||||
('cv-pay-1', 'mc-pay-prod', '0A:1B:2C:3D:4E:5F:00:03', NOW() - INTERVAL '45 days', NOW() + INTERVAL '45 days', 'sha256:ef56ab12cd34', '-----BEGIN CERTIFICATE-----\nMIIDemoPay...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '45 days'),
|
||||
('cv-auth-1', 'mc-auth-prod', '0A:1B:2C:3D:4E:5F:00:04', NOW() - INTERVAL '78 days', NOW() + INTERVAL '12 days', 'sha256:1234abcdef56', '-----BEGIN CERTIFICATE-----\nMIIDemoAuth...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '78 days'),
|
||||
('cv-wild-1', 'mc-wildcard-prod', '0A:1B:2C:3D:4E:5F:00:05', NOW() - INTERVAL '40 days', NOW() + INTERVAL '50 days', 'sha256:5678abcdef12', '-----BEGIN CERTIFICATE-----\nMIIDemoWild...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '40 days')
|
||||
('cv-api-v3', 'mc-api-prod', '0A:1B:2C:3D:4E:5F:00:01', NOW() - INTERVAL '15 days', NOW() + INTERVAL '75 days', 'sha256:ab12cd34ef5600', '-----BEGIN CERTIFICATE-----\nMIIDemoAPI...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '15 days'),
|
||||
('cv-api-v2', 'mc-api-prod', '0A:1B:2C:3D:4E:5F:AA:01', NOW() - INTERVAL '105 days', NOW() - INTERVAL '15 days', 'sha256:ab12cd34ef5601', '-----BEGIN CERTIFICATE-----\nMIIDemoAPIv2...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '105 days'),
|
||||
('cv-web-v2', 'mc-web-prod', '0A:1B:2C:3D:4E:5F:00:02', NOW() - INTERVAL '30 days', NOW() + INTERVAL '60 days', 'sha256:cd34ef56ab1200', '-----BEGIN CERTIFICATE-----\nMIIDemoWeb...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '30 days'),
|
||||
('cv-pay-v4', 'mc-pay-prod', '0A:1B:2C:3D:4E:5F:00:03', NOW() - INTERVAL '50 days', NOW() + INTERVAL '40 days', 'sha256:ef56ab12cd3400', '-----BEGIN CERTIFICATE-----\nMIIDemoPay...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '50 days'),
|
||||
('cv-auth-v5', 'mc-auth-prod', '0A:1B:2C:3D:4E:5F:00:04', NOW() - INTERVAL '78 days', NOW() + INTERVAL '12 days', 'sha256:1234abcdef5600', '-----BEGIN CERTIFICATE-----\nMIIDemoAuth...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '78 days'),
|
||||
('cv-wild-v3', 'mc-wildcard-prod', '0A:1B:2C:3D:4E:5F:00:05', NOW() - INTERVAL '40 days', NOW() + INTERVAL '50 days', 'sha256:5678abcdef1200', '-----BEGIN CERTIFICATE-----\nMIIDemoWild...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '40 days'),
|
||||
('cv-dash-v2', 'mc-dash-prod', '0A:1B:2C:3D:4E:5F:00:06', NOW() - INTERVAL '8 days', NOW() + INTERVAL '82 days', 'sha256:dash12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoDash...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '8 days'),
|
||||
('cv-data-v3', 'mc-data-prod', '0A:1B:2C:3D:4E:5F:00:07', NOW() - INTERVAL '35 days', NOW() + INTERVAL '55 days', 'sha256:data12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoData...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '35 days'),
|
||||
('cv-blog-v2', 'mc-blog-prod', '0A:1B:2C:3D:4E:5F:00:08', NOW() - INTERVAL '38 days', NOW() + INTERVAL '52 days', 'sha256:blog12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoBlog...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '38 days'),
|
||||
('cv-grpc-v2', 'mc-grpc-prod', '0A:1B:2C:3D:4E:5F:00:09', NOW() - INTERVAL '32 days', NOW() + INTERVAL '58 days', 'sha256:grpc12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoGRPC...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '32 days'),
|
||||
('cv-shop-v1', 'mc-shop-prod', '0A:1B:2C:3D:4E:5F:00:10', NOW() - INTERVAL '46 days', NOW() + INTERVAL '44 days', 'sha256:shop12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoShop...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '46 days'),
|
||||
('cv-edge-v1', 'mc-edge-eu', '0A:1B:2C:3D:4E:5F:00:11', NOW() - INTERVAL '29 days', NOW() + INTERVAL '61 days', 'sha256:edge12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoEdge...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '29 days'),
|
||||
('cv-k8s-v1', 'mc-k8s-ingress', '0A:1B:2C:3D:4E:5F:00:12', NOW() - INTERVAL '34 days', NOW() + INTERVAL '56 days', 'sha256:k8si12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoK8s...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '34 days'),
|
||||
('cv-vpn-v2', 'mc-vpn-prod', '0A:1B:2C:3D:4E:5F:00:13', NOW() - INTERVAL '90 days', NOW() + INTERVAL '1 day', 'sha256:vpn012345600', '-----BEGIN CERTIFICATE-----\nMIIDemoVPN...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '90 days'),
|
||||
('cv-compro-v1', 'mc-compromised', '0A:1B:2C:3D:4E:5F:00:14', NOW() - INTERVAL '60 days', NOW() + INTERVAL '30 days', 'sha256:comp12345600', '-----BEGIN CERTIFICATE-----\nMIIDemoComp...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '60 days'),
|
||||
('cv-smime-v1', 'mc-smime-bob', '0A:1B:2C:3D:4E:5F:00:15', NOW() - INTERVAL '65 days', NOW() + INTERVAL '300 days', 'sha256:smime1234560', '-----BEGIN CERTIFICATE-----\nMIIDemoSMIME...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '65 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Recent Audit Events
|
||||
-- ============================================================
|
||||
-- 10. Certificate Revocations
|
||||
-- ============================================================
|
||||
INSERT INTO certificate_revocations (id, certificate_id, serial_number, reason, revoked_by, revoked_at, issuer_id, issuer_notified, created_at) VALUES
|
||||
('cr-compro-01', 'mc-compromised', '0A:1B:2C:3D:4E:5F:00:14', 'keyCompromise', 'bob@example.com', NOW() - INTERVAL '14 days', 'iss-local', true, NOW() - INTERVAL '14 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 11. Jobs — 90 days of realistic job history
|
||||
-- Simulates weekly renewal cycles, deployment chains, and some failures
|
||||
-- ============================================================
|
||||
INSERT INTO jobs (id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts, last_error, scheduled_at, started_at, completed_at, created_at, verification_status) VALUES
|
||||
-- ---- Week 1 (90 days ago): Initial issuances ----
|
||||
('job-iss-001', 'issuance', 'mc-api-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '90 days', NOW() - INTERVAL '90 days', NOW() - INTERVAL '90 days' + INTERVAL '10 seconds', NOW() - INTERVAL '90 days', 'success'),
|
||||
('job-dep-001', 'deployment', 'mc-api-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '90 days', NOW() - INTERVAL '90 days' + INTERVAL '15 seconds', NOW() - INTERVAL '90 days' + INTERVAL '25 seconds', NOW() - INTERVAL '90 days', 'success'),
|
||||
|
||||
-- ---- Week 3 (77 days ago): Renewal cycle ----
|
||||
('job-ren-010', 'renewal', 'mc-web-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '77 days', NOW() - INTERVAL '77 days', NOW() - INTERVAL '77 days' + INTERVAL '12 seconds', NOW() - INTERVAL '77 days', 'success'),
|
||||
('job-dep-010', 'deployment', 'mc-web-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '77 days', NOW() - INTERVAL '77 days' + INTERVAL '15 seconds', NOW() - INTERVAL '77 days' + INTERVAL '22 seconds', NOW() - INTERVAL '77 days', 'success'),
|
||||
('job-dep-011', 'deployment', 'mc-web-prod', 'tgt-haproxy-prod', 'ag-lb-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '77 days', NOW() - INTERVAL '77 days' + INTERVAL '15 seconds', NOW() - INTERVAL '77 days' + INTERVAL '24 seconds', NOW() - INTERVAL '77 days', 'success'),
|
||||
|
||||
-- ---- Week 5 (63 days ago): step-ca renewals ----
|
||||
('job-ren-020', 'renewal', 'mc-grpc-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '63 days', NOW() - INTERVAL '63 days', NOW() - INTERVAL '63 days' + INTERVAL '8 seconds', NOW() - INTERVAL '63 days', 'success'),
|
||||
('job-dep-020', 'deployment', 'mc-grpc-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '63 days', NOW() - INTERVAL '63 days' + INTERVAL '10 seconds', NOW() - INTERVAL '63 days' + INTERVAL '18 seconds', NOW() - INTERVAL '63 days', 'success'),
|
||||
|
||||
-- ---- Week 6 (56 days ago): Failed renewal attempt ----
|
||||
('job-ren-030', 'renewal', 'mc-vpn-prod', NULL, 'ag-lb-prod', 'Failed', 3, 3, 'ACME challenge failed: DNS timeout after 30s', NOW() - INTERVAL '56 days', NOW() - INTERVAL '56 days', NOW() - INTERVAL '56 days' + INTERVAL '35 seconds', NOW() - INTERVAL '56 days', NULL),
|
||||
|
||||
-- ---- Week 7 (50 days ago): Payments renewal ----
|
||||
('job-ren-040', 'renewal', 'mc-pay-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '50 days', NOW() - INTERVAL '50 days', NOW() - INTERVAL '50 days' + INTERVAL '11 seconds', NOW() - INTERVAL '50 days', 'success'),
|
||||
('job-dep-040', 'deployment', 'mc-pay-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '50 days', NOW() - INTERVAL '50 days' + INTERVAL '14 seconds', NOW() - INTERVAL '50 days' + INTERVAL '22 seconds', NOW() - INTERVAL '50 days', 'success'),
|
||||
('job-dep-041', 'deployment', 'mc-pay-prod', 'tgt-haproxy-prod', 'ag-lb-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '50 days', NOW() - INTERVAL '50 days' + INTERVAL '14 seconds', NOW() - INTERVAL '50 days' + INTERVAL '25 seconds', NOW() - INTERVAL '50 days', 'success'),
|
||||
|
||||
-- ---- Week 8 (46 days ago): ZeroSSL issuance ----
|
||||
('job-iss-050', 'issuance', 'mc-shop-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '46 days', NOW() - INTERVAL '46 days', NOW() - INTERVAL '46 days' + INTERVAL '18 seconds', NOW() - INTERVAL '46 days', 'success'),
|
||||
('job-dep-050', 'deployment', 'mc-shop-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '46 days', NOW() - INTERVAL '46 days' + INTERVAL '20 seconds', NOW() - INTERVAL '46 days' + INTERVAL '28 seconds', NOW() - INTERVAL '46 days', 'success'),
|
||||
|
||||
-- ---- Week 9 (43 days ago): Docs renewal (ACME) ----
|
||||
('job-ren-060', 'renewal', 'mc-docs-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '43 days', NOW() - INTERVAL '43 days', NOW() - INTERVAL '43 days' + INTERVAL '15 seconds', NOW() - INTERVAL '43 days', 'success'),
|
||||
('job-dep-060', 'deployment', 'mc-docs-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '43 days', NOW() - INTERVAL '43 days' + INTERVAL '18 seconds', NOW() - INTERVAL '43 days' + INTERVAL '26 seconds', NOW() - INTERVAL '43 days', 'success'),
|
||||
|
||||
-- ---- Week 10 (40 days ago): Wildcard renewal (DNS-01) ----
|
||||
('job-ren-070', 'renewal', 'mc-wildcard-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '40 days', NOW() - INTERVAL '40 days', NOW() - INTERVAL '40 days' + INTERVAL '45 seconds', NOW() - INTERVAL '40 days', 'success'),
|
||||
('job-dep-070', 'deployment', 'mc-wildcard-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '40 days', NOW() - INTERVAL '40 days' + INTERVAL '48 seconds', NOW() - INTERVAL '40 days' + INTERVAL '55 seconds', NOW() - INTERVAL '40 days', 'success'),
|
||||
|
||||
-- ---- Week 11 (38 days ago): Blog renewal ----
|
||||
('job-ren-075', 'renewal', 'mc-blog-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '38 days', NOW() - INTERVAL '38 days', NOW() - INTERVAL '38 days' + INTERVAL '14 seconds', NOW() - INTERVAL '38 days', 'success'),
|
||||
('job-dep-075', 'deployment', 'mc-blog-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '38 days', NOW() - INTERVAL '38 days' + INTERVAL '16 seconds', NOW() - INTERVAL '38 days' + INTERVAL '24 seconds', NOW() - INTERVAL '38 days', 'success'),
|
||||
|
||||
-- ---- Week 11 (35 days ago): Data API renewal ----
|
||||
('job-ren-080', 'renewal', 'mc-data-prod', NULL, 'ag-data-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '35 days', NOW() - INTERVAL '35 days', NOW() - INTERVAL '35 days' + INTERVAL '9 seconds', NOW() - INTERVAL '35 days', 'success'),
|
||||
('job-dep-080', 'deployment', 'mc-data-prod', 'tgt-nginx-data', 'ag-data-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '35 days', NOW() - INTERVAL '35 days' + INTERVAL '12 seconds', NOW() - INTERVAL '35 days' + INTERVAL '19 seconds', NOW() - INTERVAL '35 days', 'success'),
|
||||
|
||||
-- ---- Week 12 (34 days ago): K8s ingress issuance ----
|
||||
('job-iss-085', 'issuance', 'mc-k8s-ingress', NULL, 'ag-k8s-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '34 days', NOW() - INTERVAL '34 days', NOW() - INTERVAL '34 days' + INTERVAL '16 seconds', NOW() - INTERVAL '34 days', 'success'),
|
||||
('job-dep-085', 'deployment', 'mc-k8s-ingress', 'tgt-traefik-prod','ag-k8s-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '34 days', NOW() - INTERVAL '34 days' + INTERVAL '18 seconds', NOW() - INTERVAL '34 days' + INTERVAL '24 seconds', NOW() - INTERVAL '34 days', 'success'),
|
||||
|
||||
-- ---- Week 12 (30 days ago): Web prod renewal ----
|
||||
('job-ren-090', 'renewal', 'mc-web-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days' + INTERVAL '11 seconds', NOW() - INTERVAL '30 days', 'success'),
|
||||
('job-dep-090', 'deployment', 'mc-web-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days' + INTERVAL '14 seconds', NOW() - INTERVAL '30 days' + INTERVAL '21 seconds', NOW() - INTERVAL '30 days', 'success'),
|
||||
('job-dep-091', 'deployment', 'mc-web-prod', 'tgt-haproxy-prod', 'ag-lb-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days' + INTERVAL '14 seconds', NOW() - INTERVAL '30 days' + INTERVAL '23 seconds', NOW() - INTERVAL '30 days', 'success'),
|
||||
|
||||
-- ---- Week 13 (29 days ago): Edge EU issuance ----
|
||||
('job-iss-093', 'issuance', 'mc-edge-eu', NULL, 'ag-edge-01', 'Completed', 1, 3, NULL, NOW() - INTERVAL '29 days', NOW() - INTERVAL '29 days', NOW() - INTERVAL '29 days' + INTERVAL '13 seconds', NOW() - INTERVAL '29 days', 'success'),
|
||||
('job-dep-093', 'deployment', 'mc-edge-eu', 'tgt-caddy-prod', 'ag-edge-01', 'Completed', 1, 3, NULL, NOW() - INTERVAL '29 days', NOW() - INTERVAL '29 days' + INTERVAL '15 seconds', NOW() - INTERVAL '29 days' + INTERVAL '20 seconds', NOW() - INTERVAL '29 days', 'success'),
|
||||
|
||||
-- ---- Week 13 (27 days ago): Consul renewal ----
|
||||
('job-ren-095', 'renewal', 'mc-consul-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '27 days', NOW() - INTERVAL '27 days', NOW() - INTERVAL '27 days' + INTERVAL '9 seconds', NOW() - INTERVAL '27 days', 'success'),
|
||||
|
||||
-- ---- Week 14 (22 days ago): Search renewal ----
|
||||
('job-ren-100', 'renewal', 'mc-search-prod', NULL, 'ag-data-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '22 days', NOW() - INTERVAL '22 days', NOW() - INTERVAL '22 days' + INTERVAL '10 seconds', NOW() - INTERVAL '22 days', 'success'),
|
||||
('job-dep-100', 'deployment', 'mc-search-prod', 'tgt-nginx-data', 'ag-data-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '22 days', NOW() - INTERVAL '22 days' + INTERVAL '13 seconds', NOW() - INTERVAL '22 days' + INTERVAL '20 seconds', NOW() - INTERVAL '22 days', 'success'),
|
||||
|
||||
-- ---- Week 14 (19 days ago): Status page renewal ----
|
||||
('job-ren-105', 'renewal', 'mc-status-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '19 days', NOW() - INTERVAL '19 days', NOW() - INTERVAL '19 days' + INTERVAL '12 seconds', NOW() - INTERVAL '19 days', 'success'),
|
||||
('job-dep-105', 'deployment', 'mc-status-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '19 days', NOW() - INTERVAL '19 days' + INTERVAL '15 seconds', NOW() - INTERVAL '19 days' + INTERVAL '22 seconds', NOW() - INTERVAL '19 days', 'success'),
|
||||
|
||||
-- ---- Week 15 (15 days ago): API prod renewal ----
|
||||
('job-ren-110', 'renewal', 'mc-api-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days' + INTERVAL '10 seconds', NOW() - INTERVAL '15 days', 'success'),
|
||||
('job-dep-110', 'deployment', 'mc-api-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days' + INTERVAL '13 seconds', NOW() - INTERVAL '15 days' + INTERVAL '20 seconds', NOW() - INTERVAL '15 days', 'success'),
|
||||
('job-dep-111', 'deployment', 'mc-api-prod', 'tgt-haproxy-prod', 'ag-lb-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days' + INTERVAL '13 seconds', NOW() - INTERVAL '15 days' + INTERVAL '22 seconds', NOW() - INTERVAL '15 days', 'success'),
|
||||
|
||||
-- ---- Revocation job (14 days ago) ----
|
||||
('job-rev-120', 'validation', 'mc-compromised', NULL, 'ag-web-prod', 'Completed', 1, 1, NULL, NOW() - INTERVAL '14 days', NOW() - INTERVAL '14 days', NOW() - INTERVAL '14 days' + INTERVAL '2 seconds', NOW() - INTERVAL '14 days', NULL),
|
||||
|
||||
-- ---- Week 16 (8 days ago): Dashboard renewal ----
|
||||
('job-ren-130', 'renewal', 'mc-dash-prod', NULL, 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '8 days', NOW() - INTERVAL '8 days', NOW() - INTERVAL '8 days' + INTERVAL '9 seconds', NOW() - INTERVAL '8 days', 'success'),
|
||||
('job-dep-130', 'deployment', 'mc-dash-prod', 'tgt-nginx-prod', 'ag-web-prod', 'Completed', 1, 3, NULL, NOW() - INTERVAL '8 days', NOW() - INTERVAL '8 days' + INTERVAL '11 seconds', NOW() - INTERVAL '8 days' + INTERVAL '18 seconds', NOW() - INTERVAL '8 days', 'success'),
|
||||
|
||||
-- ---- Failed VPN renewal retries (recent) ----
|
||||
('job-ren-140', 'renewal', 'mc-vpn-prod', NULL, 'ag-lb-prod', 'Failed', 3, 3, 'ACME HTTP-01 challenge: connection refused on port 80', NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days' + INTERVAL '32 seconds', NOW() - INTERVAL '3 days', NULL),
|
||||
|
||||
-- ---- Grafana renewal in progress ----
|
||||
('job-ren-150', 'renewal', 'mc-grafana-prod', NULL, 'ag-data-prod', 'Running', 1, 3, NULL, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours', NULL, NOW() - INTERVAL '2 hours', NULL),
|
||||
|
||||
-- ---- Awaiting approval ----
|
||||
('job-approval-01', 'renewal', 'mc-auth-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour', NULL, NOW() - INTERVAL '1 hour', NULL),
|
||||
('job-approval-02', 'renewal', 'mc-pay-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes', NULL, NOW() - INTERVAL '30 minutes', NULL),
|
||||
|
||||
-- ---- Development API issuance (5 days ago) ----
|
||||
('job-iss-160', 'issuance', 'mc-api-dev', NULL, 'ag-mac-dev', 'Completed', 1, 3, NULL, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days' + INTERVAL '6 seconds', NOW() - INTERVAL '5 days', 'skipped')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 12. Audit Events — 90 days of activity
|
||||
-- ============================================================
|
||||
INSERT INTO audit_events (id, actor, actor_type, action, resource_type, resource_id, details, timestamp) VALUES
|
||||
('audit-demo-01', 'alice@example.com', 'user', 'certificate.renewed', 'certificate', 'mc-api-prod', '{"issuer": "local", "serial": "0A:1B:2C:3D:4E:5F:00:01"}', NOW() - INTERVAL '15 days'),
|
||||
('audit-demo-02', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-api-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '15 days' + INTERVAL '5 minutes'),
|
||||
('audit-demo-03', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-api-prod', '{"target": "tgt-f5-prod", "status": "success"}', NOW() - INTERVAL '15 days' + INTERVAL '8 minutes'),
|
||||
('audit-demo-04', 'dave@example.com', 'user', 'certificate.renewed', 'certificate', 'mc-web-prod', '{"issuer": "local", "serial": "0A:1B:2C:3D:4E:5F:00:02"}', NOW() - INTERVAL '30 days'),
|
||||
('audit-demo-05', 'carol@example.com', 'user', 'certificate.created', 'certificate', 'mc-pay-prod', '{"common_name": "pay.example.com"}', NOW() - INTERVAL '200 days'),
|
||||
('audit-demo-06', 'system', 'system', 'renewal.started', 'certificate', 'mc-grafana-prod', '{"reason": "expiring_in_3_days"}', NOW() - INTERVAL '2 hours'),
|
||||
('audit-demo-07', 'system', 'system', 'renewal.failed', 'certificate', 'mc-vpn-prod', '{"error": "ACME challenge failed: DNS timeout", "attempt": 3}', NOW() - INTERVAL '1 hour'),
|
||||
('audit-demo-08', 'system', 'system', 'expiration.warning', 'certificate', 'mc-auth-prod', '{"days_until_expiry": 12}', NOW() - INTERVAL '30 minutes'),
|
||||
('audit-demo-09', 'system', 'system', 'expiration.warning', 'certificate', 'mc-cdn-prod', '{"days_until_expiry": 8}', NOW() - INTERVAL '25 minutes'),
|
||||
('audit-demo-10', 'system', 'system', 'expiration.warning', 'certificate', 'mc-mail-prod', '{"days_until_expiry": 5}', NOW() - INTERVAL '20 minutes'),
|
||||
('audit-demo-11', 'bob@example.com', 'user', 'agent.registered', 'agent', 'ag-iis-prod', '{"hostname": "iis-prod-01.internal"}', NOW() - INTERVAL '30 days'),
|
||||
('audit-demo-12', 'system', 'system', 'agent.offline', 'agent', 'ag-iis-prod', '{"last_heartbeat": "3 hours ago"}', NOW() - INTERVAL '3 hours'),
|
||||
('audit-demo-13', 'alice@example.com', 'user', 'policy.violation', 'certificate', 'mc-legacy-prod', '{"rule": "max-certificate-lifetime", "message": "Certificate expired"}', NOW() - INTERVAL '3 days'),
|
||||
('audit-demo-14', 'bob@example.com', 'user', 'issuer.configured', 'issuer', 'iss-local', '{"type": "local", "ca_common_name": "CertCtl Demo CA"}', NOW() - INTERVAL '90 days'),
|
||||
('audit-demo-15', 'alice@example.com', 'user', 'target.configured', 'target', 'tgt-nginx-prod', '{"type": "nginx", "agent": "ag-web-prod"}', NOW() - INTERVAL '90 days')
|
||||
-- System bootstrap (90 days ago)
|
||||
('audit-001', 'alice@example.com', 'user', 'issuer.configured', 'issuer', 'iss-local', '{"type": "local", "ca_common_name": "CertCtl Demo CA"}', NOW() - INTERVAL '180 days'),
|
||||
('audit-002', 'alice@example.com', 'user', 'issuer.configured', 'issuer', 'iss-acme-le', '{"type": "acme", "directory": "letsencrypt-staging"}', NOW() - INTERVAL '150 days'),
|
||||
('audit-003', 'bob@example.com', 'user', 'issuer.configured', 'issuer', 'iss-stepca', '{"type": "stepca", "ca_url": "ca.internal:9000"}', NOW() - INTERVAL '120 days'),
|
||||
('audit-004', 'alice@example.com', 'user', 'target.configured', 'target', 'tgt-nginx-prod', '{"type": "nginx", "agent": "ag-web-prod"}', NOW() - INTERVAL '120 days'),
|
||||
('audit-005', 'system', 'system', 'agent.registered', 'agent', 'ag-web-prod', '{"hostname": "web-prod-01.internal", "os": "linux"}', NOW() - INTERVAL '120 days'),
|
||||
('audit-006', 'system', 'system', 'agent.registered', 'agent', 'ag-lb-prod', '{"hostname": "lb-prod-01.internal", "os": "linux"}', NOW() - INTERVAL '150 days'),
|
||||
|
||||
-- Issuances (90-60 days ago)
|
||||
('audit-010', 'system', 'system', 'certificate.issued', 'certificate', 'mc-api-prod', '{"issuer": "iss-local", "serial": "0A:1B:2C:3D:4E:5F:00:01"}', NOW() - INTERVAL '90 days'),
|
||||
('audit-011', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-api-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '90 days' + INTERVAL '25 seconds'),
|
||||
('audit-012', 'system', 'system', 'certificate.issued', 'certificate', 'mc-pay-prod', '{"issuer": "iss-local", "serial": "0A:1B:2C:3D:4E:5F:00:03"}', NOW() - INTERVAL '85 days'),
|
||||
('audit-013', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-pay-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '85 days' + INTERVAL '22 seconds'),
|
||||
('audit-014', 'system', 'system', 'certificate.issued', 'certificate', 'mc-web-prod', '{"issuer": "iss-local", "serial": "0A:1B:2C:3D:4E:5F:00:02"}', NOW() - INTERVAL '77 days'),
|
||||
('audit-015', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-web-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '77 days' + INTERVAL '22 seconds'),
|
||||
('audit-016', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-web-prod', '{"target": "tgt-haproxy-prod", "status": "success"}', NOW() - INTERVAL '77 days' + INTERVAL '24 seconds'),
|
||||
|
||||
-- step-ca renewals
|
||||
('audit-020', 'system', 'system', 'certificate.renewed', 'certificate', 'mc-grpc-prod', '{"issuer": "iss-stepca", "serial": "0A:1B:2C:3D:4E:5F:00:09"}', NOW() - INTERVAL '63 days'),
|
||||
('audit-021', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-grpc-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '63 days' + INTERVAL '18 seconds'),
|
||||
|
||||
-- Failed VPN renewal
|
||||
('audit-025', 'system', 'system', 'renewal.failed', 'certificate', 'mc-vpn-prod', '{"error": "ACME challenge failed: DNS timeout", "attempt": 3}', NOW() - INTERVAL '56 days'),
|
||||
|
||||
-- Payments renewal
|
||||
('audit-030', 'system', 'system', 'certificate.renewed', 'certificate', 'mc-pay-prod', '{"issuer": "iss-local", "serial": "0A:1B:2C:3D:4E:5F:00:03"}', NOW() - INTERVAL '50 days'),
|
||||
('audit-031', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-pay-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '50 days' + INTERVAL '22 seconds'),
|
||||
('audit-032', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-pay-prod', '{"target": "tgt-haproxy-prod", "status": "success"}', NOW() - INTERVAL '50 days' + INTERVAL '25 seconds'),
|
||||
|
||||
-- ZeroSSL issuance
|
||||
('audit-035', 'carol@example.com', 'user', 'certificate.created', 'certificate', 'mc-shop-prod', '{"common_name": "shop.example.com", "issuer": "iss-acme-zs"}', NOW() - INTERVAL '46 days'),
|
||||
('audit-036', 'system', 'system', 'certificate.issued', 'certificate', 'mc-shop-prod', '{"issuer": "iss-acme-zs", "serial": "0A:1B:2C:3D:4E:5F:00:10"}', NOW() - INTERVAL '46 days'),
|
||||
('audit-037', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-shop-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '46 days' + INTERVAL '28 seconds'),
|
||||
|
||||
-- Wildcard renewal
|
||||
('audit-040', 'system', 'system', 'certificate.renewed', 'certificate', 'mc-wildcard-prod', '{"issuer": "iss-acme-le", "challenge": "dns-01"}', NOW() - INTERVAL '40 days'),
|
||||
('audit-041', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-wildcard-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '40 days' + INTERVAL '55 seconds'),
|
||||
|
||||
-- K8s ingress + Traefik
|
||||
('audit-045', 'frank@example.com', 'user', 'certificate.created', 'certificate', 'mc-k8s-ingress', '{"common_name": "ingress.example.com"}', NOW() - INTERVAL '34 days'),
|
||||
('audit-046', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-k8s-ingress', '{"target": "tgt-traefik-prod", "status": "success"}', NOW() - INTERVAL '34 days' + INTERVAL '24 seconds'),
|
||||
|
||||
-- Edge EU + Caddy
|
||||
('audit-048', 'alice@example.com', 'user', 'certificate.created', 'certificate', 'mc-edge-eu', '{"common_name": "eu.cdn.example.com"}', NOW() - INTERVAL '29 days'),
|
||||
('audit-049', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-edge-eu', '{"target": "tgt-caddy-prod", "status": "success"}', NOW() - INTERVAL '29 days' + INTERVAL '20 seconds'),
|
||||
|
||||
-- API prod renewal (15 days ago)
|
||||
('audit-050', 'system', 'system', 'certificate.renewed', 'certificate', 'mc-api-prod', '{"issuer": "iss-local", "serial": "0A:1B:2C:3D:4E:5F:00:01"}', NOW() - INTERVAL '15 days'),
|
||||
('audit-051', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-api-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '15 days' + INTERVAL '20 seconds'),
|
||||
('audit-052', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-api-prod', '{"target": "tgt-haproxy-prod", "status": "success"}', NOW() - INTERVAL '15 days' + INTERVAL '22 seconds'),
|
||||
|
||||
-- Revocation (14 days ago)
|
||||
('audit-055', 'bob@example.com', 'user', 'certificate.revoked', 'certificate', 'mc-compromised', '{"reason": "keyCompromise", "serial": "0A:1B:2C:3D:4E:5F:00:14"}', NOW() - INTERVAL '14 days'),
|
||||
|
||||
-- Dashboard renewal (8 days ago)
|
||||
('audit-060', 'system', 'system', 'certificate.renewed', 'certificate', 'mc-dash-prod', '{"issuer": "iss-local"}', NOW() - INTERVAL '8 days'),
|
||||
('audit-061', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-dash-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '8 days' + INTERVAL '18 seconds'),
|
||||
|
||||
-- Expiration warnings (recent)
|
||||
('audit-070', 'system', 'system', 'expiration.warning', 'certificate', 'mc-auth-prod', '{"days_until_expiry": 12}', NOW() - INTERVAL '30 minutes'),
|
||||
('audit-071', 'system', 'system', 'expiration.warning', 'certificate', 'mc-cdn-prod', '{"days_until_expiry": 8}', NOW() - INTERVAL '25 minutes'),
|
||||
('audit-072', 'system', 'system', 'expiration.warning', 'certificate', 'mc-mail-prod', '{"days_until_expiry": 5}', NOW() - INTERVAL '20 minutes'),
|
||||
('audit-073', 'system', 'system', 'expiration.warning', 'certificate', 'mc-ci-prod', '{"days_until_expiry": 18}', NOW() - INTERVAL '15 minutes'),
|
||||
|
||||
-- Recent failed VPN retry
|
||||
('audit-075', 'system', 'system', 'renewal.failed', 'certificate', 'mc-vpn-prod', '{"error": "ACME HTTP-01 challenge: connection refused", "attempt": 3}', NOW() - INTERVAL '3 days'),
|
||||
|
||||
-- Grafana renewal started
|
||||
('audit-080', 'system', 'system', 'renewal.started', 'certificate', 'mc-grafana-prod', '{"reason": "expiring_in_3_days"}', NOW() - INTERVAL '2 hours'),
|
||||
|
||||
-- Agent events
|
||||
('audit-085', 'system', 'system', 'agent.registered', 'agent', 'ag-edge-01', '{"hostname": "edge-eu-01.internal", "os": "linux"}', NOW() - INTERVAL '45 days'),
|
||||
('audit-086', 'system', 'system', 'agent.registered', 'agent', 'ag-k8s-prod', '{"hostname": "k8s-node-01.internal", "os": "linux"}', NOW() - INTERVAL '30 days'),
|
||||
('audit-087', 'system', 'system', 'agent.registered', 'agent', 'ag-mac-dev', '{"hostname": "dev-mac-01.internal", "os": "darwin"}', NOW() - INTERVAL '15 days'),
|
||||
('audit-088', 'bob@example.com', 'user', 'agent.registered', 'agent', 'ag-iis-prod', '{"hostname": "iis-prod-01.internal", "os": "windows"}', NOW() - INTERVAL '60 days'),
|
||||
('audit-089', 'system', 'system', 'agent.offline', 'agent', 'ag-iis-prod', '{"last_heartbeat": "3 hours ago"}', NOW() - INTERVAL '3 hours'),
|
||||
|
||||
-- Discovery events
|
||||
('audit-090', 'system', 'system', 'discovery_scan_completed', 'agent', 'ag-web-prod', '{"certs_found": 4, "certs_new": 2, "dirs": ["/etc/nginx/ssl"]}', NOW() - INTERVAL '3 hours'),
|
||||
('audit-091', 'system', 'system', 'discovery_scan_completed', 'agent', 'ag-data-prod', '{"certs_found": 3, "certs_new": 1, "dirs": ["/etc/nginx/ssl"]}', NOW() - INTERVAL '2 hours'),
|
||||
('audit-092', 'system', 'system', 'discovery_scan_completed', 'agent', 'server-scanner', '{"certs_found": 5, "certs_new": 5, "scan_type": "network"}', NOW() - INTERVAL '1 hour'),
|
||||
|
||||
-- Policy violations
|
||||
('audit-095', 'alice@example.com', 'user', 'policy.violation', 'certificate', 'mc-legacy-prod', '{"rule": "max-certificate-lifetime", "message": "Certificate expired"}', NOW() - INTERVAL '3 days'),
|
||||
('audit-096', 'system', 'system', 'policy.violation', 'certificate', 'mc-old-api', '{"rule": "max-certificate-lifetime", "message": "Certificate expired 15 days ago"}', NOW() - INTERVAL '15 days'),
|
||||
|
||||
-- API audit middleware events (sampled — these accumulate fast)
|
||||
('audit-100', 'alice@example.com', 'user', 'api.call', 'api', 'GET /api/v1/certificates', '{"status": 200, "latency_ms": 12}', NOW() - INTERVAL '2 hours'),
|
||||
('audit-101', 'bob@example.com', 'user', 'api.call', 'api', 'GET /api/v1/agents', '{"status": 200, "latency_ms": 8}', NOW() - INTERVAL '1 hour'),
|
||||
('audit-102', 'anonymous', 'system', 'api.call', 'api', 'GET /api/v1/auth/info', '{"status": 200, "latency_ms": 1}', NOW() - INTERVAL '30 minutes')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Policy Violations (reference policy rules by their IDs from seed.sql)
|
||||
-- ============================================================
|
||||
-- 13. Policy Violations
|
||||
-- ============================================================
|
||||
INSERT INTO policy_violations (id, certificate_id, rule_id, message, severity, created_at) VALUES
|
||||
('pv-demo-01', 'mc-legacy-prod', 'pr-max-certificate-lifetime', 'Certificate has expired and exceeds maximum lifetime policy', 'critical', NOW() - INTERVAL '3 days'),
|
||||
('pv-demo-02', 'mc-old-api', 'pr-max-certificate-lifetime', 'Certificate expired 15 days ago', 'critical', NOW() - INTERVAL '15 days'),
|
||||
('pv-demo-03', 'mc-vpn-prod', 'pr-min-renewal-window', 'Renewal failed within minimum renewal window', 'error', NOW() - INTERVAL '1 hour'),
|
||||
('pv-demo-04', 'mc-mail-prod', 'pr-min-renewal-window', 'Certificate expiring in 5 days, below 14-day minimum window','warning', NOW() - INTERVAL '20 minutes')
|
||||
('pv-001', 'mc-legacy-prod', 'pr-max-certificate-lifetime', 'Certificate has expired and exceeds maximum lifetime policy', 'critical', NOW() - INTERVAL '3 days'),
|
||||
('pv-002', 'mc-old-api', 'pr-max-certificate-lifetime', 'Certificate expired 15 days ago', 'critical', NOW() - INTERVAL '15 days'),
|
||||
('pv-003', 'mc-vpn-prod', 'pr-min-renewal-window', 'Renewal failed within minimum renewal window', 'error', NOW() - INTERVAL '3 days'),
|
||||
('pv-004', 'mc-mail-prod', 'pr-min-renewal-window', 'Certificate expiring in 5 days, below 14-day minimum window','warning', NOW() - INTERVAL '20 minutes'),
|
||||
('pv-005', 'mc-wiki-prod', 'pr-max-certificate-lifetime', 'Certificate expired 7 days ago', 'critical', NOW() - INTERVAL '7 days'),
|
||||
('pv-006', 'mc-compromised', 'pr-min-renewal-window', 'Certificate revoked due to key compromise', 'critical', NOW() - INTERVAL '14 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Notification Events
|
||||
-- ============================================================
|
||||
-- 14. Notification Events
|
||||
-- ============================================================
|
||||
INSERT INTO notification_events (id, type, certificate_id, channel, recipient, message, sent_at, status, error) VALUES
|
||||
('ne-demo-01', 'expiration_warning', 'mc-auth-prod', 'email', 'bob@example.com', 'Certificate auth-production expires in 12 days', NOW() - INTERVAL '30 minutes', 'sent', NULL),
|
||||
('ne-demo-02', 'expiration_warning', 'mc-cdn-prod', 'email', 'alice@example.com', 'Certificate cdn-production expires in 8 days', NOW() - INTERVAL '25 minutes', 'sent', NULL),
|
||||
('ne-demo-03', 'expiration_warning', 'mc-mail-prod', 'email', 'bob@example.com', 'Certificate mail-production expires in 5 days', NOW() - INTERVAL '20 minutes', 'sent', NULL),
|
||||
('ne-demo-04', 'renewal_failure', 'mc-vpn-prod', 'webhook', 'https://hooks.example.com/certctl', 'Renewal failed for vpn-production after 3 attempts', NOW() - INTERVAL '1 hour', 'sent', NULL),
|
||||
('ne-demo-05', 'renewal_success', 'mc-api-prod', 'email', 'alice@example.com', 'Certificate api-production renewed successfully', NOW() - INTERVAL '15 days', 'sent', NULL),
|
||||
('ne-demo-06', 'deployment_success', 'mc-api-prod', 'webhook', 'https://hooks.example.com/certctl', 'Certificate api-production deployed to NGINX Production', NOW() - INTERVAL '15 days', 'sent', NULL)
|
||||
-- Expiration warnings
|
||||
('ne-001', 'expiration_warning', 'mc-auth-prod', 'email', 'bob@example.com', 'Certificate auth-production expires in 12 days', NOW() - INTERVAL '30 minutes', 'sent', NULL),
|
||||
('ne-002', 'expiration_warning', 'mc-cdn-prod', 'email', 'alice@example.com', 'Certificate cdn-production expires in 8 days', NOW() - INTERVAL '25 minutes', 'sent', NULL),
|
||||
('ne-003', 'expiration_warning', 'mc-mail-prod', 'email', 'bob@example.com', 'Certificate mail-production expires in 5 days', NOW() - INTERVAL '20 minutes', 'sent', NULL),
|
||||
('ne-004', 'expiration_warning', 'mc-ci-prod', 'email', 'frank@example.com', 'Certificate ci-production expires in 18 days', NOW() - INTERVAL '15 minutes', 'sent', NULL),
|
||||
|
||||
-- Renewal success/failure
|
||||
('ne-010', 'renewal_success', 'mc-api-prod', 'email', 'alice@example.com', 'Certificate api-production renewed successfully', NOW() - INTERVAL '15 days', 'sent', NULL),
|
||||
('ne-011', 'renewal_success', 'mc-web-prod', 'email', 'dave@example.com', 'Certificate web-production renewed successfully', NOW() - INTERVAL '30 days', 'sent', NULL),
|
||||
('ne-012', 'renewal_success', 'mc-pay-prod', 'email', 'carol@example.com', 'Certificate payments-production renewed successfully', NOW() - INTERVAL '50 days', 'sent', NULL),
|
||||
('ne-013', 'renewal_failure', 'mc-vpn-prod', 'webhook', 'https://hooks.example.com/certctl', 'Renewal failed for vpn-production after 3 attempts', NOW() - INTERVAL '3 days', 'sent', NULL),
|
||||
('ne-014', 'renewal_failure', 'mc-vpn-prod', 'email', 'bob@example.com', 'Renewal failed for vpn-production after 3 attempts', NOW() - INTERVAL '3 days', 'sent', NULL),
|
||||
|
||||
-- Deployment success
|
||||
('ne-020', 'deployment_success', 'mc-api-prod', 'webhook', 'https://hooks.example.com/certctl', 'Certificate api-production deployed to NGINX Production', NOW() - INTERVAL '15 days', 'sent', NULL),
|
||||
('ne-021', 'deployment_success', 'mc-dash-prod', 'email', 'dave@example.com', 'Certificate dashboard-production deployed successfully', NOW() - INTERVAL '8 days', 'sent', NULL),
|
||||
('ne-022', 'deployment_success', 'mc-k8s-ingress', 'email', 'frank@example.com', 'Certificate k8s-ingress deployed to Traefik', NOW() - INTERVAL '34 days', 'sent', NULL),
|
||||
|
||||
-- Revocation notification
|
||||
('ne-030', 'revocation', 'mc-compromised', 'email', 'bob@example.com', 'Certificate old-service.example.com revoked: keyCompromise', NOW() - INTERVAL '14 days', 'sent', NULL),
|
||||
|
||||
-- Slack notifications (recent)
|
||||
('ne-040', 'expiration_warning', 'mc-auth-prod', 'slack', '#ops-alerts', 'Certificate auth-production expires in 12 days', NOW() - INTERVAL '30 minutes', 'sent', NULL),
|
||||
('ne-041', 'renewal_failure', 'mc-vpn-prod', 'slack', '#ops-alerts', 'Renewal failed: vpn-production (ACME HTTP-01 refused)', NOW() - INTERVAL '3 days', 'sent', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Agent Groups
|
||||
-- ============================================================
|
||||
-- 15. Agent Groups
|
||||
-- ============================================================
|
||||
INSERT INTO agent_groups (id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at) VALUES
|
||||
('ag-linux-prod', 'Linux Production', 'All Linux agents in production', 'linux', '', '', '', true, NOW(), NOW()),
|
||||
('ag-linux-amd64', 'Linux AMD64', 'Linux agents on x86_64 architecture', 'linux', 'amd64', '', '', true, NOW(), NOW()),
|
||||
('ag-windows', 'Windows Agents', 'All Windows-based agents', 'windows', '', '', '', true, NOW(), NOW()),
|
||||
('ag-datacenter-a', 'Datacenter A', 'Agents in 10.0.1.0/24 subnet', '', '', '10.0.1.0/24', '', true, NOW(), NOW()),
|
||||
('ag-manual', 'Manual Group', 'Manually managed agent group (no dynamic criteria)', '', '', '', '', false, NOW(), NOW())
|
||||
('ag-linux-prod', 'Linux Production', 'All Linux agents in production', 'linux', '', '', '', true, NOW() - INTERVAL '90 days', NOW()),
|
||||
('ag-linux-amd64', 'Linux AMD64', 'Linux agents on x86_64 architecture', 'linux', 'amd64', '', '', true, NOW() - INTERVAL '90 days', NOW()),
|
||||
('ag-windows', 'Windows Agents', 'All Windows-based agents', 'windows', '', '', '', true, NOW() - INTERVAL '60 days', NOW()),
|
||||
('ag-datacenter-a', 'Datacenter A', 'Agents in 10.0.1.0/24 subnet', '', '', '10.0.1.0/24', '', true, NOW() - INTERVAL '90 days', NOW()),
|
||||
('ag-arm64', 'ARM64 Agents', 'Agents on ARM architecture', '', 'arm64', '', '', true, NOW() - INTERVAL '45 days', NOW()),
|
||||
('ag-manual', 'Manual Group', 'Manually managed agent group', '', '', '', '', false, NOW() - INTERVAL '30 days', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Network Scan Targets
|
||||
INSERT INTO network_scan_targets (id, name, cidrs, ports, enabled, scan_interval_hours, timeout_ms, created_at, updated_at) VALUES
|
||||
('nst-dc1-web', 'DC1 Web Servers', '{10.0.1.0/24}', '{443,8443}', true, 6, 5000, NOW(), NOW()),
|
||||
('nst-dc2-apps', 'DC2 Application Tier', '{10.0.2.0/24,10.0.3.0/24}', '{443}', true, 6, 5000, NOW(), NOW()),
|
||||
('nst-dmz', 'DMZ Public Endpoints', '{192.168.100.0/24}', '{443,8443,9443}', true, 12, 3000, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Agent Group Members (manual membership for the manual group)
|
||||
INSERT INTO agent_group_members (agent_group_id, agent_id, membership_type, created_at) VALUES
|
||||
('ag-manual', 'ag-web-prod', 'include', NOW()),
|
||||
('ag-manual', 'ag-web-staging', 'include', NOW()),
|
||||
('ag-manual', 'ag-iis-prod', 'exclude', NOW())
|
||||
('ag-manual', 'ag-web-prod', 'include', NOW() - INTERVAL '30 days'),
|
||||
('ag-manual', 'ag-web-staging', 'include', NOW() - INTERVAL '30 days'),
|
||||
('ag-manual', 'ag-iis-prod', 'exclude', NOW() - INTERVAL '30 days')
|
||||
ON CONFLICT (agent_group_id, agent_id) DO NOTHING;
|
||||
|
||||
-- Sentinel agent for network-discovered certificates (created by server on startup, seed for demo)
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'online', NOW(), NOW() - INTERVAL '30 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.5')
|
||||
-- ============================================================
|
||||
-- 16. Network Scan Targets
|
||||
-- ============================================================
|
||||
INSERT INTO network_scan_targets (id, name, cidrs, ports, enabled, scan_interval_hours, timeout_ms, created_at, updated_at) VALUES
|
||||
('nst-dc1-web', 'DC1 Web Servers', '{10.0.1.0/24}', '{443,8443}', true, 6, 5000, NOW() - INTERVAL '60 days', NOW()),
|
||||
('nst-dc2-apps', 'DC2 Application Tier', '{10.0.2.0/24,10.0.3.0/24}', '{443}', true, 6, 5000, NOW() - INTERVAL '60 days', NOW()),
|
||||
('nst-dmz', 'DMZ Public Endpoints', '{192.168.100.0/24}', '{443,8443,9443}', true, 12, 3000, NOW() - INTERVAL '45 days', NOW()),
|
||||
('nst-edge', 'Edge Locations', '{10.0.5.0/24,10.0.6.0/24}', '{443}', true, 6, 5000, NOW() - INTERVAL '30 days', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Discovery Scans — show recent scan activity from agents
|
||||
UPDATE network_scan_targets SET
|
||||
last_scan_at = NOW() - INTERVAL '1 hour',
|
||||
last_scan_duration_ms = 4500,
|
||||
last_scan_certs_found = 5
|
||||
WHERE id = 'nst-dc1-web';
|
||||
|
||||
UPDATE network_scan_targets SET
|
||||
last_scan_at = NOW() - INTERVAL '2 hours',
|
||||
last_scan_duration_ms = 8200,
|
||||
last_scan_certs_found = 2
|
||||
WHERE id = 'nst-dc2-apps';
|
||||
|
||||
UPDATE network_scan_targets SET
|
||||
last_scan_at = NOW() - INTERVAL '6 hours',
|
||||
last_scan_duration_ms = 3100,
|
||||
last_scan_certs_found = 3
|
||||
WHERE id = 'nst-dmz';
|
||||
|
||||
-- ============================================================
|
||||
-- 17. Discovery Scans (backdated over 90 days)
|
||||
-- ============================================================
|
||||
INSERT INTO discovery_scans (id, agent_id, directories, certificates_found, certificates_new, errors_count, scan_duration_ms, started_at, completed_at) VALUES
|
||||
('ds-web-prod-01', 'ag-web-prod', '{/etc/nginx/ssl,/etc/pki/tls/certs}', 4, 2, 0, 1250, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '3 hours' + INTERVAL '1 second'),
|
||||
('ds-data-prod-01', 'ag-data-prod', '{/etc/nginx/ssl,/opt/certs}', 3, 1, 0, 980, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours' + INTERVAL '1 second'),
|
||||
('ds-network-scan-01','server-scanner', '{network-scan}', 3, 3, 0, 4500, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour' + INTERVAL '5 seconds')
|
||||
-- Historical scans
|
||||
('ds-web-hist-01', 'ag-web-prod', '{/etc/nginx/ssl,/etc/pki/tls/certs}', 3, 3, 0, 1100, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days' + INTERVAL '1 second'),
|
||||
('ds-web-hist-02', 'ag-web-prod', '{/etc/nginx/ssl,/etc/pki/tls/certs}', 4, 1, 0, 1200, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days' + INTERVAL '1 second'),
|
||||
('ds-data-hist-01', 'ag-data-prod', '{/etc/nginx/ssl,/opt/certs}', 2, 2, 0, 850, NOW() - INTERVAL '45 days', NOW() - INTERVAL '45 days' + INTERVAL '1 second'),
|
||||
-- Recent scans
|
||||
('ds-web-prod-01', 'ag-web-prod', '{/etc/nginx/ssl,/etc/pki/tls/certs}', 4, 0, 0, 1250, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '3 hours' + INTERVAL '1 second'),
|
||||
('ds-data-prod-01', 'ag-data-prod', '{/etc/nginx/ssl,/opt/certs}', 3, 0, 0, 980, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours' + INTERVAL '1 second'),
|
||||
('ds-edge-prod-01', 'ag-edge-01', '{/etc/caddy/certs}', 1, 0, 0, 420, NOW() - INTERVAL '4 hours', NOW() - INTERVAL '4 hours' + INTERVAL '1 second'),
|
||||
-- Network scans
|
||||
('ds-net-hist-01', 'server-scanner', '{network-scan}', 3, 3, 0, 12500, NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days' + INTERVAL '12 seconds'),
|
||||
('ds-net-prod-01', 'server-scanner', '{network-scan}', 5, 2, 1, 15200, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour' + INTERVAL '15 seconds')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Discovered Certificates — populate discovery triage page with realistic mix
|
||||
-- ============================================================
|
||||
-- 18. Discovered Certificates
|
||||
-- ============================================================
|
||||
INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn, not_before, not_after, key_algorithm, key_size, is_ca, pem_data, source_path, source_format, agent_id, discovery_scan_id, managed_certificate_id, status, first_seen_at, last_seen_at) VALUES
|
||||
-- Unmanaged: found on filesystem, not yet claimed
|
||||
('dc-unmanaged-01', 'sha256:f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0',
|
||||
@@ -244,7 +570,7 @@ INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans,
|
||||
'CN=internal-service.example.com,O=Example Corp', NOW() - INTERVAL '200 days', NOW() + INTERVAL '20 days',
|
||||
'RSA', 2048, false, '', '/etc/pki/tls/certs/internal-svc.pem', 'PEM',
|
||||
'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
|
||||
NOW() - INTERVAL '60 days', NOW() - INTERVAL '3 hours'),
|
||||
|
||||
('dc-unmanaged-02', 'sha256:a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0',
|
||||
'monitoring.internal.example.com', ARRAY['monitoring.internal.example.com', 'prometheus.internal.example.com'],
|
||||
@@ -252,7 +578,7 @@ INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans,
|
||||
'CN=monitoring.internal.example.com', NOW() - INTERVAL '60 days', NOW() + INTERVAL '30 days',
|
||||
'ECDSA', 256, false, '', '/opt/certs/monitoring.pem', 'PEM',
|
||||
'ag-data-prod', 'ds-data-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '5 days', NOW() - INTERVAL '2 hours'),
|
||||
NOW() - INTERVAL '45 days', NOW() - INTERVAL '2 hours'),
|
||||
|
||||
('dc-unmanaged-03', 'sha256:1122334455667788990011223344556677889900',
|
||||
'db-replication.example.com', ARRAY['db-replication.example.com'],
|
||||
@@ -260,7 +586,15 @@ INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans,
|
||||
'CN=db-replication.example.com,O=Example Corp', NOW() - INTERVAL '300 days', NOW() - INTERVAL '10 days',
|
||||
'RSA', 4096, false, '', '/etc/pki/tls/certs/db-repl.pem', 'PEM',
|
||||
'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
|
||||
NOW() - INTERVAL '60 days', NOW() - INTERVAL '3 hours'),
|
||||
|
||||
('dc-unmanaged-04', 'sha256:aabb001122334455667788990011223344aabb00',
|
||||
'redis-tls.internal.example.com', ARRAY['redis-tls.internal.example.com'],
|
||||
'4D:5E:6F:7A:8B:9C:00:44', 'CN=Example Internal CA,O=Example Corp',
|
||||
'CN=redis-tls.internal.example.com,O=Example Corp', NOW() - INTERVAL '90 days', NOW() + INTERVAL '60 days',
|
||||
'ECDSA', 256, false, '', '/opt/certs/redis-tls.pem', 'PEM',
|
||||
'ag-data-prod', 'ds-data-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '45 days', NOW() - INTERVAL '2 hours'),
|
||||
|
||||
-- Managed: already linked to managed certificates
|
||||
('dc-managed-01', 'sha256:ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12',
|
||||
@@ -269,15 +603,15 @@ INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans,
|
||||
'CN=api.example.com', NOW() - INTERVAL '15 days', NOW() + INTERVAL '75 days',
|
||||
'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM',
|
||||
'ag-web-prod', 'ds-web-prod-01', 'mc-api-prod', 'Managed',
|
||||
NOW() - INTERVAL '15 days', NOW() - INTERVAL '3 hours'),
|
||||
NOW() - INTERVAL '60 days', NOW() - INTERVAL '3 hours'),
|
||||
|
||||
('dc-managed-02', 'sha256:cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34',
|
||||
'data.example.com', ARRAY['data.example.com', 'analytics.example.com'],
|
||||
'0A:1B:2C:3D:4E:5F:00:06', 'CN=CertCtl Demo CA',
|
||||
'0A:1B:2C:3D:4E:5F:00:07', 'CN=CertCtl Demo CA',
|
||||
'CN=data.example.com', NOW() - INTERVAL '35 days', NOW() + INTERVAL '55 days',
|
||||
'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM',
|
||||
'ag-data-prod', 'ds-data-prod-01', 'mc-data-prod', 'Managed',
|
||||
NOW() - INTERVAL '35 days', NOW() - INTERVAL '2 hours'),
|
||||
NOW() - INTERVAL '45 days', NOW() - INTERVAL '2 hours'),
|
||||
|
||||
-- Dismissed: triaged and explicitly ignored
|
||||
('dc-dismissed-01', 'sha256:9988776655443322110099887766554433221100',
|
||||
@@ -285,8 +619,8 @@ INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans,
|
||||
'00:00:00:00:00:00:FF:01', 'CN=test-selfsigned.local',
|
||||
'CN=test-selfsigned.local', NOW() - INTERVAL '365 days', NOW() + INTERVAL '365 days',
|
||||
'RSA', 2048, false, '', '/etc/pki/tls/certs/test.pem', 'PEM',
|
||||
'ag-web-prod', 'ds-web-prod-01', NULL, 'Dismissed',
|
||||
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
|
||||
'ag-web-prod', 'ds-web-hist-01', NULL, 'Dismissed',
|
||||
NOW() - INTERVAL '60 days', NOW() - INTERVAL '3 hours'),
|
||||
|
||||
-- Network-discovered certs (from server-scanner sentinel agent)
|
||||
('dc-network-01', 'sha256:net1aabbccdd11223344556677889900aabbccdd',
|
||||
@@ -294,41 +628,38 @@ INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans,
|
||||
'5E:6F:7A:8B:9C:0D:00:44', 'CN=Example Network CA,O=Example Corp',
|
||||
'CN=switch-mgmt.example.com,O=Example Corp', NOW() - INTERVAL '180 days', NOW() + INTERVAL '5 days',
|
||||
'RSA', 2048, false, '', '10.0.1.50:443', 'TLS',
|
||||
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
|
||||
'server-scanner', 'ds-net-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '7 days', NOW() - INTERVAL '1 hour'),
|
||||
|
||||
('dc-network-02', 'sha256:net2eeff00112233445566778899aabbccddeeff',
|
||||
'printer.example.com', ARRAY['printer.example.com'],
|
||||
'6F:7A:8B:9C:0D:1E:00:55', 'CN=printer.example.com',
|
||||
'CN=printer.example.com', NOW() - INTERVAL '400 days', NOW() - INTERVAL '30 days',
|
||||
'RSA', 1024, false, '', '10.0.2.100:443', 'TLS',
|
||||
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
|
||||
'server-scanner', 'ds-net-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '7 days', NOW() - INTERVAL '1 hour'),
|
||||
|
||||
('dc-network-03', 'sha256:net3001122334455667788990011223344556677',
|
||||
'vpn-appliance.example.com', ARRAY['vpn-appliance.example.com', '10.0.1.1'],
|
||||
'7A:8B:9C:0D:1E:2F:00:66', 'CN=Fortinet CA,O=Fortinet',
|
||||
'CN=vpn-appliance.example.com', NOW() - INTERVAL '90 days', NOW() + INTERVAL '275 days',
|
||||
'RSA', 2048, false, '', '10.0.1.1:443', 'TLS',
|
||||
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
|
||||
'server-scanner', 'ds-net-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '7 days', NOW() - INTERVAL '1 hour'),
|
||||
|
||||
('dc-network-04', 'sha256:net400112233445566778899001122334455aabb',
|
||||
'ilo-server-rack3.example.com', ARRAY['ilo-server-rack3.example.com'],
|
||||
'8B:9C:0D:1E:2F:3A:00:77', 'CN=iLO Default Issuer',
|
||||
'CN=ilo-server-rack3.example.com', NOW() - INTERVAL '730 days', NOW() - INTERVAL '365 days',
|
||||
'RSA', 2048, false, '', '10.0.1.80:443', 'TLS',
|
||||
'server-scanner', 'ds-net-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
|
||||
|
||||
('dc-network-05', 'sha256:net500aabbccdd11223344556677889900112233',
|
||||
'nas-backup.example.com', ARRAY['nas-backup.example.com'],
|
||||
'9C:0D:1E:2F:3A:4B:00:88', 'CN=Synology Inc CA,O=Synology Inc.',
|
||||
'CN=nas-backup.example.com', NOW() - INTERVAL '180 days', NOW() + INTERVAL '180 days',
|
||||
'RSA', 2048, false, '', '10.0.1.90:5001', 'TLS',
|
||||
'server-scanner', 'ds-net-prod-01', NULL, 'Unmanaged',
|
||||
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Jobs — add AwaitingApproval jobs for approval workflow demo
|
||||
INSERT INTO jobs (id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts, last_error, scheduled_at, created_at) VALUES
|
||||
('job-approval-01', 'renewal', 'mc-auth-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
|
||||
('job-approval-02', 'renewal', 'mc-pay-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Update network scan targets with last_scan data so GUI shows recent activity
|
||||
UPDATE network_scan_targets SET
|
||||
last_scan_at = NOW() - INTERVAL '1 hour',
|
||||
last_scan_duration_ms = 4500,
|
||||
last_scan_certs_found = 3
|
||||
WHERE id = 'nst-dc1-web';
|
||||
|
||||
UPDATE network_scan_targets SET
|
||||
last_scan_at = NOW() - INTERVAL '2 hours',
|
||||
last_scan_duration_ms = 8200,
|
||||
last_scan_certs_found = 0
|
||||
WHERE id = 'nst-dc2-apps';
|
||||
|
||||
@@ -78,6 +78,11 @@ import {
|
||||
triggerNetworkScan,
|
||||
previewDigest,
|
||||
sendDigest,
|
||||
getJob,
|
||||
getJobVerification,
|
||||
getIssuer,
|
||||
getTarget,
|
||||
getPrometheusMetrics,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
@@ -1006,4 +1011,99 @@ describe('API Client', () => {
|
||||
expect(result.message).toBe('digest sent');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Job Detail ────────────────────────────
|
||||
|
||||
describe('Job Detail', () => {
|
||||
it('getJob fetches single job by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'job-1', type: 'Deployment', status: 'Completed' }));
|
||||
const result = await getJob('job-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1');
|
||||
expect(result.id).toBe('job-1');
|
||||
expect(result.type).toBe('Deployment');
|
||||
});
|
||||
|
||||
it('getJobVerification fetches verification result', async () => {
|
||||
const verificationData = {
|
||||
job_id: 'job-1',
|
||||
target_id: 't-nginx1',
|
||||
verified: true,
|
||||
actual_fingerprint: 'abc123',
|
||||
expected_fingerprint: 'abc123',
|
||||
verified_at: '2026-03-28T12:00:00Z',
|
||||
};
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse(verificationData));
|
||||
const result = await getJobVerification('job-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1/verification');
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.actual_fingerprint).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Issuer Detail ─────────────────────────
|
||||
|
||||
describe('Issuer Detail', () => {
|
||||
it('getIssuer fetches single issuer by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-local', name: 'Local CA', type: 'local_ca', status: 'active' }));
|
||||
const result = await getIssuer('iss-local');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/issuers/iss-local');
|
||||
expect(result.name).toBe('Local CA');
|
||||
expect(result.type).toBe('local_ca');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Target Detail ─────────────────────────
|
||||
|
||||
describe('Target Detail', () => {
|
||||
it('getTarget fetches single target by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-nginx1', name: 'Web Server', type: 'nginx', hostname: 'web1.example.com' }));
|
||||
const result = await getTarget('t-nginx1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/targets/t-nginx1');
|
||||
expect(result.name).toBe('Web Server');
|
||||
expect(result.type).toBe('nginx');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prometheus Metrics ────────────────────
|
||||
|
||||
describe('Prometheus Metrics', () => {
|
||||
it('getPrometheusMetrics fetches text format', async () => {
|
||||
const metricsText = '# HELP certctl_certificate_total Total certificates\ncertctl_certificate_total 10';
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(metricsText),
|
||||
} as Response)
|
||||
);
|
||||
const result = await getPrometheusMetrics();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics/prometheus');
|
||||
expect(result).toContain('certctl_certificate_total');
|
||||
});
|
||||
|
||||
it('getPrometheusMetrics throws on error', async () => {
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('error'),
|
||||
} as Response)
|
||||
);
|
||||
await expect(getPrometheusMetrics()).rejects.toThrow('Prometheus metrics failed: 500');
|
||||
});
|
||||
|
||||
it('getPrometheusMetrics includes auth header', async () => {
|
||||
setApiKey('prom-key');
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('metrics'),
|
||||
} as Response)
|
||||
);
|
||||
await getPrometheusMetrics();
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.headers['Authorization']).toBe('Bearer prom-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,5 +365,32 @@ export const previewDigest = () => {
|
||||
export const sendDigest = () =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
|
||||
|
||||
// Jobs (single)
|
||||
export const getJob = (id: string) =>
|
||||
fetchJSON<Job>(`${BASE}/jobs/${id}`);
|
||||
|
||||
// Job Verification
|
||||
export const getJobVerification = (id: string) =>
|
||||
fetchJSON<{ job_id: string; target_id: string; verified: boolean; actual_fingerprint: string; expected_fingerprint: string; verified_at: string; error?: string }>(`${BASE}/jobs/${id}/verification`);
|
||||
|
||||
// Issuers (single)
|
||||
export const getIssuer = (id: string) =>
|
||||
fetchJSON<Issuer>(`${BASE}/issuers/${id}`);
|
||||
|
||||
// Targets (single)
|
||||
export const getTarget = (id: string) =>
|
||||
fetchJSON<Target>(`${BASE}/targets/${id}`);
|
||||
|
||||
// Prometheus metrics (text format)
|
||||
export const getPrometheusMetrics = () => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
return fetch(`${BASE}/metrics/prometheus`, { headers })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`Prometheus metrics failed: ${r.status}`);
|
||||
return r.text();
|
||||
});
|
||||
};
|
||||
|
||||
// Health
|
||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface Job {
|
||||
id: string;
|
||||
certificate_id: string;
|
||||
type: string;
|
||||
target_id?: string;
|
||||
agent_id?: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
|
||||
@@ -19,6 +19,8 @@ const nav = [
|
||||
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
|
||||
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
|
||||
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
||||
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
];
|
||||
|
||||
@@ -69,7 +71,7 @@ export default function Layout() {
|
||||
</nav>
|
||||
|
||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.9</span>
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.14</span>
|
||||
{authRequired && (
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -25,6 +25,11 @@ import ShortLivedPage from './pages/ShortLivedPage';
|
||||
import AgentFleetPage from './pages/AgentFleetPage';
|
||||
import DiscoveryPage from './pages/DiscoveryPage';
|
||||
import NetworkScanPage from './pages/NetworkScanPage';
|
||||
import DigestPage from './pages/DigestPage';
|
||||
import ObservabilityPage from './pages/ObservabilityPage';
|
||||
import JobDetailPage from './pages/JobDetailPage';
|
||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||
import TargetDetailPage from './pages/TargetDetailPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||
<Route path="fleet" element={<AgentFleetPage />} />
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="jobs/:id" element={<JobDetailPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="targets/:id" element={<TargetDetailPage />} />
|
||||
<Route path="owners" element={<OwnersPage />} />
|
||||
<Route path="teams" element={<TeamsPage />} />
|
||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
||||
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="short-lived" element={<ShortLivedPage />} />
|
||||
<Route path="discovery" element={<DiscoveryPage />} />
|
||||
<Route path="network-scans" element={<NetworkScanPage />} />
|
||||
<Route path="digest" element={<DigestPage />} />
|
||||
<Route path="observability" element={<ObservabilityPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Certificate } from '../api/types';
|
||||
|
||||
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
id: '',
|
||||
common_name: '',
|
||||
environment: 'production',
|
||||
@@ -35,6 +36,12 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">New Certificate</h2>
|
||||
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Name *</label>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
placeholder="API Production Cert" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
|
||||
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
|
||||
@@ -89,7 +96,7 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!form.common_name || !form.issuer_id || mutation.isPending}
|
||||
disabled={!form.name || !form.common_name || !form.issuer_id || mutation.isPending}
|
||||
className="btn btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Certificate'}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { previewDigest, sendDigest } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
|
||||
export default function DigestPage() {
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const { data: html, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['digest-preview'],
|
||||
queryFn: previewDigest,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: sendDigest,
|
||||
onSuccess: () => setShowConfirm(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Certificate Digest"
|
||||
subtitle="Preview and send the scheduled certificate digest email"
|
||||
action={
|
||||
<button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={!html || sendMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
Send Digest Now
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{sendMutation.isSuccess && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
|
||||
Digest sent successfully.
|
||||
</div>
|
||||
)}
|
||||
{sendMutation.isError && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
Failed to send digest: {(sendMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading digest preview...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ErrorState
|
||||
error={error as Error}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{html && (
|
||||
<div className="bg-white border border-surface-border rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-surface border-b border-surface-border flex items-center justify-between">
|
||||
<span className="text-xs text-ink-muted font-medium">Email Preview</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-xs text-brand-400 hover:text-brand-500"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
srcDoc={html}
|
||||
title="Digest Preview"
|
||||
className="w-full border-0"
|
||||
style={{ minHeight: '600px' }}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-surface-border">
|
||||
<h3 className="text-lg font-semibold text-ink">Send Digest</h3>
|
||||
<p className="text-sm text-ink-muted mt-1">
|
||||
This will send the certificate digest email to all configured recipients.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
|
||||
<button onClick={() => setShowConfirm(false)} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={sendMutation.isPending}
|
||||
className="px-4 py-2 text-sm text-white bg-brand-500 hover:bg-brand-600 rounded disabled:opacity-50"
|
||||
>
|
||||
{sendMutation.isPending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Certificate } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME (Let\'s Encrypt)',
|
||||
step_ca: 'step-ca',
|
||||
openssl: 'OpenSSL / Custom',
|
||||
vault: 'Vault PKI',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IssuerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: issuer, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuer', id],
|
||||
queryFn: () => getIssuer(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: certsData } = useQuery({
|
||||
queryKey: ['certificates', { issuer_id: id }],
|
||||
queryFn: () => getCertificates({ issuer_id: id! }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => testIssuerConnection(id!),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Issuer Details" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !issuer) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Issuer Details" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading issuer...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Redact sensitive config fields
|
||||
const safeConfig = issuer.config ? Object.fromEntries(
|
||||
Object.entries(issuer.config).map(([k, v]) => {
|
||||
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
|
||||
return [k, sensitive ? '********' : v];
|
||||
})
|
||||
) : {};
|
||||
|
||||
const certColumns: Column<Certificate>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Certificate',
|
||||
render: (c) => (
|
||||
<div>
|
||||
<div className="font-medium text-ink text-sm">{c.common_name}</div>
|
||||
<div className="text-xs text-ink-faint font-mono">{c.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
|
||||
{ key: 'expires', label: 'Expires', render: (c) => <span className="text-xs text-ink-muted">{formatDateTime(c.expires_at)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={issuer.name}
|
||||
subtitle={typeLabels[issuer.type] || issuer.type}
|
||||
action={
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{testMutation.isSuccess && (
|
||||
<div className="px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
|
||||
Connection test passed.
|
||||
</div>
|
||||
)}
|
||||
{testMutation.isError && (
|
||||
<div className="px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
Connection test failed: {(testMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Issuer info */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Issuer Information</h3>
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
|
||||
<InfoRow label="Name" value={issuer.name} />
|
||||
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={issuer.status} />} />
|
||||
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
|
||||
</div>
|
||||
|
||||
{/* Config (redacted) */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
|
||||
{Object.keys(safeConfig).length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{Object.entries(safeConfig).map(([key, val]) => (
|
||||
<InfoRow key={key} label={key} value={
|
||||
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
|
||||
} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issued certificates */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||
Issued Certificates {certsData ? `(${certsData.total})` : ''}
|
||||
</h3>
|
||||
<DataTable
|
||||
columns={certColumns}
|
||||
data={certsData?.data || []}
|
||||
isLoading={!certsData}
|
||||
emptyMessage="No certificates issued by this issuer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -120,7 +121,9 @@ export default function IssuersPage() {
|
||||
label: 'Issuer',
|
||||
render: (i) => (
|
||||
<div>
|
||||
<div className="font-medium text-ink">{i.name}</div>
|
||||
<Link to={`/issuers/${i.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||
{i.name}
|
||||
</Link>
|
||||
<div className="text-xs text-ink-faint font-mono">{i.id}</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime, timeAgo } from '../api/utils';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationBadge({ status }: { status?: string }) {
|
||||
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: job, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['job', id],
|
||||
queryFn: () => getJob(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: verification } = useQuery({
|
||||
queryKey: ['job-verification', id],
|
||||
queryFn: () => getJobVerification(id!),
|
||||
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: auditData } = useQuery({
|
||||
queryKey: ['audit', { resource_id: id }],
|
||||
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Job Details" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !job) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Job Details" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading job...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={`Job ${job.id}`}
|
||||
subtitle={`${job.type} job`}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Job details */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
|
||||
<InfoRow label="Type" value={job.type} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
|
||||
<InfoRow label="Certificate" value={
|
||||
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.certificate_id}
|
||||
</Link>
|
||||
} />
|
||||
{job.agent_id && (
|
||||
<InfoRow label="Agent" value={
|
||||
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.agent_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
{job.target_id && (
|
||||
<InfoRow label="Target" value={
|
||||
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.target_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
|
||||
{job.error_message && (
|
||||
<InfoRow label="Error" value={
|
||||
<span className="text-red-600 text-xs">{job.error_message}</span>
|
||||
} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
|
||||
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
|
||||
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
|
||||
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
|
||||
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
|
||||
{job.completed_at && job.started_at && (
|
||||
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification section — only for deployment jobs */}
|
||||
{job.type === 'Deployment' && (
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
|
||||
{job.verification_status ? (
|
||||
<div className="space-y-0">
|
||||
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
|
||||
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
|
||||
{job.verification_fingerprint && (
|
||||
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
|
||||
)}
|
||||
{job.verification_error && (
|
||||
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
|
||||
)}
|
||||
{verification && verification.verified && (
|
||||
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">
|
||||
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit trail */}
|
||||
{auditData && auditData.data.length > 0 && (
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
|
||||
<div className="space-y-2">
|
||||
{auditData.data.map(event => (
|
||||
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
|
||||
<div>
|
||||
<span className="text-sm text-ink">{event.action}</span>
|
||||
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
|
||||
</div>
|
||||
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -47,6 +48,27 @@ function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationBadge({ status }: { status?: string }) {
|
||||
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
@@ -89,13 +111,26 @@ export default function JobsPage() {
|
||||
label: 'Job',
|
||||
render: (j) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs text-ink">{j.id}</div>
|
||||
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||
{j.id}
|
||||
</Link>
|
||||
<div className="text-xs text-ink-faint">{j.type}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent',
|
||||
render: (j) => j.agent_id ? (
|
||||
<Link to={`/agents/${j.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono" onClick={(e) => e.stopPropagation()}>
|
||||
{j.agent_id}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-ink-faint">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attempts',
|
||||
label: 'Attempts',
|
||||
@@ -103,6 +138,11 @@ export default function JobsPage() {
|
||||
},
|
||||
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
|
||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||
{
|
||||
key: 'verification',
|
||||
label: 'Verification',
|
||||
render: (j) => j.type === 'Deployment' ? <VerificationBadge status={j.verification_status} /> : <span className="text-xs text-ink-faint">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
|
||||
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||
<div className="text-xs text-ink-muted mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold text-ink">{value}</div>
|
||||
{sub && <div className="text-xs text-ink-faint mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default function ObservabilityPage() {
|
||||
const { data: metrics, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: getMetrics,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: getHealth,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: promText } = useQuery({
|
||||
queryKey: ['prometheus-metrics'],
|
||||
queryFn: getPrometheusMetrics,
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Observability" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Observability"
|
||||
subtitle={health ? `Server: ${health.status}` : undefined}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Health status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${health?.status === 'ok' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm text-ink font-medium">
|
||||
Server {health?.status === 'ok' ? 'Healthy' : 'Unhealthy'}
|
||||
</span>
|
||||
{metrics && (
|
||||
<span className="text-xs text-ink-faint ml-auto">
|
||||
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gauge metrics */}
|
||||
{isLoading && (
|
||||
<div className="text-sm text-ink-muted py-10 text-center">Loading metrics...</div>
|
||||
)}
|
||||
|
||||
{metrics && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Certificate Gauges</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<MetricCard label="Total" value={metrics.gauge.certificate_total} />
|
||||
<MetricCard label="Active" value={metrics.gauge.certificate_active} />
|
||||
<MetricCard label="Expiring Soon" value={metrics.gauge.certificate_expiring_soon} />
|
||||
<MetricCard label="Expired" value={metrics.gauge.certificate_expired} />
|
||||
<MetricCard label="Revoked" value={metrics.gauge.certificate_revoked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Agent & Job Gauges</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<MetricCard label="Total Agents" value={metrics.gauge.agent_total} />
|
||||
<MetricCard label="Online Agents" value={metrics.gauge.agent_online} />
|
||||
<MetricCard label="Pending Jobs" value={metrics.gauge.job_pending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Counters</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||
<MetricCard label="Jobs Completed (total)" value={metrics.counter.job_completed_total} />
|
||||
<MetricCard label="Jobs Failed (total)" value={metrics.counter.job_failed_total} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Prometheus config */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Prometheus Integration</h3>
|
||||
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||
<p className="text-sm text-ink mb-3">
|
||||
Add this scrape target to your <code className="text-xs bg-surface-muted px-1 py-0.5 rounded">prometheus.yml</code>:
|
||||
</p>
|
||||
<pre className="bg-ink text-white rounded p-4 text-xs overflow-x-auto font-mono">
|
||||
{`scrape_configs:
|
||||
- job_name: 'certctl'
|
||||
metrics_path: '/api/v1/metrics/prometheus'
|
||||
scheme: 'https'
|
||||
bearer_token: '<YOUR_API_KEY>'
|
||||
static_configs:
|
||||
- targets: ['<CERTCTL_HOST>:443']`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Prometheus output */}
|
||||
{promText && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Live Prometheus Output</h3>
|
||||
<div className="bg-surface border border-surface-border rounded shadow-sm">
|
||||
<div className="px-4 py-2 border-b border-surface-border flex items-center justify-between">
|
||||
<span className="text-xs text-ink-faint font-mono">GET /api/v1/metrics/prometheus</span>
|
||||
<span className="text-xs text-ink-faint">text/plain</span>
|
||||
</div>
|
||||
<pre className="p-4 text-xs text-ink-muted overflow-x-auto font-mono max-h-96 overflow-y-auto whitespace-pre">
|
||||
{promText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTarget, getJobs } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Job } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
nginx: 'NGINX',
|
||||
apache: 'Apache',
|
||||
haproxy: 'HAProxy',
|
||||
traefik: 'Traefik',
|
||||
caddy: 'Caddy',
|
||||
f5_bigip: 'F5 BIG-IP',
|
||||
iis: 'IIS',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TargetDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: target, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['target', id],
|
||||
queryFn: () => getTarget(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Deployment jobs for this target
|
||||
const { data: jobsData } = useQuery({
|
||||
queryKey: ['jobs', { target_id: id, type: 'Deployment' }],
|
||||
queryFn: () => getJobs({ target_id: id! }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Target Details" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !target) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Target Details" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading target...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const jobColumns: Column<Job>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'Job',
|
||||
render: (j) => (
|
||||
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright">
|
||||
{j.id}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||
{ key: 'cert', label: 'Certificate', render: (j) => (
|
||||
<Link to={`/certificates/${j.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{j.certificate_id}
|
||||
</Link>
|
||||
)},
|
||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||
{
|
||||
key: 'verification',
|
||||
label: 'Verification',
|
||||
render: (j) => {
|
||||
if (!j.verification_status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[j.verification_status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[j.verification_status] || j.verification_status}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={target.name}
|
||||
subtitle={typeLabels[target.type] || target.type}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Target info */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Target Information</h3>
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
|
||||
<InfoRow label="Name" value={target.name} />
|
||||
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
|
||||
<InfoRow label="Hostname" value={target.hostname || '—'} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
|
||||
{target.agent_id && (
|
||||
<InfoRow label="Agent" value={
|
||||
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{target.agent_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
|
||||
{target.config && Object.keys(target.config).length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{Object.entries(target.config).map(([key, val]) => (
|
||||
<InfoRow key={key} label={key.replace(/_/g, ' ')} value={
|
||||
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
|
||||
} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployment history */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||
Deployment History {jobsData ? `(${jobsData.total})` : ''}
|
||||
</h3>
|
||||
<DataTable
|
||||
columns={jobColumns}
|
||||
data={jobsData?.data || []}
|
||||
isLoading={!jobsData}
|
||||
emptyMessage="No deployments to this target"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTargets, createTarget, deleteTarget } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -266,7 +267,9 @@ export default function TargetsPage() {
|
||||
label: 'Target',
|
||||
render: (t) => (
|
||||
<div>
|
||||
<div className="font-medium text-ink">{t.name}</div>
|
||||
<Link to={`/targets/${t.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||
{t.name}
|
||||
</Link>
|
||||
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user