mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:01:36 +00:00
Merge pull request #3 from shankar0123/v2-dev
V2.0.0 — Operational Maturity Release
This commit is contained in:
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- v2-dev
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
@@ -18,19 +19,21 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Go Build
|
||||
run: |
|
||||
go build ./cmd/server/...
|
||||
go build ./cmd/agent/...
|
||||
go build ./cmd/mcp-server/...
|
||||
go build ./cmd/cli/...
|
||||
|
||||
- name: Go Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Go Test with Coverage
|
||||
run: |
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/integration/... ./internal/connector/issuer/local/... -count=1 -cover -coverprofile=coverage.out
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... -count=1 -cover -coverprofile=coverage.out
|
||||
|
||||
- name: Check Coverage Thresholds
|
||||
run: |
|
||||
|
||||
@@ -54,9 +54,14 @@ temp/
|
||||
# Build artifacts
|
||||
certctl-server
|
||||
certctl-agent
|
||||
certctl-cli
|
||||
/server
|
||||
/agent
|
||||
|
||||
# Private strategy docs
|
||||
roadmap.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
mcp-server
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Go binary
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Multi-stage build for certctl agent
|
||||
# Stage 1: Build
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||
|
||||
A self-hosted certificate lifecycle platform. Track, renew, and deploy TLS certificates across your infrastructure with a web dashboard, REST API, and agent-based architecture where private keys never leave your servers.
|
||||
TLS certificate lifespans are shrinking. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Manual certificate management is no longer viable at any scale.
|
||||
|
||||
certctl is a self-hosted platform for **end-to-end certificate lifecycle automation** — from issuance through renewal to deployment — with zero human intervention. Track every certificate in your organization, automatically renew them before they expire, and deploy them to your servers without touching a terminal. Private keys never leave your infrastructure.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||
@@ -8,7 +10,7 @@ A self-hosted certificate lifecycle platform. Track, renew, and deploy TLS certi
|
||||
|
||||
## What It Does
|
||||
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (55 endpoints) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally and submit CSRs — private keys never leave your servers.
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (91 endpoints under `/api/v1/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates on disk, and submit CSRs — private keys never leave your servers. The **network scanner** discovers certificates on TLS endpoints across your infrastructure without requiring agents. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -19,14 +21,14 @@ flowchart LR
|
||||
|
||||
subgraph "Your Infrastructure"
|
||||
A1["Agent"] --> T1["NGINX"]
|
||||
A2["Agent"] --> T2["F5 BIG-IP"]
|
||||
A3["Agent"] --> T3["IIS"]
|
||||
A2["Agent"] --> T2["Apache / HAProxy"]
|
||||
A3["Agent"] --> T3["F5 · IIS"]
|
||||
end
|
||||
|
||||
API --> PG
|
||||
A1 & A2 & A3 -->|"CSR + status\n(no private keys)"| API
|
||||
API -->|"Signed certs"| A1 & A2 & A3
|
||||
API -->|"Issue/Renew"| CA["Certificate Authorities\nLocal CA · ACME"]
|
||||
API -->|"Issue/Renew"| CA["Certificate Authorities\nLocal CA · ACME · step-ca · OpenSSL"]
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
@@ -40,7 +42,7 @@ flowchart LR
|
||||
|  |  |
|
||||
| **Notifications** — threshold alerts grouped by certificate | **Policies** — enforcement rules with enable/disable and delete |
|
||||
|  |  |
|
||||
| **Issuers** — CA connectors with test connectivity | **Targets** — deployment targets (NGINX, F5, IIS) |
|
||||
| **Issuers** — CA connectors with test connectivity | **Targets** — deployment targets (NGINX, Apache, HAProxy, F5, IIS) |
|
||||
|  | |
|
||||
| **Audit Trail** — immutable log of every action | |
|
||||
|
||||
@@ -100,6 +102,8 @@ export CERTCTL_AGENT_ID=agent-local-01
|
||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||
| [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 |
|
||||
| [Manual Testing Guide](docs/testing-guide.md) | 284 tests across 25 areas — full V2 QA runbook with exact commands and pass/fail criteria |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -110,11 +114,11 @@ flowchart TB
|
||||
API["REST API\nGo 1.22 net/http"]
|
||||
SVC["Service Layer"]
|
||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
||||
SCHED["Scheduler\nRenewal · Jobs · Health · Notifications"]
|
||||
SCHED["Scheduler\nRenewal · Jobs · Health · Notifications · Short-Lived Expiry · Network Scan"]
|
||||
end
|
||||
|
||||
subgraph "Data Store"
|
||||
PG[("PostgreSQL 16\n14 tables\nTEXT primary keys")]
|
||||
PG[("PostgreSQL 16\n21 tables\nTEXT primary keys")]
|
||||
end
|
||||
|
||||
subgraph "Agents"
|
||||
@@ -144,7 +148,7 @@ flowchart TB
|
||||
| `renewal_policies` | Renewal window, auto-renew settings, retry config, alert thresholds |
|
||||
| `issuers` | CA configurations (Local CA, ACME, etc.) |
|
||||
| `deployment_targets` | Target systems (NGINX, F5, IIS) with agent assignments |
|
||||
| `agents` | Registered agents with heartbeat tracking |
|
||||
| `agents` | Registered agents with heartbeat tracking, OS/arch/IP metadata |
|
||||
| `jobs` | Issuance, renewal, deployment, and validation jobs |
|
||||
| `teams` | Organizational groups for certificate ownership |
|
||||
| `owners` | Individual owners with email for notifications |
|
||||
@@ -153,6 +157,13 @@ flowchart TB
|
||||
| `audit_events` | Immutable action log (append-only, no update/delete) |
|
||||
| `notification_events` | Email and webhook notification records |
|
||||
| `certificate_target_mappings` | Many-to-many cert ↔ target relationships |
|
||||
| `certificate_profiles` | Named enrollment profiles with allowed key types, max TTL, crypto constraints |
|
||||
| `agent_groups` | Dynamic device grouping by OS, architecture, IP CIDR, version |
|
||||
| `agent_group_members` | Manual include/exclude membership for agent groups |
|
||||
| `certificate_revocations` | Revocation records with RFC 5280 reason codes, serial numbers, issuer notification status |
|
||||
| `discovered_certificates` | Filesystem and network-discovered certificates with fingerprint deduplication |
|
||||
| `discovery_scans` | Discovery scan history with timestamps and agent attribution |
|
||||
| `network_scan_targets` | Network scan target definitions with CIDRs, ports, schedule, and scan metrics |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -171,6 +182,34 @@ All server environment variables use the `CERTCTL_` prefix:
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) |
|
||||
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) |
|
||||
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default) or `dns-01` |
|
||||
| `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode |
|
||||
| `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode |
|
||||
| `CERTCTL_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (empty = same-origin, `*` = all) |
|
||||
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable/disable token bucket rate limiting |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second limit |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Maximum burst size for rate limiter |
|
||||
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to SQL migration files |
|
||||
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often the scheduler checks for expiring certs |
|
||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often the scheduler processes pending jobs |
|
||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | How often the scheduler checks agent health |
|
||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | How often the scheduler processes pending notifications |
|
||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | — | Script to create DNS-01 `_acme-challenge` TXT record |
|
||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record |
|
||||
| `CERTCTL_STEPCA_URL` | — | step-ca server URL |
|
||||
| `CERTCTL_STEPCA_PROVISIONER` | — | step-ca JWK provisioner name |
|
||||
| `CERTCTL_STEPCA_KEY_PATH` | — | Path to step-ca provisioner private key (JWK JSON) |
|
||||
| `CERTCTL_STEPCA_PASSWORD` | — | step-ca provisioner key password |
|
||||
| `CERTCTL_OPENSSL_SIGN_SCRIPT` | — | Script for OpenSSL/Custom CA certificate signing |
|
||||
| `CERTCTL_OPENSSL_REVOKE_SCRIPT` | — | Script for OpenSSL/Custom CA certificate revocation |
|
||||
| `CERTCTL_OPENSSL_CRL_SCRIPT` | — | Script for OpenSSL/Custom CA CRL generation |
|
||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | `30` | Timeout for OpenSSL script execution |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side network certificate discovery (TLS scanning) |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the scheduler runs network scans |
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL for notifications |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams incoming webhook URL |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 routing key |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key |
|
||||
|
||||
Agent environment variables:
|
||||
|
||||
@@ -181,23 +220,89 @@ Agent environment variables:
|
||||
| `CERTCTL_AGENT_NAME` | `certctl-agent` | Agent display name |
|
||||
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for storing private keys (agent keygen mode) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | — | Comma-separated directories to scan for existing certificates (e.g., `/etc/nginx/certs,/etc/ssl/certs`) |
|
||||
|
||||
Docker Compose overrides these for the demo stack (see `deploy/docker-compose.yml`): port `8443`, auth type `none`, database pointing to the postgres container.
|
||||
|
||||
## MCP Server (AI Integration)
|
||||
|
||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443 # certctl API endpoint
|
||||
export CERTCTL_API_KEY=your-api-key # optional if auth disabled
|
||||
|
||||
# Run (stdio transport — add to your AI client config)
|
||||
mcp-server
|
||||
```
|
||||
|
||||
**Claude Desktop** (`claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"certctl": {
|
||||
"command": "mcp-server",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
78 tools organized by resource: certificates (9), CRL/OCSP (3), issuers (6), targets (5), agents (8), jobs (5), policies (6), profiles (5), teams (5), owners (5), agent groups (6), audit (2), notifications (3), stats (5), metrics (1), health (4).
|
||||
|
||||
## CLI
|
||||
|
||||
certctl ships a command-line tool for terminal-based certificate management workflows.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/shankar0123/certctl/cmd/cli@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
|
||||
# Commands
|
||||
certctl-cli list-certs # List all certificates
|
||||
certctl-cli get-cert --id mc-api-prod # Get certificate details
|
||||
certctl-cli renew-cert --id mc-api-prod # Trigger renewal
|
||||
certctl-cli revoke-cert --id mc-api-prod --reason keyCompromise
|
||||
certctl-cli list-agents # List registered agents
|
||||
certctl-cli list-jobs # List jobs
|
||||
certctl-cli health # Server health check
|
||||
certctl-cli metrics # Server metrics
|
||||
certctl-cli import --file certs.pem # Bulk import from PEM file
|
||||
|
||||
# Output formats
|
||||
certctl-cli list-certs --format json # JSON output (default: table)
|
||||
```
|
||||
|
||||
## API Overview
|
||||
|
||||
All endpoints are under `/api/v1/` and return JSON. List endpoints support pagination (`?page=1&per_page=50`).
|
||||
All endpoints are under `/api/v1/` and return JSON. List endpoints support pagination (`?page=1&per_page=50`). Full request/response schemas are available in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
|
||||
### Certificates
|
||||
```
|
||||
GET /api/v1/certificates List (filter: status, environment, owner_id, team_id)
|
||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||
POST /api/v1/certificates Create
|
||||
GET /api/v1/certificates/{id} Get
|
||||
PUT /api/v1/certificates/{id} Update
|
||||
DELETE /api/v1/certificates/{id} Archive (soft delete)
|
||||
GET /api/v1/certificates/{id}/versions Version history
|
||||
GET /api/v1/certificates/{id}/deployments List deployment targets
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/deploy Trigger deployment → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/crl Certificate Revocation List (JSON)
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
```
|
||||
|
||||
### Agents
|
||||
@@ -210,6 +315,17 @@ POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
||||
GET /api/v1/agents/{id}/certificates/{certId} Retrieve signed certificate
|
||||
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
||||
POST /api/v1/agents/{id}/jobs/{jobId}/status Report job completion/failure
|
||||
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
||||
```
|
||||
|
||||
### Certificate Discovery
|
||||
```
|
||||
GET /api/v1/discovered-certificates List discovered certificates (?agent_id, ?status)
|
||||
GET /api/v1/discovered-certificates/{id} Get discovery detail
|
||||
POST /api/v1/discovered-certificates/{id}/claim Link discovered cert to managed cert
|
||||
POST /api/v1/discovered-certificates/{id}/dismiss Dismiss discovery
|
||||
GET /api/v1/discovery-scans List discovery scan history
|
||||
GET /api/v1/discovery-summary Aggregated discovery status (new, claimed, dismissed counts)
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
@@ -247,18 +363,57 @@ DELETE /api/v1/owners/{id} Delete
|
||||
GET /api/v1/jobs List (filter: status, type)
|
||||
GET /api/v1/jobs/{id} Get
|
||||
POST /api/v1/jobs/{id}/cancel Cancel
|
||||
POST /api/v1/jobs/{id}/approve Approve (interactive renewal)
|
||||
POST /api/v1/jobs/{id}/reject Reject (interactive renewal)
|
||||
|
||||
GET /api/v1/policies List policy rules
|
||||
POST /api/v1/policies Create
|
||||
GET /api/v1/policies/{id} Get
|
||||
PUT /api/v1/policies/{id} Update (enable/disable)
|
||||
DELETE /api/v1/policies/{id} Delete
|
||||
GET /api/v1/policies/{id}/violations List violations for rule
|
||||
|
||||
GET /api/v1/profiles List certificate profiles
|
||||
POST /api/v1/profiles Create
|
||||
GET /api/v1/profiles/{id} Get
|
||||
PUT /api/v1/profiles/{id} Update
|
||||
DELETE /api/v1/profiles/{id} Delete
|
||||
|
||||
GET /api/v1/agent-groups List agent groups
|
||||
POST /api/v1/agent-groups Create
|
||||
GET /api/v1/agent-groups/{id} Get
|
||||
PUT /api/v1/agent-groups/{id} Update
|
||||
DELETE /api/v1/agent-groups/{id} Delete
|
||||
GET /api/v1/agent-groups/{id}/members List members
|
||||
|
||||
GET /api/v1/audit Query audit trail
|
||||
GET /api/v1/audit/{id} Get audit event
|
||||
GET /api/v1/notifications List notifications
|
||||
GET /api/v1/notifications/{id} Get notification
|
||||
POST /api/v1/notifications/{id}/read Mark as read
|
||||
```
|
||||
|
||||
### Observability
|
||||
```
|
||||
GET /api/v1/stats/summary Dashboard summary (totals, expiring, agents, jobs)
|
||||
GET /api/v1/stats/certificates-by-status Certificate counts grouped by status
|
||||
GET /api/v1/stats/expiration-timeline Expiration buckets (?days=30)
|
||||
GET /api/v1/stats/job-trends Job success/failure over time (?days=7)
|
||||
GET /api/v1/stats/issuance-rate Certificate issuance rate (?days=7)
|
||||
GET /api/v1/metrics JSON metrics (gauges, counters, uptime)
|
||||
GET /api/v1/metrics/prometheus Prometheus exposition format (text/plain)
|
||||
```
|
||||
|
||||
### Network Discovery
|
||||
```
|
||||
GET /api/v1/network-scan-targets List scan targets
|
||||
POST /api/v1/network-scan-targets Create scan target (CIDRs, ports, schedule)
|
||||
GET /api/v1/network-scan-targets/{id} Get scan target
|
||||
PUT /api/v1/network-scan-targets/{id} Update scan target
|
||||
DELETE /api/v1/network-scan-targets/{id} Delete scan target
|
||||
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate scan
|
||||
```
|
||||
|
||||
### Auth
|
||||
```
|
||||
GET /api/v1/auth/info Auth mode info (no auth required)
|
||||
@@ -276,20 +431,23 @@ GET /ready Readiness check
|
||||
### Certificate Issuers
|
||||
| Issuer | Status | Type |
|
||||
|--------|--------|------|
|
||||
| Local CA (self-signed) | Implemented | `GenericCA` |
|
||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01) | `ACME` |
|
||||
| step-ca | Planned (V2) | — |
|
||||
| OpenSSL / Custom CA | Planned (V2) | — |
|
||||
| ADCS (Active Directory CS) | Planned (V2) | — |
|
||||
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01) | `ACME` |
|
||||
| step-ca | Implemented | `StepCA` |
|
||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||
| Vault PKI | Planned | — |
|
||||
| DigiCert | Planned | — |
|
||||
|
||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS.
|
||||
|
||||
### Deployment Targets
|
||||
| Target | Status | Type |
|
||||
|--------|--------|------|
|
||||
| NGINX | Implemented | `NGINX` |
|
||||
| F5 BIG-IP | Interface only (V2) | `F5` |
|
||||
| Microsoft IIS | Interface only (V2) | `IIS` |
|
||||
| Apache httpd | Implemented | `Apache` |
|
||||
| HAProxy | Implemented | `HAProxy` |
|
||||
| F5 BIG-IP | Interface only | `F5` |
|
||||
| Microsoft IIS | Interface only | `IIS` |
|
||||
| Kubernetes Secrets | Planned | — |
|
||||
|
||||
### Notifiers
|
||||
@@ -297,7 +455,10 @@ GET /ready Readiness check
|
||||
|----------|--------|------|
|
||||
| Email (SMTP) | Implemented | `Email` |
|
||||
| Webhooks | Implemented | `Webhook` |
|
||||
| Slack | Planned | — |
|
||||
| Slack | Implemented | `Slack` |
|
||||
| Microsoft Teams | Implemented | `Teams` |
|
||||
| PagerDuty | Implemented | `PagerDuty` |
|
||||
| OpsGenie | Implemented | `OpsGenie` |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -341,25 +502,39 @@ make docker-clean # Stop + remove volumes
|
||||
|
||||
### Audit Trail
|
||||
- Immutable append-only log in PostgreSQL (`audit_events` table)
|
||||
- Every action attributed to an actor with timestamp and resource reference
|
||||
- Every lifecycle action attributed to an actor with timestamp and resource reference
|
||||
- No update or delete operations on audit records
|
||||
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency (M19)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0 released)
|
||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 11 views wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 220+ tests total: 170+ Go tests across service, handler, integration, and connector layers, plus 53 frontend Vitest tests covering API client functions and utility helpers. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 19 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
||||
|
||||
### V2: Operational Maturity
|
||||
- **V2.0: Operational Workflows** — ACME DNS-01 challenges (wildcard certs, custom validation scripts), step-ca, ADCS, and OpenSSL/custom CA issuer connectors, F5 BIG-IP, IIS, Apache httpd, and HAProxy target connector implementations, agent metadata collection (OS, platform, IP, hostname via heartbeat), dynamic device grouping for policy-based targeting, crypto policy enforcement, certificate ownership tracking, renewal approval UI, bulk cert operations, deployment timeline, real-time updates (SSE/WebSocket), target config wizard
|
||||
- **V2.1: Team Adoption** — OIDC/SSO, RBAC, CLI tool, Slack/Teams/PagerDuty/OpsGenie notifiers, bulk cert import
|
||||
- **V2.2: Observability** — expiration calendar, health scores, compliance scoring, Prometheus metrics, deployment rollback
|
||||
- **V2.3: Integrations & Distribution** — MCP server (OpenClaw/Claude/Cursor), CT Log monitoring, DigiCert issuer connector, filesystem cert discovery
|
||||
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
||||
- **M11: Crypto Policy + Profiles + Ownership** ✅ — certificate profiles (named enrollment profiles with allowed key types, max TTL, crypto constraints), certificate ownership tracking (owners + teams + notification routing), dynamic agent groups (OS/arch/IP CIDR/version matching), interactive renewal approval (AwaitingApproval state)
|
||||
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), step-ca issuer connector (native /sign API with JWK provisioner auth)
|
||||
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
|
||||
- **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
|
||||
- **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view
|
||||
- **M14: Observability** ✅ — dashboard charts (expiration heatmap, cert status distribution, job trends, issuance rate), agent fleet overview with OS/arch grouping, JSON metrics endpoint, stats API (5 endpoints), structured logging with request IDs, deployment rollback
|
||||
- **M18a: MCP Server** ✅ (V2.1) — AI-native integration, all 78 REST API endpoints exposed as MCP tools for Claude, Cursor, OpenClaw, and any MCP-compatible client
|
||||
- **M19: Immutable API Audit Log** ✅ — every API call recorded to immutable audit trail (method, path, actor, SHA-256 body hash, status, latency), async recording via goroutine, configurable path exclusions
|
||||
- **M16a: Notifier Connectors** ✅ — Slack (incoming webhook), Microsoft Teams (MessageCard), PagerDuty (Events API v2), OpsGenie (Alert API v2) — config-driven enablement via env vars
|
||||
- **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout)
|
||||
- **M16b: CLI + Bulk Import** ✅ — `certctl-cli` with 10 subcommands (list/get/renew/revoke certs, list agents/jobs, health, metrics, PEM bulk import), stdlib-only, JSON/table output
|
||||
- **M20: Enhanced Query API** ✅ — sparse field selection (`?fields=`), sort with direction (`?sort=-notAfter`), time-range filters (`expires_before`, `created_after`, etc.), cursor-based pagination (`?cursor=&page_size=`), `GET /certificates/{id}/deployments`, additional filters (`agent_id`, `profile_id`)
|
||||
- **M18b: Filesystem Cert Discovery** ✅ — agents scan configured directories (PEM/DER), report findings to control plane, deduplication by SHA-256 fingerprint, claim/dismiss/triage workflow via API
|
||||
- **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default)
|
||||
- **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics
|
||||
- **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
|
||||
|
||||
### V3: Discovery, Visibility & Cloud
|
||||
Discovery engine (passive/active scanning, cert chain validation, Nmap/Qualys import, unknown cert detection, triage workflows), cloud targets (AWS ALB, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets), extended issuers (Entrust, GlobalSign, Google CAS, EJBCA, Vault PKI), ServiceNow integration, Ansible module, compliance mapping docs
|
||||
### V3: Team & Enterprise
|
||||
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.
|
||||
|
||||
### V4+: Platform & Scale
|
||||
Kubernetes CRD, Terraform provider, multi-region, HA control plane, HSM support, LDAP auth, API key scoping, multi-tenancy
|
||||
### V4+: Cloud, Scale & Passive Discovery
|
||||
Passive network discovery (TLS listener), Kubernetes integration, cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support, and platform-scale features.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+3180
File diff suppressed because it is too large
Load Diff
+326
-17
@@ -6,6 +6,8 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
@@ -14,32 +16,38 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||
)
|
||||
|
||||
// AgentConfig represents the agent-side configuration.
|
||||
type AgentConfig struct {
|
||||
ServerURL string // Control plane server URL (e.g., http://localhost:8443)
|
||||
APIKey string // Agent API key for authentication
|
||||
AgentName string // Agent name for identification
|
||||
AgentID string // Agent ID for API calls (set after registration or from env)
|
||||
Hostname string // Server hostname
|
||||
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
|
||||
ServerURL string // Control plane server URL (e.g., http://localhost:8443)
|
||||
APIKey string // Agent API key for authentication
|
||||
AgentName string // Agent name for identification
|
||||
AgentID string // Agent ID for API calls (set after registration or from env)
|
||||
Hostname string // Server hostname
|
||||
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
|
||||
DiscoveryDirs []string // Directories to scan for certificates (comma-separated via env)
|
||||
}
|
||||
|
||||
// Agent represents the local agent that runs on target servers.
|
||||
// It periodically sends heartbeats, polls for work, and executes deployment and CSR jobs.
|
||||
// It periodically sends heartbeats, polls for work, executes deployment and CSR jobs,
|
||||
// and scans configured directories for existing certificates.
|
||||
// In agent keygen mode, private keys are generated and stored locally — they never leave
|
||||
// this process or filesystem.
|
||||
type Agent struct {
|
||||
@@ -50,6 +58,7 @@ type Agent struct {
|
||||
// Configuration
|
||||
heartbeatInterval time.Duration
|
||||
pollInterval time.Duration
|
||||
discoveryInterval time.Duration
|
||||
consecutiveFailures int
|
||||
}
|
||||
|
||||
@@ -80,6 +89,7 @@ func NewAgent(cfg *AgentConfig, logger *slog.Logger) *Agent {
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
heartbeatInterval: 60 * time.Second,
|
||||
pollInterval: 30 * time.Second,
|
||||
discoveryInterval: 6 * time.Hour, // scan for certs every 6 hours
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +112,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
a.logger.Warn("failed to enforce key directory permissions", "path", a.config.KeyDir, "error", err)
|
||||
}
|
||||
|
||||
// Create ticker channels for heartbeat and polling
|
||||
// Create ticker channels for heartbeat, polling, and discovery
|
||||
heartbeatTicker := time.NewTicker(a.heartbeatInterval)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
@@ -113,6 +123,22 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
a.sendHeartbeat(ctx)
|
||||
a.pollForWork(ctx)
|
||||
|
||||
// Discovery: run initial scan if directories configured, then on interval
|
||||
var discoveryTicker *time.Ticker
|
||||
if len(a.config.DiscoveryDirs) > 0 {
|
||||
a.logger.Info("certificate discovery enabled",
|
||||
"directories", a.config.DiscoveryDirs,
|
||||
"interval", a.discoveryInterval.String())
|
||||
a.runDiscoveryScan(ctx)
|
||||
discoveryTicker = time.NewTicker(a.discoveryInterval)
|
||||
defer discoveryTicker.Stop()
|
||||
} else {
|
||||
a.logger.Info("certificate discovery disabled (no CERTCTL_DISCOVERY_DIRS configured)")
|
||||
// Create a stopped ticker so the select compiles
|
||||
discoveryTicker = time.NewTicker(24 * time.Hour)
|
||||
discoveryTicker.Stop()
|
||||
}
|
||||
|
||||
// Main event loop
|
||||
for {
|
||||
select {
|
||||
@@ -135,19 +161,38 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
a.pollForWork(ctx)
|
||||
|
||||
case <-discoveryTicker.C:
|
||||
if len(a.config.DiscoveryDirs) > 0 {
|
||||
a.runDiscoveryScan(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the control plane.
|
||||
// getOutboundIP returns the preferred outbound IP address of this machine.
|
||||
func getOutboundIP() string {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
localAddr := conn.LocalAddr().(*net.UDPAddr)
|
||||
return localAddr.IP.String()
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the control plane with agent metadata.
|
||||
// POST /api/v1/agents/{agentID}/heartbeat
|
||||
func (a *Agent) sendHeartbeat(ctx context.Context) {
|
||||
a.logger.Debug("sending heartbeat", "agent_id", a.config.AgentID)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/heartbeat", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, map[string]string{
|
||||
"version": "1.0.0",
|
||||
"hostname": a.config.Hostname,
|
||||
"version": "1.0.0",
|
||||
"hostname": a.config.Hostname,
|
||||
"os": runtime.GOOS,
|
||||
"architecture": runtime.GOARCH,
|
||||
"ip_address": getOutboundIP(),
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error("heartbeat failed", "error", err)
|
||||
@@ -489,6 +534,24 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
}
|
||||
return nginx.New(&cfg, a.logger), nil
|
||||
|
||||
case "Apache":
|
||||
var cfg apache.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Apache config: %w", err)
|
||||
}
|
||||
}
|
||||
return apache.New(&cfg, a.logger), nil
|
||||
|
||||
case "HAProxy":
|
||||
var cfg haproxy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid HAProxy config: %w", err)
|
||||
}
|
||||
}
|
||||
return haproxy.New(&cfg, a.logger), nil
|
||||
|
||||
case "F5":
|
||||
var cfg f5.Config
|
||||
if len(configJSON) > 0 {
|
||||
@@ -616,6 +679,239 @@ func (a *Agent) makeRequest(ctx context.Context, method, path string, body inter
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// runDiscoveryScan walks configured directories, parses certificate files, and reports
|
||||
// discovered certificates to the control plane.
|
||||
// Supports PEM and DER encoded X.509 certificates.
|
||||
func (a *Agent) runDiscoveryScan(ctx context.Context) {
|
||||
a.logger.Info("starting filesystem certificate discovery scan",
|
||||
"directories", a.config.DiscoveryDirs)
|
||||
|
||||
startTime := time.Now()
|
||||
var certs []discoveredCertEntry
|
||||
var scanErrors []string
|
||||
|
||||
for _, dir := range a.config.DiscoveryDirs {
|
||||
a.logger.Debug("scanning directory", "path", dir)
|
||||
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("walk error at %s: %v", path, err))
|
||||
return nil // continue walking
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files larger than 1MB (unlikely to be a certificate)
|
||||
if info.Size() > 1*1024*1024 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".pem", ".crt", ".cer", ".cert":
|
||||
found := a.parsePEMFile(path)
|
||||
certs = append(certs, found...)
|
||||
case ".der":
|
||||
if entry, err := a.parseDERFile(path); err == nil {
|
||||
certs = append(certs, entry)
|
||||
} else {
|
||||
a.logger.Debug("skipping non-cert DER file", "path", path, "error", err)
|
||||
}
|
||||
default:
|
||||
// Try PEM parsing for extensionless files or unknown extensions
|
||||
if ext == "" || ext == ".key" {
|
||||
return nil // skip key files and extensionless
|
||||
}
|
||||
found := a.parsePEMFile(path)
|
||||
if len(found) > 0 {
|
||||
certs = append(certs, found...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("failed to walk %s: %v", dir, err))
|
||||
}
|
||||
}
|
||||
|
||||
scanDuration := time.Since(startTime)
|
||||
a.logger.Info("discovery scan completed",
|
||||
"certificates_found", len(certs),
|
||||
"errors", len(scanErrors),
|
||||
"duration_ms", scanDuration.Milliseconds())
|
||||
|
||||
if len(certs) == 0 && len(scanErrors) == 0 {
|
||||
a.logger.Debug("no certificates found and no errors, skipping report")
|
||||
return
|
||||
}
|
||||
|
||||
// Build report payload
|
||||
entries := make([]map[string]interface{}, len(certs))
|
||||
for i, c := range certs {
|
||||
entries[i] = map[string]interface{}{
|
||||
"fingerprint_sha256": c.FingerprintSHA256,
|
||||
"common_name": c.CommonName,
|
||||
"sans": c.SANs,
|
||||
"serial_number": c.SerialNumber,
|
||||
"issuer_dn": c.IssuerDN,
|
||||
"subject_dn": c.SubjectDN,
|
||||
"not_before": c.NotBefore,
|
||||
"not_after": c.NotAfter,
|
||||
"key_algorithm": c.KeyAlgorithm,
|
||||
"key_size": c.KeySize,
|
||||
"is_ca": c.IsCA,
|
||||
"pem_data": c.PEMData,
|
||||
"source_path": c.SourcePath,
|
||||
"source_format": c.SourceFormat,
|
||||
}
|
||||
}
|
||||
|
||||
report := map[string]interface{}{
|
||||
"agent_id": a.config.AgentID,
|
||||
"directories": a.config.DiscoveryDirs,
|
||||
"certificates": entries,
|
||||
"errors": scanErrors,
|
||||
"scan_duration_ms": int(scanDuration.Milliseconds()),
|
||||
}
|
||||
|
||||
// Submit to control plane
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/discoveries", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, report)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to submit discovery report", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("discovery report rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("discovery report submitted successfully",
|
||||
"certificates", len(certs),
|
||||
"errors", len(scanErrors))
|
||||
}
|
||||
|
||||
// discoveredCertEntry holds parsed certificate metadata for reporting.
|
||||
type discoveredCertEntry struct {
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
SubjectDN string `json:"subject_dn"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
PEMData string `json:"pem_data"`
|
||||
SourcePath string `json:"source_path"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
}
|
||||
|
||||
// parsePEMFile reads a file and extracts all X.509 certificates from PEM blocks.
|
||||
func (a *Agent) parsePEMFile(path string) []discoveredCertEntry {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to read file", "path", path, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []discoveredCertEntry
|
||||
rest := data
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to parse certificate in PEM", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
pemStr := string(pem.EncodeToMemory(block))
|
||||
entries = append(entries, certToEntry(cert, path, "PEM", pemStr))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// parseDERFile reads a DER-encoded certificate file.
|
||||
func (a *Agent) parseDERFile(path string) (discoveredCertEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("read failed: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(data)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("parse failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert to PEM for storage
|
||||
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: data}))
|
||||
return certToEntry(cert, path, "DER", pemStr), nil
|
||||
}
|
||||
|
||||
// certToEntry converts a parsed x509.Certificate into a discoveredCertEntry.
|
||||
func certToEntry(cert *x509.Certificate, path, format, pemData string) discoveredCertEntry {
|
||||
// Compute SHA-256 fingerprint
|
||||
fingerprint := fmt.Sprintf("%x", sha256Sum(cert.Raw))
|
||||
|
||||
// Determine key algorithm and size
|
||||
keyAlg, keySize := certKeyInfo(cert)
|
||||
|
||||
return discoveredCertEntry{
|
||||
FingerprintSHA256: fingerprint,
|
||||
CommonName: cert.Subject.CommonName,
|
||||
SANs: cert.DNSNames,
|
||||
SerialNumber: cert.SerialNumber.Text(16),
|
||||
IssuerDN: cert.Issuer.String(),
|
||||
SubjectDN: cert.Subject.String(),
|
||||
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339),
|
||||
NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
|
||||
KeyAlgorithm: keyAlg,
|
||||
KeySize: keySize,
|
||||
IsCA: cert.IsCA,
|
||||
PEMData: pemData,
|
||||
SourcePath: path,
|
||||
SourceFormat: format,
|
||||
}
|
||||
}
|
||||
|
||||
// sha256Sum returns the SHA-256 hash of data.
|
||||
func sha256Sum(data []byte) [32]byte {
|
||||
return sha256.Sum256(data)
|
||||
}
|
||||
|
||||
// certKeyInfo extracts key algorithm name and size from a certificate.
|
||||
func certKeyInfo(cert *x509.Certificate) (string, int) {
|
||||
switch pub := cert.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return "ECDSA", pub.Curve.Params().BitSize
|
||||
case *rsa.PublicKey:
|
||||
return "RSA", pub.N.BitLen()
|
||||
default:
|
||||
switch cert.PublicKeyAlgorithm {
|
||||
case x509.Ed25519:
|
||||
return "Ed25519", 256
|
||||
default:
|
||||
return cert.PublicKeyAlgorithm.String(), 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse command-line flags (with env var fallbacks for Docker deployment)
|
||||
serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "http://localhost:8443"), "Control plane server URL")
|
||||
@@ -623,6 +919,7 @@ func main() {
|
||||
agentName := flag.String("name", getEnvDefault("CERTCTL_AGENT_NAME", "certctl-agent"), "Agent name")
|
||||
agentID := flag.String("agent-id", getEnvDefault("CERTCTL_AGENT_ID", ""), "Agent ID (from registration)")
|
||||
keyDir := flag.String("key-dir", getEnvDefault("CERTCTL_KEY_DIR", "/var/lib/certctl/keys"), "Directory for storing private keys")
|
||||
discoveryDirsStr := flag.String("discovery-dirs", getEnvDefault("CERTCTL_DISCOVERY_DIRS", ""), "Comma-separated directories to scan for certificates")
|
||||
flag.Parse()
|
||||
|
||||
if *apiKey == "" {
|
||||
@@ -651,14 +948,26 @@ func main() {
|
||||
hostname = "unknown"
|
||||
}
|
||||
|
||||
// Parse discovery directories
|
||||
var discoveryDirs []string
|
||||
if *discoveryDirsStr != "" {
|
||||
for _, d := range strings.Split(*discoveryDirsStr, ",") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
discoveryDirs = append(discoveryDirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent configuration
|
||||
agentCfg := &AgentConfig{
|
||||
ServerURL: *serverURL,
|
||||
APIKey: *apiKey,
|
||||
AgentName: *agentName,
|
||||
AgentID: *agentID,
|
||||
Hostname: hostname,
|
||||
KeyDir: *keyDir,
|
||||
ServerURL: *serverURL,
|
||||
APIKey: *apiKey,
|
||||
AgentName: *agentName,
|
||||
AgentID: *agentID,
|
||||
Hostname: hostname,
|
||||
KeyDir: *keyDir,
|
||||
DiscoveryDirs: discoveryDirs,
|
||||
}
|
||||
|
||||
// Create and start agent
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse global flags
|
||||
fs := flag.NewFlagSet("certctl-cli", flag.ExitOnError)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, `certctl-cli — CLI for certificate lifecycle management
|
||||
|
||||
Usage:
|
||||
certctl-cli [global flags] <command> [command flags]
|
||||
|
||||
Global flags:
|
||||
`)
|
||||
fs.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
Commands:
|
||||
certs list List certificates
|
||||
certs get ID Get certificate details
|
||||
certs renew ID Trigger certificate renewal
|
||||
certs revoke ID Revoke a certificate
|
||||
|
||||
agents list List agents
|
||||
agents get ID Get agent details
|
||||
|
||||
jobs list List jobs
|
||||
jobs get ID Get job details
|
||||
jobs cancel ID Cancel a pending job
|
||||
|
||||
import FILE Bulk import certificates from PEM file(s)
|
||||
|
||||
status Show server health + summary stats
|
||||
version Show CLI version
|
||||
|
||||
Examples:
|
||||
certctl-cli --server http://localhost:8443 --api-key mykey certs list
|
||||
certctl-cli certs renew mc-prod --format json
|
||||
certctl-cli import certs.pem
|
||||
`)
|
||||
}
|
||||
|
||||
serverURL := fs.String("server", os.Getenv("CERTCTL_SERVER_URL"), "certctl server URL (env: CERTCTL_SERVER_URL)")
|
||||
if *serverURL == "" {
|
||||
*serverURL = "http://localhost:8443"
|
||||
}
|
||||
|
||||
apiKey := fs.String("api-key", os.Getenv("CERTCTL_API_KEY"), "API key for authentication (env: CERTCTL_API_KEY)")
|
||||
format := fs.String("format", "table", "Output format: table, json")
|
||||
|
||||
fs.Parse(os.Args[1:])
|
||||
|
||||
args := fs.Args()
|
||||
if len(args) == 0 {
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := cli.NewClient(*serverURL, *apiKey, *format)
|
||||
|
||||
// Dispatch to appropriate command
|
||||
command := args[0]
|
||||
cmdArgs := args[1:]
|
||||
|
||||
var err error
|
||||
switch command {
|
||||
case "certs":
|
||||
err = handleCerts(client, cmdArgs)
|
||||
case "agents":
|
||||
err = handleAgents(client, cmdArgs)
|
||||
case "jobs":
|
||||
err = handleJobs(client, cmdArgs)
|
||||
case "import":
|
||||
err = handleImport(client, cmdArgs)
|
||||
case "status":
|
||||
err = handleStatus(client)
|
||||
case "version":
|
||||
fmt.Println("certctl-cli version 0.1.0")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", command)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func handleCerts(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs <list|get|renew|revoke> [options]\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
return client.ListCertificates(subArgs)
|
||||
case "get":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.GetCertificate(subArgs[0])
|
||||
case "renew":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs renew <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.RenewCertificate(subArgs[0])
|
||||
case "revoke":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> [--reason <reason>]\n")
|
||||
return nil
|
||||
}
|
||||
id := subArgs[0]
|
||||
reason := "unspecified"
|
||||
if len(subArgs) > 2 && subArgs[1] == "--reason" {
|
||||
reason = subArgs[2]
|
||||
}
|
||||
return client.RevokeCertificate(id, reason)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: certs %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAgents(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents <list|get> [options]\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
return client.ListAgents(subArgs)
|
||||
case "get":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.GetAgent(subArgs[0])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: agents %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleJobs(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: jobs <list|get|cancel> [options]\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
return client.ListJobs(subArgs)
|
||||
case "get":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: jobs get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.GetJob(subArgs[0])
|
||||
case "cancel":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: jobs cancel <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.CancelJob(subArgs[0])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: jobs %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleImport(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: import <file> [file2 ...]\n")
|
||||
return nil
|
||||
}
|
||||
return client.ImportCertificates(args)
|
||||
}
|
||||
|
||||
func handleStatus(client *cli.Client) error {
|
||||
return client.GetStatus()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/mcp"
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
serverURL := os.Getenv("CERTCTL_SERVER_URL")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://localhost:8443"
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("CERTCTL_API_KEY")
|
||||
|
||||
client := mcp.NewClient(serverURL, apiKey)
|
||||
|
||||
server := gomcp.NewServer(&gomcp.Implementation{
|
||||
Name: "certctl",
|
||||
Version: Version,
|
||||
}, nil)
|
||||
|
||||
mcp.RegisterTools(server, client)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "certctl MCP server %s (backend: %s)\n", Version, serverURL)
|
||||
|
||||
if err := server.Run(ctx, &gomcp.StdioTransport{}); err != nil {
|
||||
log.Fatalf("MCP server error: %v", err)
|
||||
}
|
||||
}
|
||||
+176
-12
@@ -16,8 +16,15 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/api/router"
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
||||
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
|
||||
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams"
|
||||
"github.com/shankar0123/certctl/internal/repository/postgres"
|
||||
"github.com/shankar0123/certctl/internal/scheduler"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
@@ -68,50 +75,162 @@ func main() {
|
||||
policyRepo := postgres.NewPolicyRepository(db)
|
||||
notificationRepo := postgres.NewNotificationRepository(db)
|
||||
renewalPolicyRepo := postgres.NewRenewalPolicyRepository(db)
|
||||
profileRepo := postgres.NewProfileRepository(db)
|
||||
teamRepo := postgres.NewTeamRepository(db)
|
||||
ownerRepo := postgres.NewOwnerRepository(db)
|
||||
logger.Info("initialized all repositories")
|
||||
|
||||
// Initialize Local CA issuer connector
|
||||
// This provides in-memory certificate signing for development, testing, and demo.
|
||||
// The CA is ephemeral (regenerated on restart) and NOT suitable for production.
|
||||
localCA := local.New(nil, logger)
|
||||
// Initialize Local CA issuer connector.
|
||||
// In sub-CA mode (CERTCTL_CA_CERT_PATH + CERTCTL_CA_KEY_PATH set), loads a pre-signed
|
||||
// CA cert+key from disk. All issued certs chain to the upstream root (e.g., ADCS).
|
||||
// Otherwise, generates an ephemeral self-signed CA for development/demo.
|
||||
localCAConfig := &local.Config{}
|
||||
if cfg.CA.CertPath != "" && cfg.CA.KeyPath != "" {
|
||||
localCAConfig.CACertPath = cfg.CA.CertPath
|
||||
localCAConfig.CAKeyPath = cfg.CA.KeyPath
|
||||
logger.Info("Local CA configured in sub-CA mode",
|
||||
"cert_path", cfg.CA.CertPath,
|
||||
"key_path", cfg.CA.KeyPath)
|
||||
} else {
|
||||
logger.Info("Local CA configured in self-signed mode (ephemeral)")
|
||||
}
|
||||
localCA := local.New(localCAConfig, logger)
|
||||
logger.Info("initialized Local CA issuer connector")
|
||||
|
||||
// Initialize ACME issuer connector (for Let's Encrypt, Sectigo, etc.)
|
||||
// The ACME connector is registered but only activated when an issuer record
|
||||
// in the database references it. Configuration comes from the issuer's config JSON.
|
||||
// Supports HTTP-01 (default) and DNS-01 (for wildcards) challenge types.
|
||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||
}, logger)
|
||||
logger.Info("initialized ACME issuer connector")
|
||||
|
||||
// Initialize step-ca issuer connector (for Smallstep private CA).
|
||||
// Uses the native /sign API with JWK provisioner authentication.
|
||||
stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{
|
||||
CAURL: os.Getenv("CERTCTL_STEPCA_URL"),
|
||||
ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"),
|
||||
ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"),
|
||||
ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"),
|
||||
}, logger)
|
||||
logger.Info("initialized step-ca issuer connector")
|
||||
|
||||
// Initialize OpenSSL/Custom CA issuer connector (for script-based CA integrations).
|
||||
// Delegates certificate signing to user-provided scripts.
|
||||
opensslConnector := opensslissuer.New(&opensslissuer.Config{
|
||||
SignScript: os.Getenv("CERTCTL_OPENSSL_SIGN_SCRIPT"),
|
||||
RevokeScript: os.Getenv("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
|
||||
CRLScript: os.Getenv("CERTCTL_OPENSSL_CRL_SCRIPT"),
|
||||
TimeoutSeconds: getEnvIntDefault(os.Getenv("CERTCTL_OPENSSL_TIMEOUT_SECONDS"), 30),
|
||||
}, logger)
|
||||
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
||||
|
||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
||||
// "iss-stepca" is the step-ca private CA connector.
|
||||
// "iss-openssl" is the custom CA/OpenSSL connector.
|
||||
issuerRegistry := map[string]service.IssuerConnector{
|
||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
||||
"iss-acme-staging": service.NewIssuerConnectorAdapter(acmeConnector),
|
||||
"iss-acme-prod": service.NewIssuerConnectorAdapter(acmeConnector),
|
||||
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
||||
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
||||
}
|
||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||
|
||||
// Initialize revocation repository
|
||||
revocationRepo := postgres.NewRevocationRepository(db)
|
||||
|
||||
// Initialize services (following the dependency graph)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
|
||||
notificationService := service.NewNotificationService(notificationRepo, make(map[string]service.Notifier))
|
||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||
notifierRegistry := make(map[string]service.Notifier)
|
||||
|
||||
// Wire notifier connectors from config
|
||||
if cfg.Notifiers.SlackWebhookURL != "" {
|
||||
slackNotifier := notifyslack.New(notifyslack.Config{
|
||||
WebhookURL: cfg.Notifiers.SlackWebhookURL,
|
||||
ChannelOverride: cfg.Notifiers.SlackChannel,
|
||||
Username: cfg.Notifiers.SlackUsername,
|
||||
})
|
||||
notifierRegistry["Slack"] = slackNotifier
|
||||
logger.Info("Slack notifier enabled")
|
||||
}
|
||||
if cfg.Notifiers.TeamsWebhookURL != "" {
|
||||
teamsNotifier := notifyteams.New(notifyteams.Config{
|
||||
WebhookURL: cfg.Notifiers.TeamsWebhookURL,
|
||||
})
|
||||
notifierRegistry["Teams"] = teamsNotifier
|
||||
logger.Info("Teams notifier enabled")
|
||||
}
|
||||
if cfg.Notifiers.PagerDutyRoutingKey != "" {
|
||||
pdNotifier := notifypagerduty.New(notifypagerduty.Config{
|
||||
RoutingKey: cfg.Notifiers.PagerDutyRoutingKey,
|
||||
Severity: cfg.Notifiers.PagerDutySeverity,
|
||||
})
|
||||
notifierRegistry["PagerDuty"] = pdNotifier
|
||||
logger.Info("PagerDuty notifier enabled")
|
||||
}
|
||||
if cfg.Notifiers.OpsGenieAPIKey != "" {
|
||||
ogNotifier := notifyopsgenie.New(notifyopsgenie.Config{
|
||||
APIKey: cfg.Notifiers.OpsGenieAPIKey,
|
||||
Priority: cfg.Notifiers.OpsGeniePriority,
|
||||
})
|
||||
notifierRegistry["OpsGenie"] = ogNotifier
|
||||
logger.Info("OpsGenie notifier enabled")
|
||||
}
|
||||
|
||||
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
|
||||
notificationService.SetOwnerRepo(ownerRepo)
|
||||
|
||||
// Wire revocation dependencies into CertificateService
|
||||
certificateService.SetRevocationRepo(revocationRepo)
|
||||
certificateService.SetNotificationService(notificationService)
|
||||
certificateService.SetIssuerRegistry(issuerRegistry)
|
||||
certificateService.SetProfileRepo(profileRepo)
|
||||
certificateService.SetTargetRepo(targetRepo)
|
||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||
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)
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||
targetService := service.NewTargetService(targetRepo, auditService)
|
||||
profileService := service.NewProfileService(profileRepo, auditService)
|
||||
teamService := service.NewTeamService(teamRepo, auditService)
|
||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||
agentGroupRepo := postgres.NewAgentGroupRepository(db)
|
||||
agentGroupService := service.NewAgentGroupService(agentGroupRepo, auditService)
|
||||
discoveryRepo := postgres.NewDiscoveryRepository(db)
|
||||
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
|
||||
networkScanRepo := postgres.NewNetworkScanRepository(db)
|
||||
networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger)
|
||||
logger.Info("initialized network scan service")
|
||||
|
||||
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
|
||||
// This agent ID is used as the agent_id in discovered_certificates for network-scanned certs.
|
||||
if cfg.NetworkScan.Enabled {
|
||||
sentinelAgent := &domain.Agent{
|
||||
ID: service.SentinelAgentID,
|
||||
Name: "Network Scanner (Server-Side)",
|
||||
Status: domain.AgentStatusOnline,
|
||||
}
|
||||
if err := agentRepo.Create(context.Background(), sentinelAgent); err != nil {
|
||||
// Ignore duplicate key errors (agent already exists)
|
||||
logger.Debug("sentinel agent creation", "status", "exists or created", "id", service.SentinelAgentID)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("initialized all services")
|
||||
|
||||
// Initialize stats and metrics services
|
||||
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
|
||||
logger.Info("initialized stats service")
|
||||
|
||||
// Initialize API handlers
|
||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||
issuerHandler := handler.NewIssuerHandler(issuerService)
|
||||
@@ -119,11 +238,17 @@ func main() {
|
||||
agentHandler := handler.NewAgentHandler(agentService)
|
||||
jobHandler := handler.NewJobHandler(jobService)
|
||||
policyHandler := handler.NewPolicyHandler(policyService)
|
||||
profileHandler := handler.NewProfileHandler(profileService)
|
||||
teamHandler := handler.NewTeamHandler(teamService)
|
||||
ownerHandler := handler.NewOwnerHandler(ownerService)
|
||||
agentGroupHandler := handler.NewAgentGroupHandler(agentGroupService)
|
||||
auditHandler := handler.NewAuditHandler(auditService)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
statsHandler := handler.NewStatsHandler(statsService)
|
||||
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
|
||||
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
||||
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
|
||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||
logger.Info("initialized all handlers")
|
||||
|
||||
// Create context with cancellation
|
||||
@@ -136,6 +261,7 @@ func main() {
|
||||
jobService,
|
||||
agentService,
|
||||
notificationService,
|
||||
networkScanService,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -144,6 +270,10 @@ func main() {
|
||||
sched.SetJobProcessorInterval(cfg.Scheduler.JobProcessorInterval)
|
||||
sched.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval)
|
||||
sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval)
|
||||
if cfg.NetworkScan.Enabled {
|
||||
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
|
||||
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
|
||||
}
|
||||
|
||||
// Start scheduler
|
||||
logger.Info("starting scheduler")
|
||||
@@ -160,11 +290,17 @@ func main() {
|
||||
agentHandler,
|
||||
jobHandler,
|
||||
policyHandler,
|
||||
profileHandler,
|
||||
teamHandler,
|
||||
ownerHandler,
|
||||
agentGroupHandler,
|
||||
auditHandler,
|
||||
notificationHandler,
|
||||
statsHandler,
|
||||
metricsHandler,
|
||||
healthHandler,
|
||||
discoveryHandler,
|
||||
networkScanHandler,
|
||||
)
|
||||
logger.Info("registered all API handlers")
|
||||
|
||||
@@ -177,12 +313,27 @@ func main() {
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
})
|
||||
|
||||
structuredLogger := middleware.NewLogging(logger)
|
||||
|
||||
// API audit log middleware — records every API call to the audit trail
|
||||
auditAdapter := middleware.NewAuditServiceAdapter(
|
||||
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
||||
return auditService.RecordEvent(ctx, actor, domain.ActorType(actorType), action, resourceType, resourceID, details)
|
||||
},
|
||||
)
|
||||
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
|
||||
ExcludePaths: []string{"/health", "/ready"},
|
||||
Logger: logger,
|
||||
})
|
||||
logger.Info("API audit logging enabled (excluding /health, /ready)")
|
||||
|
||||
middlewareStack := []func(http.Handler) http.Handler{
|
||||
middleware.RequestID,
|
||||
middleware.Logging,
|
||||
structuredLogger,
|
||||
middleware.Recovery,
|
||||
corsMiddleware,
|
||||
authMiddleware,
|
||||
auditMiddleware,
|
||||
}
|
||||
|
||||
// Add rate limiter if enabled
|
||||
@@ -193,11 +344,12 @@ func main() {
|
||||
})
|
||||
middlewareStack = []func(http.Handler) http.Handler{
|
||||
middleware.RequestID,
|
||||
middleware.Logging,
|
||||
structuredLogger,
|
||||
middleware.Recovery,
|
||||
rateLimiter,
|
||||
corsMiddleware,
|
||||
authMiddleware,
|
||||
auditMiddleware,
|
||||
}
|
||||
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
|
||||
}
|
||||
@@ -291,3 +443,15 @@ func main() {
|
||||
|
||||
logger.Info("certctl server stopped")
|
||||
}
|
||||
|
||||
// getEnvIntDefault parses an integer from a string with a default fallback.
|
||||
func getEnvIntDefault(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
val, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
+242
-41
@@ -9,11 +9,13 @@ 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. **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.
|
||||
3. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
||||
4. **Audit-First** — Complete traceability of all issuance, deployment, and rotation events
|
||||
5. **Connector Architecture** — Pluggable issuers, targets, and notifiers for extensibility
|
||||
6. **Self-Hosted** — No cloud lock-in; run with Docker Compose, Kubernetes, or bare metal
|
||||
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.
|
||||
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
|
||||
6. **Audit-First** — Complete traceability of all issuance, deployment, and rotation events
|
||||
7. **Connector Architecture** — Pluggable issuers, targets, and notifiers for extensibility
|
||||
8. **Self-Hosted** — No cloud lock-in; run with Docker Compose, Kubernetes, or bare metal
|
||||
|
||||
## System Components
|
||||
|
||||
@@ -23,12 +25,12 @@ flowchart TB
|
||||
API["REST API\n(Go net/http, :8443)"]
|
||||
SVC["Service Layer"]
|
||||
REPO["Repository Layer\n(database/sql + lib/pq)"]
|
||||
SCHED["Background Scheduler\n4 loops"]
|
||||
SCHED["Background Scheduler\n6 loops"]
|
||||
DASH["Web Dashboard\n(React SPA)"]
|
||||
end
|
||||
|
||||
subgraph "Data Store"
|
||||
PG[("PostgreSQL 16\n14 tables\nTEXT primary keys")]
|
||||
PG[("PostgreSQL 16\n21 tables\nTEXT primary keys")]
|
||||
end
|
||||
|
||||
subgraph "Agent Fleet"
|
||||
@@ -38,18 +40,19 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph "Issuer Backends"
|
||||
CA1["Local CA\n(crypto/x509)"]
|
||||
CA2["ACME\n(Let's Encrypt)"]
|
||||
CA3["step-ca\n(planned)"]
|
||||
CA4["OpenSSL / Custom CA\n(planned)"]
|
||||
CA5["ADCS\n(planned)"]
|
||||
CA1["Local CA\n(crypto/x509, sub-CA)"]
|
||||
CA2["ACME\n(HTTP-01 + DNS-01)"]
|
||||
CA3["step-ca\n(/sign API)"]
|
||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||
CA6["Vault PKI\n(planned)"]
|
||||
end
|
||||
|
||||
subgraph "Target Systems"
|
||||
T1["NGINX\n(file write + reload)"]
|
||||
T2["F5 BIG-IP\n(iControl REST, planned)"]
|
||||
T3["IIS\n(WinRM, planned)"]
|
||||
T4["Apache httpd\n(file write + reload)"]
|
||||
T5["HAProxy\n(combined PEM + reload)"]
|
||||
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
||||
end
|
||||
|
||||
DASH --> API
|
||||
@@ -77,17 +80,19 @@ The server exposes a REST API under `/api/v1/` and optionally serves the web das
|
||||
|
||||
### Agents
|
||||
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX fully implemented; Apache httpd, HAProxy planned for V2; F5 BIG-IP, IIS interface only with V2 implementations planned) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy fully implemented; F5 BIG-IP, IIS interface only with V2 implementations planned) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
|
||||
The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for actionable jobs via `GET /api/v1/agents/{id}/work`. Jobs may be `AwaitingCSR` (agent needs to generate key + submit CSR) or `Deployment` (agent needs to deploy a certificate). Private keys are stored in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`) with 0600 permissions.
|
||||
|
||||
**Planned (V2):** Agent metadata collection — agents will report OS, platform, architecture, IP address, and hostname via heartbeat using `runtime.GOOS`, `runtime.GOARCH`, and `net` stdlib. This metadata enables dynamic device grouping, allowing policies to be scoped by agent criteria (e.g., all Ubuntu agents, all agents in a specific subnet) rather than requiring manual per-certificate assignment.
|
||||
**Agent metadata (M10):** Agents report OS, architecture, IP address, hostname, and version via heartbeat using `runtime.GOOS`, `runtime.GOARCH`, and `net` stdlib. This metadata is stored on the `agents` table and displayed in the GUI (agent list shows OS/Arch column, detail page shows full system info).
|
||||
|
||||
**Agent groups (M11b):** Dynamic device grouping allows organizing agents by metadata criteria. Agent groups can match by OS, architecture, IP CIDR, and version. Groups support both dynamic matching (agents automatically join when criteria match) and manual membership (explicit include/exclude). Renewal policies can be scoped to agent groups via the `agent_group_id` foreign key. The GUI provides full CRUD management for agent groups with visual match criteria badges.
|
||||
|
||||
### Web Dashboard
|
||||
|
||||
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
|
||||
|
||||
**Current views**: certificate inventory (list with "New Certificate" creation modal + detail with version history, deploy, archive, and trigger renewal actions), agent fleet (health indicators from heartbeat), job queue (status, retry, cancel), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range and actor/action filters), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with delete), and a summary dashboard.
|
||||
**Current views (19 pages)**: certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info + OS/architecture grouping with charts), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), summary dashboard with charts (expiration heatmap, renewal success rate, status distribution, issuance rate), and login page.
|
||||
|
||||
The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
|
||||
|
||||
@@ -95,7 +100,7 @@ The dashboard includes an **ErrorBoundary component** for graceful error recover
|
||||
- Vite for fast builds and HMR during development
|
||||
- TanStack Query over manual fetch/useEffect for automatic cache invalidation and refetching
|
||||
- Dark theme default (ops teams live in dark mode)
|
||||
- SSE/WebSocket planned for real-time job status updates (V2.0)
|
||||
- SSE/WebSocket planned for real-time job status updates
|
||||
|
||||
### PostgreSQL Database
|
||||
|
||||
@@ -117,6 +122,8 @@ erDiagram
|
||||
managed_certificates ||--o{ policy_violations : "violates"
|
||||
managed_certificates ||--o{ audit_events : "logged in"
|
||||
managed_certificates ||--o{ notification_events : "generates"
|
||||
agent_groups ||--o{ agent_group_members : "has members"
|
||||
agents ||--o{ agent_group_members : "belongs to"
|
||||
|
||||
teams {
|
||||
text id PK
|
||||
@@ -157,6 +164,10 @@ erDiagram
|
||||
text hostname
|
||||
text status
|
||||
text api_key_hash
|
||||
varchar os
|
||||
varchar architecture
|
||||
varchar ip_address
|
||||
varchar version
|
||||
}
|
||||
deployment_targets {
|
||||
text id PK
|
||||
@@ -211,6 +222,26 @@ erDiagram
|
||||
text recipient
|
||||
text status
|
||||
}
|
||||
certificate_profiles {
|
||||
text id PK
|
||||
text name
|
||||
text description
|
||||
jsonb allowed_key_types
|
||||
int max_validity_days
|
||||
}
|
||||
agent_groups {
|
||||
text id PK
|
||||
text name
|
||||
text description
|
||||
jsonb match_criteria
|
||||
boolean enabled
|
||||
}
|
||||
agent_group_members {
|
||||
text id PK
|
||||
text agent_group_id FK
|
||||
text agent_id FK
|
||||
text membership_type
|
||||
}
|
||||
```
|
||||
|
||||
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL.
|
||||
@@ -274,6 +305,8 @@ sequenceDiagram
|
||||
Note over A: Agent deploys using locally-held private key
|
||||
```
|
||||
|
||||
**Profile enforcement:** If the certificate is assigned to a profile (`certificate_profile_id`), the profile's `allowed_key_algorithms` and `max_validity_days` constraints are checked during CSR validation. A CSR with a disallowed key type or a validity period exceeding the profile maximum is rejected before reaching the issuer connector.
|
||||
|
||||
#### Server-Side Key Generation (Demo Only)
|
||||
|
||||
Set `CERTCTL_KEYGEN_MODE=server` for development/demo with Local CA. The control plane generates RSA-2048 keys server-side. A log warning is emitted at startup.
|
||||
@@ -301,15 +334,47 @@ sequenceDiagram
|
||||
|
||||
The agent deploys certificates using target connectors. Each connector knows how to push certificates to a specific system:
|
||||
|
||||
- **NGINX**: Writes cert/chain files to disk, validates config with `nginx -t`, reloads with `nginx -s reload` or `systemctl reload nginx`
|
||||
- **F5 BIG-IP**: Calls the F5 REST API to upload certificate and update virtual server bindings
|
||||
- **IIS**: Uses WinRM to import the certificate into the Windows certificate store and bind it to an IIS site
|
||||
- **NGINX**: Writes cert/chain/key files to disk, validates config with `nginx -t`, reloads with `nginx -s reload` or `systemctl reload nginx`
|
||||
- **Apache httpd**: Writes separate cert/chain/key files, validates with `apachectl configtest`, graceful reload
|
||||
- **HAProxy**: Builds a combined PEM file (cert + chain + key), optionally validates config, reloads via systemctl or signal
|
||||
- **F5 BIG-IP** (planned): A proxy agent in the same network zone calls the iControl REST API to upload certificate and update SSL profile bindings. The server assigns the work; the proxy agent executes it.
|
||||
- **IIS** (planned, dual-mode): (1) Agent-local (recommended) — a Windows agent on the IIS box runs PowerShell `Import-PfxCertificate` + `Set-WebBinding` directly. (2) Proxy agent WinRM — for agentless IIS targets, a nearby Windows agent reaches the IIS box via WinRM.
|
||||
|
||||
The agent handles both the certificate (public) and the private key (read from local key store at `CERTCTL_KEY_DIR`). The control plane never sees the private key.
|
||||
The agent handles both the certificate (public) and the private key (read from local key store at `CERTCTL_KEY_DIR`). The control plane never sees the private key and never initiates outbound connections to agents or targets (pull-only model).
|
||||
|
||||
### 3.5 Revoke a Certificate
|
||||
|
||||
When a certificate needs immediate revocation (key compromise, decommission, etc.), the control plane executes a 7-step process:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User / API Client
|
||||
participant API as REST API
|
||||
participant SVC as CertificateService
|
||||
participant DB as PostgreSQL
|
||||
participant ISS as Issuer Connector
|
||||
participant NOT as Notification Service
|
||||
|
||||
U->>API: POST /api/v1/certificates/{id}/revoke<br/>{reason: "keyCompromise"}
|
||||
API->>SVC: RevokeCertificateWithActor(id, reason, actor)
|
||||
SVC->>DB: Validate cert is not already revoked/archived
|
||||
SVC->>DB: Get latest certificate version (serial number)
|
||||
SVC->>DB: UPDATE managed_certificates SET status='Revoked'
|
||||
SVC->>DB: INSERT INTO certificate_revocations<br/>(ON CONFLICT DO NOTHING for idempotency)
|
||||
SVC->>ISS: RevokeCertificate(serial, reason)<br/>(best-effort — failure doesn't block)
|
||||
SVC->>DB: INSERT audit_event (certificate_revoked)
|
||||
SVC->>NOT: SendRevocationNotification(cert, reason)
|
||||
SVC-->>API: Updated certificate with Revoked status
|
||||
API-->>U: 200 OK
|
||||
```
|
||||
|
||||
The revocation is recorded in the `certificate_revocations` table (separate from the certificate status update) for CRL generation. The DER-encoded CRL at `GET /api/v1/crl/{issuer_id}` is generated on-demand by querying this table and signing with the issuing CA's key. The OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` checks both the certificate status and the revocations table to return signed good/revoked/unknown responses.
|
||||
|
||||
Short-lived certificates (those with profile TTL < 1 hour) return "good" from OCSP and are excluded from CRL — their rapid expiry is treated as sufficient revocation.
|
||||
|
||||
### 4. Automatic Renewal
|
||||
|
||||
The control plane runs a scheduler with four background loops:
|
||||
The control plane runs a scheduler with six background loops:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -318,12 +383,16 @@ flowchart LR
|
||||
J["Job Processor\n⏱ every 30s"]
|
||||
H["Agent Health\n⏱ every 2m"]
|
||||
N["Notification Processor\n⏱ every 1m"]
|
||||
SL["Short-Lived Expiry\n⏱ every 30s"]
|
||||
NS["Network Scanner\n⏱ every 6h"]
|
||||
end
|
||||
|
||||
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
|
||||
J -->|"Process pending jobs\nCoordinate issuance"| DB
|
||||
H -->|"Check heartbeat staleness\nMark agents offline"| DB
|
||||
N -->|"Send pending notifications\nEmail / Webhook"| DB
|
||||
N -->|"Send pending notifications\nEmail / Webhook / Slack"| DB
|
||||
SL -->|"Expire short-lived certs\nMark as Expired"| DB
|
||||
NS -->|"Probe TLS endpoints\nStore discovered certs"| DB
|
||||
```
|
||||
|
||||
| Loop | Interval | Timeout | Purpose |
|
||||
@@ -332,6 +401,8 @@ flowchart LR
|
||||
| Job processor | 30 seconds | 2 minutes | Processes pending jobs (issuance, renewal, deployment) |
|
||||
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
|
||||
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
|
||||
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
||||
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`) |
|
||||
|
||||
Each operation has a context timeout to prevent indefinite hangs if external services become unresponsive.
|
||||
|
||||
@@ -352,9 +423,8 @@ flowchart TB
|
||||
II["IssuerConnector Interface\nIssueCertificate() | RenewCertificate()\nRevokeCertificate() | GetOrderStatus()"]
|
||||
II --> LC["Local CA"]
|
||||
II --> ACME["ACME v2"]
|
||||
II --> SC["step-ca (planned)"]
|
||||
II --> OC["OpenSSL / Custom CA (planned)"]
|
||||
II --> AD["ADCS (planned)"]
|
||||
II --> SC["step-ca"]
|
||||
II --> OC["OpenSSL / Custom CA"]
|
||||
II --> VP["Vault PKI (planned)"]
|
||||
end
|
||||
|
||||
@@ -362,6 +432,8 @@ flowchart TB
|
||||
direction TB
|
||||
TI["TargetConnector Interface\nDeployCertificate()\nValidateDeployment()"]
|
||||
TI --> NG["NGINX"]
|
||||
TI --> AP["Apache httpd"]
|
||||
TI --> HP["HAProxy"]
|
||||
TI --> F5["F5 BIG-IP (interface only)"]
|
||||
TI --> IIS["IIS (interface only)"]
|
||||
end
|
||||
@@ -371,7 +443,10 @@ flowchart TB
|
||||
NI["NotifierConnector Interface\nSendAlert() | SendEvent()"]
|
||||
NI --> EM["Email (SMTP)"]
|
||||
NI --> WH["Webhook (HTTP)"]
|
||||
NI --> SL["Slack (future)"]
|
||||
NI --> SL["Slack"]
|
||||
NI --> TM["Microsoft Teams"]
|
||||
NI --> PD["PagerDuty"]
|
||||
NI --> OG["OpsGenie"]
|
||||
end
|
||||
```
|
||||
|
||||
@@ -409,7 +484,7 @@ type Connector interface {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in issuers: **Local CA** (self-signed, in-memory CA for development/demos using `crypto/x509`) and **ACME v2** (fully implemented with HTTP-01 challenge solving, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, HTTP-01 challenge solving via a built-in temporary HTTP server, order finalization, and DER-to-PEM chain conversion. Configure via `CERTCTL_ACME_DIRECTORY_URL` and `CERTCTL_ACME_EMAIL`.
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01 and DNS-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), and **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks), order finalization, and DER-to-PEM chain conversion.
|
||||
|
||||
### Target Connector
|
||||
|
||||
@@ -425,9 +500,9 @@ type Connector interface {
|
||||
|
||||
The `DeploymentRequest` struct carries the full material needed by the target system: the signed certificate, the CA chain, the agent-generated private key, target-specific configuration, and arbitrary metadata. The key field is populated by the agent from its local key store (`CERTCTL_KEY_DIR`) — it never originates from the control plane.
|
||||
|
||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **F5 BIG-IP** (interface only — iControl REST flow mapped, implementation planned), **IIS** (interface only — WinRM/PowerShell flow mapped, implementation planned).
|
||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **F5 BIG-IP** (interface only — proxy agent + iControl REST, implementation planned), **IIS** (interface only — dual-mode: agent-local PowerShell primary + proxy agent WinRM for agentless targets, implementation planned).
|
||||
|
||||
**Planned targets (V2):** Apache httpd (file write, `apachectl configtest`, graceful reload), HAProxy (combined PEM file write, reload via socket/signal). **Planned targets (V3):** AWS ALB/CloudFront, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets.
|
||||
Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
||||
|
||||
### Notifier Connector
|
||||
|
||||
@@ -441,7 +516,7 @@ type Connector interface {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in notifiers: **Email** (SMTP) and **Webhook** (HTTP POST).
|
||||
Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incoming webhook), **Microsoft Teams** (MessageCard), **PagerDuty** (Events API v2), and **OpsGenie** (Alert API v2). Each is enabled by setting its configuration environment variable.
|
||||
|
||||
See the [Connector Development Guide](connectors.md) for details on building custom connectors.
|
||||
|
||||
@@ -489,7 +564,7 @@ The control plane only handles public material: certificates, chains, and CSRs.
|
||||
- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode
|
||||
- **Agent → Server**: API key registered at agent creation, included in all requests
|
||||
- **Server → Issuers**: ACME account key, or connector-specific credentials
|
||||
- **Agent → Targets**: SSH keys, API tokens, WinRM credentials (stored locally on agent)
|
||||
- **Agent → Targets**: API tokens, WinRM credentials (stored locally on agent or proxy agent — never on server). Credential scope is limited to the agent's network zone.
|
||||
|
||||
### Audit Trail
|
||||
|
||||
@@ -510,6 +585,12 @@ Every action is recorded as an immutable audit event:
|
||||
|
||||
Audit events cannot be modified or deleted. They support filtering by actor, action, resource type, resource ID, and time range. All audit operations are logged via structured `slog` logging; if an audit event fails to persist, the error is logged immediately to ensure no gaps in the audit trail go unnoticed.
|
||||
|
||||
### API Audit Log
|
||||
|
||||
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, path, actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
|
||||
|
||||
Audit recording is async (via goroutine) so it never blocks the HTTP response. If audit persistence fails, the error is logged immediately — the API call still succeeds. The middleware sits after the auth middleware in the stack so the actor identity is available from context.
|
||||
|
||||
### Logging
|
||||
|
||||
All logging throughout the service layer uses Go's `log/slog` package for structured, queryable logs. This replaces ad-hoc `fmt.Printf` statements with consistent key-value logging that includes request context, operation names, and error details. Agents also implement exponential backoff on network failures to gracefully handle temporary connectivity issues with the control plane.
|
||||
@@ -525,10 +606,58 @@ All endpoints are under `/api/v1/` and follow consistent patterns:
|
||||
- **Delete**: `DELETE /api/v1/{resources}/{id}` — returns `204` (soft delete/archive)
|
||||
- **Actions**: `POST /api/v1/{resources}/{id}/{action}` — returns `202` for async operations
|
||||
|
||||
Resources: certificates, issuers, targets, agents, jobs, policies, teams, owners, audit, notifications.
|
||||
Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications.
|
||||
|
||||
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 91 endpoints across 19 resource domains (including health, readiness, auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, and Prometheus metrics from M22), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
|
||||
|
||||
Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`.
|
||||
|
||||
**Enhanced Query Features (M20):** Certificate list endpoints support additional query capabilities beyond basic pagination:
|
||||
|
||||
- **Sorting**: `?sort=notAfter` (ascending) or `?sort=-createdAt` (descending). Whitelist: notAfter, expiresAt, createdAt, updatedAt, commonName, name, status, environment.
|
||||
- **Time-range filters**: `?expires_before=`, `?expires_after=`, `?created_after=`, `?updated_after=` (RFC 3339 format).
|
||||
- **Cursor pagination**: `?cursor=<token>&page_size=100` for efficient keyset pagination alongside traditional page-based.
|
||||
- **Sparse fields**: `?fields=id,common_name,status` to reduce response payload.
|
||||
- **Additional filters**: `?agent_id=`, `?profile_id=` (in addition to existing status, environment, owner_id, team_id, issuer_id).
|
||||
- **Deployments**: `GET /api/v1/certificates/{id}/deployments` returns deployment targets for a certificate.
|
||||
|
||||
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. A JSON-formatted CRL is available at `GET /api/v1/crl`, and a DER-encoded X.509 CRL signed by the issuing CA at `GET /api/v1/crl/{issuer_id}`. An embedded OCSP responder serves signed responses at `GET /api/v1/ocsp/{issuer_id}/{serial}`. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation.
|
||||
|
||||
Health checks live outside the API prefix: `GET /health` and `GET /ready`.
|
||||
|
||||
## MCP Server
|
||||
|
||||
certctl includes an MCP (Model Context Protocol) server as a separate binary (`cmd/mcp-server/`) that enables AI assistants to interact with the certificate platform. The MCP server uses the official MCP Go SDK (`modelcontextprotocol/go-sdk`) with stdio transport for integration with Claude, Cursor, and other MCP-compatible tools.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AI["AI Assistant\n(Claude, Cursor)"] -->|"stdio"| MCP["MCP Server\ncmd/mcp-server/"]
|
||||
MCP -->|"HTTP + Bearer token"| API["certctl REST API\n:8443"]
|
||||
|
||||
subgraph "78 MCP Tools"
|
||||
T1["Certificate CRUD"]
|
||||
T2["Agent Management"]
|
||||
T3["Job Operations"]
|
||||
T4["Policy/Profile Queries"]
|
||||
T5["Audit Trail Access"]
|
||||
T6["Stats & Metrics"]
|
||||
end
|
||||
|
||||
MCP --> T1 & T2 & T3 & T4 & T5 & T6
|
||||
```
|
||||
|
||||
The MCP server is a stateless HTTP proxy — every MCP tool call translates to an HTTP request to the certctl REST API. It adds no new state, no new dependencies, and no new attack surface beyond what the API already exposes. Configuration is minimal: `CERTCTL_SERVER_URL` and `CERTCTL_API_KEY` environment variables.
|
||||
|
||||
The 78 tools are organized across 16 resource domains with typed input structs and `jsonschema` struct tags for automatic LLM-friendly schema generation. Binary response support handles DER CRL and OCSP endpoints.
|
||||
|
||||
## CLI Tool
|
||||
|
||||
certctl ships with a command-line tool (`certctl-cli`, built from `cmd/cli/main.go`) that wraps the REST API for terminal workflows. The CLI uses Go's standard library only (`flag` + `text/tabwriter`) — no Cobra or other framework dependencies.
|
||||
|
||||
10 subcommands: `list-certs`, `get-cert`, `renew-cert`, `revoke-cert`, `list-agents`, `list-jobs`, `health`, `metrics`, and `import` (bulk PEM import). Output is available in table (default) or JSON format via `--format`. Connection is configured via `CERTCTL_SERVER_URL` and `CERTCTL_API_KEY` environment variables or CLI flags.
|
||||
|
||||
The bulk import command (`certctl-cli import <file.pem>`) parses multi-certificate PEM files and creates certificate records via the API — useful for bootstrapping certctl with existing certificate inventory.
|
||||
|
||||
## Deployment Topologies
|
||||
|
||||
### Docker Compose (Development / Small Deployments)
|
||||
@@ -577,24 +706,96 @@ flowchart TB
|
||||
|
||||
For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.).
|
||||
|
||||
## Discovery Data Flow (M18b + M21)
|
||||
|
||||
Certificate discovery enables operators to build a complete inventory of existing certificates before managing them with certctl. There are two discovery modes that feed into the same pipeline:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "Discovery Sources"
|
||||
AGENT["certctl-agent\n(filesystem discovery)"]
|
||||
SCAN["Filesystem Scanner\n(CERTCTL_DISCOVERY_DIRS)"]
|
||||
SERVER["certctl-server\n(network discovery)"]
|
||||
NETSCAN["TLS Scanner\n(CIDR ranges + ports)"]
|
||||
end
|
||||
|
||||
EXTRACT["Extract Metadata\n(CN, SANs, serial, issuer, expiry, fingerprint)"]
|
||||
SERVICE["Discovery Service\n(ProcessDiscoveryReport)"]
|
||||
REPO["Discovery Repository\n(upsert with fingerprint dedup)"]
|
||||
DB["PostgreSQL\ndiscovered_certificates\ndiscovery_scans tables"]
|
||||
AUDIT["Audit Service\n(RecordDiscoveryScanCompleted)"]
|
||||
API_LIST["GET /api/v1/discovered-certificates\n(list for triage)"]
|
||||
API_CLAIM["POST /discovered-certificates/{id}/claim"]
|
||||
API_DISMISS["POST /discovered-certificates/{id}/dismiss"]
|
||||
|
||||
AGENT -->|"Scan loop\n(startup + 6h)"| SCAN
|
||||
SCAN --> EXTRACT
|
||||
SERVER -->|"Scheduler loop\n(every 6h)"| NETSCAN
|
||||
NETSCAN -->|"crypto/tls.Dial\n50 goroutines"| EXTRACT
|
||||
EXTRACT --> SERVICE
|
||||
SERVICE --> REPO
|
||||
REPO -->|"Dedup by fingerprint\n+ agent_id + source_path"| DB
|
||||
SERVICE --> AUDIT
|
||||
AUDIT --> DB
|
||||
DB --> API_LIST
|
||||
API_LIST --> API_CLAIM
|
||||
API_LIST --> API_DISMISS
|
||||
```
|
||||
|
||||
**Filesystem Discovery (M18b):**
|
||||
|
||||
1. **Agent-side discovery** — Agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, walking directories recursively and parsing PEM/DER files
|
||||
2. **Metadata extraction** — For each certificate found, extract: common name, SANs, serial number, issuer DN, subject DN, expiration date, key algorithm, key size, is_ca flag, SHA-256 fingerprint (used as dedup key)
|
||||
3. **Server submission** — Agent POSTs scan results as `DiscoveryReport` to `POST /api/v1/agents/{id}/discoveries`
|
||||
4. **Deduplication** — Server uses fingerprint + agent ID + filesystem path as unique key; prevents duplicate records of the same cert on the same agent
|
||||
|
||||
**Network Discovery (M21):**
|
||||
|
||||
1. **Target configuration** — Operator creates network scan targets via `POST /api/v1/network-scan-targets` with CIDR ranges, ports, and scan interval
|
||||
2. **CIDR expansion** — Ranges expanded to individual IPs with /20 safety cap (4096 IPs max)
|
||||
3. **TLS probing** — Server uses `crypto/tls.DialWithDialer` with `InsecureSkipVerify=true` to connect to each endpoint; 50 concurrent goroutines with configurable timeout
|
||||
4. **Certificate extraction** — Full X.509 metadata extracted from TLS handshake peer certificates
|
||||
5. **Sentinel agent** — Results submitted using `server-scanner` as virtual agent ID, with `source_path` set to `ip:port` and `source_format` set to `network`
|
||||
6. **Same pipeline** — Feeds into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery — same dedup, same audit trail, same triage workflow
|
||||
|
||||
**Common triage workflow (both sources):**
|
||||
|
||||
1. **Storage** — Records stored in `discovered_certificates` table with status = "Unmanaged"
|
||||
2. **Audit** — `discovery_scan_completed` event logged with agent ID, cert count, scan timestamp
|
||||
3. **Operator triage** — Operator queries `GET /api/v1/discovered-certificates?status=Unmanaged` to see new findings
|
||||
4. **Claim or dismiss** — For each unmanaged cert, operator either:
|
||||
- **Claims it** via `POST /discovered-certificates/{id}/claim` — links to existing managed cert or creates new enrollment
|
||||
- **Dismisses it** via `POST /discovered-certificates/{id}/dismiss` — removes from triage, marked as "Dismissed"
|
||||
9. **Status tracking** — `discovery_cert_claimed` and `discovery_cert_dismissed` events audit the operator's decision
|
||||
10. **Summary** — `GET /api/v1/discovery-summary` returns count of Unmanaged, Managed, and Dismissed certs (useful for compliance reporting)
|
||||
|
||||
This data flow is pull-based and non-blocking. Agents discover at their own pace; the server stores results for later review. There's no pressure to claim or dismiss; operators can leave certificates in "Unmanaged" status indefinitely.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 220+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
|
||||
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 900+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
|
||||
|
||||
**Service layer unit tests** (`internal/service/*_test.go`) — 74 test functions across 7 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), and notification deduplication (threshold tag matching, channel routing). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
|
||||
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
|
||||
|
||||
**Handler layer tests** (`internal/api/handler/*_test.go`) — 127 test functions across 7 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (22 tests), agents (28 tests), jobs (13 tests), notifications (11 tests), policies (19 tests), issuers (17 tests), and targets (17 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs), error propagation from the service layer, method-not-allowed responses, and pagination parameters.
|
||||
**Handler layer tests** (`internal/api/handler/*_test.go`) — ~257 test functions across 11 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (50 tests including revocation, DER CRL, and OCSP), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), agent groups (12 tests), teams (26 tests), and owners (21 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs, name length limits), error propagation from the service layer, method-not-allowed responses, and pagination parameters.
|
||||
|
||||
**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths: nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, and expired certificate lifecycle. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector.
|
||||
**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths, 19 M11b endpoint tests, and 8 revocation endpoint tests (M15a+M15b): nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, expired certificate lifecycle, team/owner/agent group CRUD validation, revocation success, already-revoked rejection, not-found revocation, JSON CRL retrieval, DER CRL retrieval, OCSP response retrieval, and short-lived cert exemption. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector. A third file, `e2e_test.go`, contains 8 cross-milestone test functions with 48+ subtests that exercise features across milestones end-to-end: M10 agent metadata via heartbeat, M11 profiles/teams/owners/agent-groups CRUD, M12 issuer registry verification, M13 GUI operation endpoints, M14 stats and metrics, M15 revocation and CRL, M16 notification channels, and M20 enhanced query API (sorting, cursor pagination, sparse fields, time-range filters).
|
||||
|
||||
**Frontend tests** (`web/src/api/client.test.ts`, `web/src/api/utils.test.ts`) — 53 Vitest tests covering the API client and utility functions. The API client tests mock `globalThis.fetch` and verify all endpoint functions (certificates, agents, jobs, policies, issuers, targets, notifications, audit, health) send correct HTTP methods, URLs, headers, and request bodies. They also test API key management (store/retrieve/clear), auth header propagation, 401 event dispatching, and error handling (server messages, error fields, status text fallback). The utility tests use `vi.useFakeTimers()` for deterministic date testing and cover `formatDate`, `formatDateTime`, `timeAgo`, `daysUntil`, and `expiryColor`. The test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
||||
**Frontend tests** (`web/src/api/client.test.ts`, `web/src/api/utils.test.ts`) — 86 Vitest tests covering the API client, stats/metrics endpoints, and utility functions. The API client tests mock `globalThis.fetch` and verify all endpoint functions (certificates, agents, jobs, policies, issuers, targets, notifications, audit, stats, metrics, health) send correct HTTP methods, URLs, headers, and request bodies. They also test API key management (store/retrieve/clear), auth header propagation, 401 event dispatching, and error handling (server messages, error fields, status text fallback). The stats/metrics endpoint tests verify correct query parameter handling and response shape validation. The utility tests use `vi.useFakeTimers()` for deterministic date testing and cover `formatDate`, `formatDateTime`, `timeAgo`, `daysUntil`, and `expiryColor`. The test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
||||
|
||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~34%) and handler layer must be at least 50% (current: ~61%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/local/...` (the Local CA package, which has unit tests for certificate signing logic). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
||||
**CLI tests** (`internal/cli/client_test.go`) — 14 tests covering all 10 CLI subcommands with httptest mock servers, PEM parsing for bulk import, auth header verification, and JSON/table output formatting.
|
||||
|
||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors (NGINX, F5, IIS) depend on real infrastructure or complex mocks. Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, and HAProxy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
||||
|
||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 6 tests for script-based DNS-01 challenges. The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
||||
|
||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for a future release). Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
||||
|
||||
## What's Next
|
||||
|
||||
- [Quick Start](quickstart.md) — Get certctl running locally
|
||||
- [Advanced Demo](demo-advanced.md) — Issue a certificate end-to-end
|
||||
- [Connector Guide](connectors.md) — Build custom connectors
|
||||
- [Compliance Mapping](compliance.md) — SOC 2, PCI-DSS 4.0, and NIST SP 800-57 alignment
|
||||
- [MCP Server Guide](mcp.md) — AI-native access to the API
|
||||
- [OpenAPI Spec](openapi.md) — Full API reference and SDK generation
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# NIST SP 800-57 Key Management Alignment
|
||||
|
||||
NIST SP 800-57 Part 1 Rev 5 (May 2020) is the authoritative US government guidance on cryptographic key management. This document maps certctl's implementation to its recommendations. certctl follows NIST guidance where applicable; this guide documents the alignment and identifies gaps for future roadmap planning.
|
||||
|
||||
## Key Generation (Section 6.1)
|
||||
|
||||
certctl generates certificate keys on agent infrastructure using Go's `crypto/rand` for entropy, backed by `/dev/urandom` on Linux and `CryptGenRandom` on Windows. Key generation happens as follows:
|
||||
|
||||
**Agent-Side Key Generation (Production Default)**
|
||||
- Agents generate ECDSA P-256 key pairs per certificate using `crypto/ecdsa` + `crypto/elliptic` (Go stdlib)
|
||||
- Key generation triggered by `AwaitingCSR` job state in renewal/issuance workflows
|
||||
- Agent creates Certificate Signing Request (CSR) with `x509.CreateCertificateRequest`, signed with the agent's private key
|
||||
- Only the CSR crosses the network to the control plane; private key material never leaves the agent
|
||||
- Configuration: `CERTCTL_KEYGEN_MODE=agent` (default, production)
|
||||
|
||||
**Server-Side Key Generation (Demo Only)**
|
||||
- Available for development and testing via `CERTCTL_KEYGEN_MODE=server`
|
||||
- Explicitly logged as a warning at startup: "server-side keygen enabled (production deployments must use agent mode)"
|
||||
- Docker Compose demo uses server mode for backward compatibility
|
||||
- Not recommended for production; agent mode is the secure default
|
||||
|
||||
**Entropy Source**
|
||||
- `crypto/rand` provides cryptographically secure random bytes
|
||||
- On Linux: backed by `/dev/urandom` via `getrandom()` syscall
|
||||
- On Windows: backed by `CryptGenRandom()` (now `BCryptGenRandom()`)
|
||||
- Meets NIST SP 800-90B requirements for entropy generation
|
||||
|
||||
## Key Storage and Protection (Sections 6.3, 6.4)
|
||||
|
||||
certctl implements tiered key storage with different protection profiles based on key purpose.
|
||||
|
||||
**Agent Private Keys**
|
||||
- Stored on agent filesystem at `CERTCTL_KEY_DIR` (default: `/var/lib/certctl/keys`)
|
||||
- File permissions: 0600 (read/write by agent process only, no world/group access)
|
||||
- One PEM file per certificate, organized by certificate ID
|
||||
- Accessible only to the agent process; isolated from other processes
|
||||
- For container deployments: use Docker volumes with restricted permissions (`-v /var/lib/certctl/keys:0600`)
|
||||
|
||||
**Issuing CA Keys (Local CA Connector)**
|
||||
- Loaded from disk at server startup via `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` env vars
|
||||
- Supports RSA (PKCS#1, PKCS#8) and ECDSA (SEC1, PKCS#8) key formats
|
||||
- Validates certificate constraints before use:
|
||||
- `IsCA=true` flag present
|
||||
- `KeyUsageCertSign` extension set
|
||||
- Valid certificate chain (for sub-CA mode)
|
||||
- Keys held in memory during server runtime (no on-disk caching after load)
|
||||
- Cleared from memory only on server shutdown
|
||||
|
||||
**Sub-CA Mode (Enterprise Integration)**
|
||||
- CA certificate and key signed by upstream enterprise root (e.g., Active Directory Certificate Services)
|
||||
- Certctl acts as subordinate CA, inheriting issuer DN from upstream CA
|
||||
- All issued certificates chain to enterprise trust anchor
|
||||
- CA key protection inherits upstream root's key management practices
|
||||
- Configured via: `CERTCTL_CA_CERT_PATH=/path/to/ca.crt` and `CERTCTL_CA_KEY_PATH=/path/to/ca.key`
|
||||
|
||||
**NIST Gap: HSM Storage**
|
||||
NIST SP 800-57 Part 1 recommends Hardware Security Module (HSM) storage for high-value keys (CA signing keys). certctl V2 uses filesystem storage on the server. HSM support is planned for V5 roadmap, enabling integration with:
|
||||
- AWS CloudHSM
|
||||
- Azure Dedicated HSM
|
||||
- Thales Luna, Gemalto SafeNet, YubiHSM (on-premises)
|
||||
- PKCS#11-compatible devices
|
||||
|
||||
## Cryptoperiods (Section 5.3, Table 1)
|
||||
|
||||
NIST recommends cryptoperiods (key validity durations) based on key type and security requirements. certctl enforces cryptoperiods through certificate profiles and renewal policies.
|
||||
|
||||
**Certificate Profile Enforcement**
|
||||
- Certificate profiles (M11a) define `max_ttl` constraint per enrollment profile
|
||||
- All certificates issued through a profile cannot exceed the profile's max_ttl
|
||||
- Profile configuration example:
|
||||
```json
|
||||
{
|
||||
"id": "prof-web-prod",
|
||||
"name": "Production Web Certs",
|
||||
"max_ttl_seconds": 31536000, // 1 year max
|
||||
"allowed_key_algorithms": ["ECDSA_P256"],
|
||||
"required_sans": ["example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
**Renewal Thresholds**
|
||||
- Renewal policies with configurable `alert_thresholds_days`: `[30, 14, 7, 0]` (days before expiry)
|
||||
- Background scheduler checks renewal eligibility every 1 hour
|
||||
- Certificates transitioned to `Expiring` status at 30 days, `Expired` at 0 days
|
||||
- Renewal workflow can be triggered manually or automatically
|
||||
|
||||
**NIST Cryptoperiod Recommendations vs certctl Implementation**
|
||||
|
||||
| Key Type | NIST Recommendation | certctl Implementation |
|
||||
|----------|---------------------|------------------------|
|
||||
| CA signing key | 3–10 years | Configured via CA certificate not-after date; inheritable from upstream CA in sub-CA mode |
|
||||
| End-entity web server cert | 1–3 years (trending shorter) | Profile `max_ttl` configurable; ACME issuer typically 90 days; SC-081v3 mandating 47 days by 2029 |
|
||||
| Code signing cert | 2–8 years | Profile enforcement via `max_ttl`; not primary certctl use case |
|
||||
| Short-lived credentials | < 1 hour recommended | Profile TTL < 1 hour; exempt from CRL/OCSP (expiry is sufficient revocation); auto-expiry on scheduler tick |
|
||||
| OCSP signing key | 1–2 years | Embedded OCSP responder uses issuing CA key (same period as issuer) or delegated signing cert |
|
||||
| TLS/SSL interoperability cert | 1–2 years | Trending 1 year or less; certctl's ACME/sub-CA/step-ca issuers all support short periods |
|
||||
|
||||
## Key States and Transitions (Section 5.2)
|
||||
|
||||
NIST defines lifecycle states for keys: pre-activation, active, suspended, deactivated, compromised, and destroyed. certctl maps these to certificate and job states:
|
||||
|
||||
| NIST Key State | certctl Equivalent | Implementation |
|
||||
|---|---|---|
|
||||
| **Pre-activation** | `Pending` job state / `AwaitingCSR` | Job created but key not yet generated; awaiting agent CSR submission (agent-mode) or server keygen (demo mode) |
|
||||
| **Active** | Certificate status `Active` | Cert deployed to targets and in use; within validity period (not before < now < not after) |
|
||||
| **Suspended** | Job state `AwaitingApproval` | Interactive approval holds deployment job pending human review; resumes on approval or cancels on rejection |
|
||||
| **Deactivated** | Certificate status `Expired` | Past not-after date; auto-transitioned by scheduler every 2 minutes; renewal eligible |
|
||||
| **Compromised** | Certificate status `Revoked` | Issued via `POST /api/v1/certificates/{id}/revoke` with RFC 5280 revocation reason |
|
||||
| **Destroyed** | Archived (implementation detail) | Operator responsibility; certctl retains all certs in audit trail for compliance; no destructive deletion API |
|
||||
|
||||
**State Transition Audit Trail**
|
||||
All transitions logged to immutable `audit_events` table with:
|
||||
- Event type (e.g., `certificate_revoked`, `renewal_job_completed`)
|
||||
- Actor (authenticated user or agent ID)
|
||||
- Timestamp (RFC3339)
|
||||
- Resource (certificate ID)
|
||||
- Reason (revocation reason code, approval reason, etc.)
|
||||
- HTTP method, path, status (for API calls)
|
||||
|
||||
Example audit entry for revocation:
|
||||
```json
|
||||
{
|
||||
"id": "ae-2024-0615",
|
||||
"event_type": "certificate_revoked",
|
||||
"actor": "ops-alice@example.com",
|
||||
"timestamp": "2024-06-15T14:23:00Z",
|
||||
"resource_id": "cert-web-prod-2024",
|
||||
"resource_type": "certificate",
|
||||
"description": "Revoked: reason=keyCompromise",
|
||||
"body_hash": "sha256:a1b2c3d..."
|
||||
}
|
||||
```
|
||||
|
||||
## Algorithm Recommendations (Section 5.1, SP 800-131A)
|
||||
|
||||
NIST SP 800-131A Rev 2 (January 2024) categorizes cryptographic algorithms as Approved, Conditionally Approved, or Disallowed. certctl implements only NIST-approved algorithms:
|
||||
|
||||
| Algorithm | NIST Status | certctl Support | Notes |
|
||||
|-----------|-------------|-----------------|-------|
|
||||
| **ECDSA P-256** | Approved (128-bit security strength) | Default for agent-side keygen | Meets NIST curve requirements (FIPS 186-4) |
|
||||
| **ECDSA P-384** | Approved (192-bit security strength) | Supported via profile configuration | Higher security margin; slower than P-256 |
|
||||
| **ECDSA P-521** | Approved (256-bit security strength) | Supported via profile configuration | Rarely needed; overkill for TLS |
|
||||
| **RSA 2048** | Approved minimum (112-bit security, transitioning) | Supported via all issuers | Deprecated path; migrate to 3072+ by 2030 per NIST |
|
||||
| **RSA 3072** | Approved (128-bit security) | Supported via all issuers | Recommended minimum for long-term security |
|
||||
| **RSA 4096** | Approved (192-bit security) | Supported via all issuers | Supported but slower; overkill for most TLS |
|
||||
| **SHA-256** | Approved | Used throughout | CSR signing, certificate fingerprints, audit body hashing, CRL/OCSP signing |
|
||||
| **SHA-384** | Approved (192-bit) | Supported where algorithm selection available | Used in some CA signing scenarios |
|
||||
| **SHA-512** | Approved (256-bit) | Supported where algorithm selection available | Rarely needed; SHA-256 suffices for most use cases |
|
||||
| **SHA-1** | Deprecated | Not used in certctl | Browsers reject SHA-1 certs; certctl never generates them |
|
||||
|
||||
**Algorithm Enforcement via Profiles**
|
||||
Certificate profiles enforce allowed key algorithms:
|
||||
```json
|
||||
{
|
||||
"id": "prof-web-prod",
|
||||
"allowed_key_algorithms": ["ECDSA_P256", "ECDSA_P384", "RSA3072"]
|
||||
}
|
||||
```
|
||||
|
||||
**Post-Quantum Cryptography (Tracking)**
|
||||
NIST has finalized PQC standards (FIPS 204, FIPS 205) in August 2024:
|
||||
- **ML-KEM** (Kyber): Approved key encapsulation mechanism
|
||||
- **ML-DSA** (Dilithium): Approved digital signature algorithm
|
||||
- **SLH-DSA** (SPHINCS+): Approved stateless hash-based signature scheme
|
||||
|
||||
certctl will track NIST's PQC roadmap and plan integration when hybrid PQC+classical certificate formats reach browser/infrastructure support. Currently, pure PQC certificates are not widely interoperable.
|
||||
|
||||
## Key Distribution and Transport (Section 6.2)
|
||||
|
||||
NIST SP 800-57 Part 1 Section 6.2 addresses secure key distribution to minimize exposure during transit. certctl implements a zero-transmission-of-private-keys model:
|
||||
|
||||
**Private Key Distribution**
|
||||
- Agent-side keygen model: Private keys never leave agent infrastructure
|
||||
- CSR transmitted over HTTPS (TLS 1.2+) with mutual TLS optional
|
||||
- API key authentication via `Authorization: Bearer <api-key>` header
|
||||
- All API calls logged to immutable audit trail
|
||||
|
||||
**Signed Certificate Distribution**
|
||||
- Certificates (public component) distributed via `GET /agents/{id}/work` over HTTPS
|
||||
- Work endpoint enriches deployment jobs with certificate PEM and metadata
|
||||
- Certificate PEM is idempotent (same cert always returns same bytes)
|
||||
|
||||
**Target Deployment**
|
||||
- Deployment to targets via local filesystem write (NGINX, Apache, HAProxy)
|
||||
- No network transmission of private keys to targets
|
||||
- Agents read local private key from `CERTCTL_KEY_DIR` on deployment
|
||||
- For appliances without agents (F5 BIG-IP, IIS), proxy agent pattern:
|
||||
- Proxy agent runs in same trust zone as appliance
|
||||
- Proxy agent holds target API credentials (iControl, WinRM)
|
||||
- Control plane never communicates with appliance directly
|
||||
- Deployment request includes certificate and proxy agent ID
|
||||
- Proxy agent executes deployment via appliance API
|
||||
|
||||
**Revocation Distribution**
|
||||
- Certificate Revocation List (CRL) via `GET /api/v1/crl/{issuer_id}`
|
||||
- Returns DER-encoded X.509 CRL signed by issuing CA
|
||||
- 24-hour validity period
|
||||
- Includes all revoked serials, reasons, and revocation timestamps
|
||||
- Subject to URL caching; OCSP preferred for real-time revocation
|
||||
- OCSP via `GET /api/v1/ocsp/{issuer_id}/{serial}`
|
||||
- Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure)
|
||||
- Signed by issuing CA (or delegated OCSP signing cert)
|
||||
- Responds with good/revoked/unknown status
|
||||
- Real-time, more bandwidth-efficient than CRL polling
|
||||
|
||||
## Revocation and Compromise (NIST SP 800-57 Part 3)
|
||||
|
||||
NIST SP 800-57 Part 3 covers revocation (Section 2.5) when keys are suspected compromised or no longer needed. certctl implements comprehensive revocation infrastructure:
|
||||
|
||||
**Revocation API**
|
||||
- Endpoint: `POST /api/v1/certificates/{id}/revoke`
|
||||
- Request body:
|
||||
```json
|
||||
{
|
||||
"reason": "keyCompromise",
|
||||
"reason_text": "Private key exposed in log file"
|
||||
}
|
||||
```
|
||||
- Supports all 8 RFC 5280 revocation reason codes:
|
||||
- `unspecified` — no specific reason provided
|
||||
- `keyCompromise` — private key suspected compromised
|
||||
- `caCompromise` — issuing CA key compromised
|
||||
- `affiliationChanged` — subject org/affiliation changed
|
||||
- `superseded` — cert superseded by newer cert
|
||||
- `cessationOfOperation` — key no longer in use
|
||||
- `certificateHold` — temporary hold (rarely used)
|
||||
- `privilegeWithdrawn` — subject authorization withdrawn
|
||||
|
||||
**Revocation Recording**
|
||||
- Certificate status updated to `Revoked`
|
||||
- Entry recorded in `certificate_revocations` table with:
|
||||
- Certificate serial number
|
||||
- Revocation timestamp
|
||||
- Revocation reason code
|
||||
- Issuer ID
|
||||
- Idempotent (revoking an already-revoked cert is safe; returns 200 OK)
|
||||
|
||||
**Issuer Notification (Best-Effort)**
|
||||
- Control plane calls `issuer.RevokeCertificate(ctx, serial, reason)` on issuing connector
|
||||
- Failure does not block the revocation (async, logged, retried)
|
||||
- Supported issuers:
|
||||
- Local CA: generates new CRL immediately
|
||||
- ACME: submits revocation to ACME server (RFC 8555 Section 7.6)
|
||||
- step-ca: calls `/revoke` API
|
||||
- OpenSSL: executes user-provided revocation script
|
||||
|
||||
**Revocation Notifications**
|
||||
- Notifiers triggered after revocation recorded: Slack, Teams, PagerDuty, OpsGenie, email, webhook
|
||||
- Message includes certificate common name, issuer, reason, actor, timestamp
|
||||
- Delivery is asynchronous and retried on failure
|
||||
|
||||
**CRL and OCSP Distribution**
|
||||
- CRL updated on every revocation (or scheduled refresh for non-issued revocations)
|
||||
- OCSP responder queries revocation table in real-time
|
||||
- Short-lived certificate exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
|
||||
|
||||
**Revocation Audit Trail**
|
||||
All revocation events logged:
|
||||
- Event type: `certificate_revoked`
|
||||
- Actor: authenticated user or service
|
||||
- Reason code: RFC 5280 enum
|
||||
- Timestamp: RFC3339
|
||||
- Issuer notification status: success or error reason
|
||||
|
||||
## Alignment Summary Table
|
||||
|
||||
| NIST SP 800-57 Area | Status | Coverage | Notes |
|
||||
|---|---|---|---|
|
||||
| **Key Generation** | ✅ Aligned | 100% | Agent-side ECDSA P-256 using crypto/rand; server mode flagged as demo-only |
|
||||
| **Key Storage** | ⚠️ Partially Aligned | 80% | Filesystem with 0600 perms; HSM support planned V5 |
|
||||
| **Cryptoperiods** | ✅ Aligned | 100% | Profile-enforced max_ttl; threshold-based renewal alerting |
|
||||
| **Key States** | ✅ Aligned | 100% | Full lifecycle tracking with immutable audit trail |
|
||||
| **Algorithms** | ✅ Aligned | 100% | NIST-approved algorithms only; post-quantum tracking in progress |
|
||||
| **Key Distribution** | ✅ Aligned | 100% | Private keys never transmitted; CSR/cert over TLS; agent-local deployment |
|
||||
| **Revocation** | ✅ Aligned | 100% | CRL, OCSP, all RFC 5280 reason codes; real-time updates |
|
||||
|
||||
## Gaps and Remediation Roadmap
|
||||
|
||||
### V2 (Current)
|
||||
- [x] Agent-side key generation
|
||||
- [x] Profile-enforced cryptoperiods
|
||||
- [x] CRL and OCSP distribution
|
||||
- [x] RFC 5280 revocation support
|
||||
- [x] Immutable audit trail
|
||||
|
||||
### V3 (Planned: 2026)
|
||||
- Role-based access control (limit revocation/approval to authorized operators)
|
||||
- Bulk revocation by profile/owner/agent (fleet-level revocation policy)
|
||||
|
||||
### V5 (Planned: 2027+)
|
||||
- HSM support for CA key storage
|
||||
- PKCS#11 integration for hardware tokens
|
||||
- FIPS 140-2/3 validated crypto module (BoringCrypto build or external FIPS library)
|
||||
- Key destruction API (explicit secure erasure of agent keys)
|
||||
- Key escrow / recovery mechanism (backup encrypted private keys for disaster recovery)
|
||||
|
||||
### Post-Quantum (2027+)
|
||||
- ML-KEM and ML-DSA support when browser/TLS ecosystem supports hybrid certificates
|
||||
- Migration path documentation (how to transition existing RSA certs to PQC)
|
||||
|
||||
## References
|
||||
|
||||
- NIST SP 800-57 Part 1 Rev 5 (May 2020): https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf
|
||||
- NIST SP 800-131A Rev 2 (January 2024): https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-131Ar2.pdf
|
||||
- FIPS 186-4 (Digital Signature Standard): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-4.pdf
|
||||
- RFC 5280 (X.509 PKI Certificate and CRL Profile): https://tools.ietf.org/html/rfc5280
|
||||
- RFC 8555 (Automatic Certificate Management Environment): https://tools.ietf.org/html/rfc8555
|
||||
- NIST FIPS 204 (ML-DSA): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.204.pdf
|
||||
- NIST FIPS 205 (ML-KEM): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.205.pdf
|
||||
|
||||
## Questions or Corrections?
|
||||
|
||||
This document reflects certctl's implementation as of March 2026. For the latest code, refer to:
|
||||
- Key generation: `cmd/agent/main.go` (agent keygen) and `internal/service/renewal.go` (server keygen)
|
||||
- Key storage: `internal/config/config.go` (CERTCTL_KEY_DIR, CERTCTL_CA_CERT_PATH)
|
||||
- Revocation: `internal/service/revocation.go` and `internal/api/handler/certificates.go`
|
||||
- Audit trail: `internal/api/middleware/audit.go`
|
||||
@@ -0,0 +1,791 @@
|
||||
# PCI-DSS 4.0 Compliance Mapping
|
||||
|
||||
This guide maps certctl's existing capabilities to PCI-DSS 4.0 requirements relevant to TLS certificate and cryptographic key management. It is **not a compliance attestation** — a qualified security assessor (QSA) must evaluate your organization's complete control environment. Rather, this document helps you understand which PCI-DSS control objectives certctl supports and where operator responsibility lies.
|
||||
|
||||
Organizations subject to PCI-DSS typically need to demonstrate control over certificate issuance, renewal, rotation, revocation, and key management. Certctl automates the technical controls for certificate lifecycle; compliance depends on how you deploy, monitor, and audit it.
|
||||
|
||||
## How to Use This Guide
|
||||
|
||||
Your QSA will request evidence that your certificate and key management systems meet specific PCI-DSS 4.0 requirements. For each applicable requirement, this guide identifies:
|
||||
|
||||
1. **Which certctl features support the control** — API endpoints, database tables, background processes
|
||||
2. **What evidence you can produce** — audit logs, dashboard metrics, API queries, deployment configs
|
||||
3. **Operator responsibilities** — what you must do outside certctl (policy, monitoring, access control)
|
||||
4. **Status** — Available (v1.0 shipped), Planned (future release), or Operator Responsibility (outside scope)
|
||||
|
||||
---
|
||||
|
||||
## Requirement 4: Protect Data in Transit
|
||||
|
||||
**Objective**: Ensure strong cryptography is used to protect sensitive data during transmission.
|
||||
|
||||
### 4.2.1 — Strong Cryptography for Transmission
|
||||
|
||||
**Requirement**: Use appropriate and current cryptographic algorithms for all TLS and SSH connections protecting card data in transit.
|
||||
|
||||
**certctl Support**:
|
||||
- **Automated TLS certificate lifecycle** — Certctl issues TLS certificates to NGINX, Apache HAProxy targets via `POST /api/v1/deployments`. Certificates include RSA 2048-bit and ECDSA P-256 key types (configurable per profile, M11a).
|
||||
- **Control plane TLS enforcement** — All REST API endpoints served exclusively over HTTPS. Agent-to-server heartbeat and work polling use TLS. No plaintext protocol options.
|
||||
- **Issuer connector key negotiation** — ACME v2 (Let's Encrypt, ZeroSSL) validates issuer cryptography. Local CA enforces RSA/ECDSA constraints. step-ca integration ensures Smallstep's cryptography standards.
|
||||
- **Certificate profiles** (M11a) document allowed key types and minimum key sizes per environment (development, production, cardholder-network).
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Exported certificate inventory via `GET /api/v1/certificates` with key algorithm and size (serial JSON).
|
||||
- Issued certificate details showing RSA 2048+ or ECDSA P-256 for all deployed certificates.
|
||||
- Audit trail (`GET /api/v1/audit`) showing issuer connector selection and certificate profile assignment per certificate.
|
||||
- Target deployment logs showing TLS certificate installation on NGINX/Apache/HAProxy.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- Configure certificate profiles for your environments with approved key algorithms.
|
||||
- Audit cipher suite configuration on deployed targets (certctl deploys certs; you verify target TLS settings).
|
||||
- Periodically review `CERTCTL_KEYGEN_MODE` — must be `agent` in production (never `server`).
|
||||
- Monitor issuer connector configuration to ensure issuers meet your cryptography standards.
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
---
|
||||
|
||||
### 4.2.2 — Certificate Inventory and Validation
|
||||
|
||||
**Requirement**: Ensure all TLS/SSL certificates used for data transmission are valid, current, and meet required cryptographic standards.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Managed Certificate Inventory** — Full CRUD API (`/api/v1/certificates`) with sortable, filterable list. Fields: common name, SANs, subject, issuer, serial number, key type/size, not-before/after dates, issuer ID, profile ID, owner, team, status (Active/Expiring/Expired/Revoked).
|
||||
|
||||
- **Filesystem Certificate Discovery** (M18b) — Agents scan configured directories (`CERTCTL_DISCOVERY_DIRS` env var) for existing PEM/DER certificates every 6 hours and on startup. Control plane deduplicates by SHA-256 fingerprint. Three triage statuses: Unmanaged (not managed by certctl), Managed (linked to a managed certificate), Dismissed (operator-marked as out-of-scope).
|
||||
- API endpoints:
|
||||
- `GET /api/v1/discovered-certificates?status=Unmanaged` — find orphaned certs
|
||||
- `GET /api/v1/discovery-summary` — aggregate counts by status
|
||||
- `POST /api/v1/discovered-certificates/{id}/claim` — link to managed certificate
|
||||
- `POST /api/v1/discovered-certificates/{id}/dismiss` — mark out-of-scope
|
||||
|
||||
- **Expiration Threshold Alerting** — Renewal policies support `alert_thresholds_days` (default 30, 14, 7, 0). Background scheduler evaluates daily; certificates transition to Expiring/Expired status automatically. Notifications sent to owners via email/webhook/Slack/Teams/PagerDuty.
|
||||
|
||||
- **Certificate Status Tracking** — Four statuses: Active (deployed, not yet expired), Expiring (within threshold, awaiting renewal), Expired (past not-after date), Revoked (revoked via RFC 5280 revocation API). Dashboard charts show status distribution.
|
||||
|
||||
- **Revocation Infrastructure** (M15a, M15b):
|
||||
- CRL endpoint: `GET /api/v1/crl` (JSON format) or `GET /api/v1/crl/{issuer_id}` (DER X.509 CRL, 24h validity, signed by issuing CA)
|
||||
- OCSP responder: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns DER-encoded OCSP response: good/revoked/unknown)
|
||||
- Short-lived cert exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
|
||||
|
||||
- **Stats API** (M14) — Real-time visibility:
|
||||
- `GET /api/v1/stats/summary` — total certs, by status, by issuer
|
||||
- `GET /api/v1/stats/expiration-timeline?days=90` — expiration distribution (weekly buckets)
|
||||
- `GET /api/v1/stats/job-trends?days=30` — renewal/issuance job success rates
|
||||
- `GET /api/v1/certificates` with `?sort=-notAfter&fields=id,commonName,notAfter,status` — sparse, sorted inventory
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Discovered certificate report: `GET /api/v1/discovered-certificates` JSON export showing all certs on systems, fingerprints, and status.
|
||||
- Managed certificate inventory: `GET /api/v1/certificates` with filters (`?status=Expiring` for upcoming renewals).
|
||||
- Expiration alert configuration: policy JSON showing `alert_thresholds_days` for each environment.
|
||||
- CRL/OCSP availability proof: HTTP GET requests to `/api/v1/crl` and `/api/v1/ocsp/{issuer}/{serial}` with signed responses.
|
||||
- Audit trail for certificate creation/renewal/revocation: `GET /api/v1/audit?type=certificate_issued,certificate_renewed,certificate_revoked`.
|
||||
- Dashboard charts showing expiration timeline, renewal success trends, status distribution.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- Configure `CERTCTL_DISCOVERY_DIRS` on agents to scan all certificate storage locations (e.g., `/etc/nginx/certs`, `/etc/apache2/certs`, `/usr/local/share/ca-certificates`).
|
||||
- Regularly triage discovered certificates: `GET /api/v1/discovered-certificates?status=Unmanaged`, claim or dismiss each.
|
||||
- Set renewal policies for all certificate profiles with appropriate `alert_thresholds_days` (recommendation: 30, 14, 7, 0).
|
||||
- Monitor expiration dashboard and respond to Expiring alerts before certificates expire.
|
||||
- Verify that issued certificates meet your organization's cryptography standards (key type, key size, SANs).
|
||||
- Test CRL/OCSP endpoints periodically to confirm they are reachable and signed correctly.
|
||||
|
||||
**Status**: **Available** (v1.0 shipped, discovery M18b, revocation M15a/M15b)
|
||||
|
||||
---
|
||||
|
||||
## Requirement 3: Protect Stored Cardholder Data (Key Management)
|
||||
|
||||
**Objective**: Render cardholder data unreadable anywhere it is stored; protect cryptographic keys used to encrypt data.
|
||||
|
||||
### 3.6 — Cryptographic Key Documentation
|
||||
|
||||
**Requirement**: Document and implement all key management processes and procedures covering generation, storage, archival, destruction, and change; protect cryptographic keys; and restrict access to keys to the minimum required.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Certificate Profile Documentation** (M11a) — Named profiles define allowed key types, maximum TTL, and allowed EKUs per use case. Each profile is a documented policy:
|
||||
```json
|
||||
{
|
||||
"id": "p-web-tls",
|
||||
"name": "Web TLS Production",
|
||||
"allowed_key_types": ["RSA_2048", "ECDSA_P256"],
|
||||
"max_ttl_seconds": 31536000,
|
||||
"require_sans": true,
|
||||
"description": "Production TLS certs for external web services"
|
||||
}
|
||||
```
|
||||
|
||||
- **Owner and Team Tracking** (M11b) — Every certificate is assigned an owner (person + email) and optionally a team. This documents key responsibility and escalation paths.
|
||||
|
||||
- **Issuer Connector Specification** — Configuration and API endpoints document which CA and protocol issues each certificate:
|
||||
- `GET /api/v1/issuers/{id}` returns issuer type (local-ca, acme, step-ca, openssl), CA endpoint, authentication method, constraints
|
||||
- Each issuer type has documented key handling (e.g., Local CA loads CA key from `CERTCTL_CA_CERT_PATH`, step-ca via JWK provisioner)
|
||||
|
||||
- **Immutable Audit Trail** (M19) — Every certificate lifecycle event recorded in append-only `audit_events` table:
|
||||
- `certificate_issued` — when certificate created, by whom, issuer type, profile
|
||||
- `certificate_renewed` — when renewed, by whom, issuer
|
||||
- `certificate_revoked` — when revoked, by whom, RFC 5280 reason code
|
||||
- `certificate_deployed` — when deployed to target, by agent, target type
|
||||
- Query: `GET /api/v1/audit?resource_type=certificate&resource_id={cert_id}`
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Exported certificate profiles: `GET /api/v1/profiles` showing documented key types, max TTLs, constraints per environment.
|
||||
- Certificate-to-owner mapping: `GET /api/v1/certificates` with owner/team fields.
|
||||
- Issuer configuration audit: `GET /api/v1/issuers` showing CA endpoints, key storage paths, auth methods.
|
||||
- Audit trail for a certificate: `GET /api/v1/audit?resource_type=certificate&resource_id={cert_id}` showing complete lifecycle.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- Define and document certificate profiles for each environment and use case.
|
||||
- Assign owner and team to each certificate via API or dashboard.
|
||||
- Document issuer connector configuration (CA endpoint, auth method, key storage location).
|
||||
- Maintain baseline audit trail exports for compliance evidence.
|
||||
- Establish certificate retirement policy (how long to retain audit records after certificate expiry/revocation).
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
---
|
||||
|
||||
### 3.7 — Key Lifecycle Procedures
|
||||
|
||||
**Requirement**: Generate, store, protect, access, and destroy cryptographic keys used to encrypt data in transit or at rest.
|
||||
|
||||
This requirement covers key generation, storage, rotation, and destruction. Certctl addresses the certificate/TLS key portion (not symmetric encryption keys used for cardholder data at rest — those are outside scope).
|
||||
|
||||
#### 3.7.1 — Key Generation
|
||||
|
||||
**Requirement**: Generate new keys using strong cryptography.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Agent-Side Key Generation** (M8) — Production mode (default `CERTCTL_KEYGEN_MODE=agent`):
|
||||
- Agents generate ECDSA P-256 key pairs using `crypto/ecdsa` + `crypto/elliptic.P256()` + `crypto/rand` (cryptographically secure random).
|
||||
- Key generation happens **only on the agent**, never on the control plane.
|
||||
- Agent submits Certificate Signing Request (CSR) with public key to control plane via `POST /api/v1/agents/{id}/csr`.
|
||||
- Issued certificate is returned; private key remains on agent at `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`).
|
||||
|
||||
- **Server-Side Fallback** (demo/development only) — `CERTCTL_KEYGEN_MODE=server`:
|
||||
- Control plane generates RSA 2048-bit or ECDSA P-256 keys using `crypto/rand` + `crypto/rsa`.
|
||||
- Server signs CSR and stores the private key in the certificate version record for agent deployment. **Security note:** In server keygen mode, the control plane holds private keys — this is why agent keygen mode is the recommended default for production.
|
||||
- **Must not be used in production.** Explicit warning logged: `Key generation mode is server; this should only be used for testing.`
|
||||
|
||||
- **Issuer-Specific Key Negotiation**:
|
||||
- **ACME (Let's Encrypt, ZeroSSL)**: Let's Encrypt controls key types; certctl requests ECDSA P-256 by default.
|
||||
- **Local CA**: Supports RSA 2048+, ECDSA (P-256, P-384), PKCS#8 format. Key algorithm inherited from CA cert or specified via profile.
|
||||
- **step-ca**: Smallstep's provisioner defines key type; certctl respects server constraints.
|
||||
- **OpenSSL / Custom CA**: User-provided signing script; key type depends on CA backend.
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Deployment configuration: `CERTCTL_KEYGEN_MODE=agent` in production (verify in `docker-compose.yml`, Kubernetes manifests, or systemd units).
|
||||
- Agent log excerpt showing key generation: `openssl genrsa...` or agent process logs with CSR submission timestamp.
|
||||
- Certificate CSR audit: `GET /api/v1/audit?type=certificate_issued` showing CSR fingerprint (SHA-256 hash of CSR PEM).
|
||||
- Renewal job logs showing agent-submitted CSR, not server-generated key.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Enforce `CERTCTL_KEYGEN_MODE=agent` in all production deployments.** Never use `server` mode outside demos.
|
||||
- Verify agent hardware is adequately isolated (crypto/rand relies on OS `/dev/urandom` quality).
|
||||
- Monitor `CERTCTL_KEY_DIR` on agents for unauthorized file access (use OS-level file audit if available).
|
||||
- Backup agent key directory (`/var/lib/certctl/keys`) as part of disaster recovery procedure.
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
#### 3.7.2 — Key Storage and Access Control
|
||||
|
||||
**Requirement**: Restrict cryptographic key access to the minimum required and protect keys from unauthorized access.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Agent-Side Key Storage** (M8) — Private keys written to `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`):
|
||||
- File permissions: `0600` (readable/writable by agent process owner only).
|
||||
- Filename convention: one file per certificate (e.g., `web-tls-prod.key`, `api-service.key`).
|
||||
- No key data passed over the network between agent and control plane (CSR only).
|
||||
- Keys used locally by agent to sign TLS handshakes, never transmitted to control plane or other systems.
|
||||
|
||||
- **Control Plane Key Storage** — Sensitive credentials managed via environment variables or `.env` files:
|
||||
- CA private key path: `CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH` (for Local CA sub-CA mode).
|
||||
- ACME account key: embedded in ACME issuer config (not stored separately; ACME library handles in memory).
|
||||
- step-ca provisioner key: `CERTCTL_STEPCA_PROVISIONER_KEY` env var (JWK, in memory during runtime).
|
||||
- API keys: `CERTCTL_API_KEY` (SHA-256 hashed in database, plaintext never stored).
|
||||
- Database credentials: `CERTCTL_DATABASE_URL` in `.env` file, not in source code.
|
||||
|
||||
- **Docker Compose Credential Management** — `.env` file (git-ignored) holds all secrets:
|
||||
```bash
|
||||
CERTCTL_API_KEY=sk-test-...
|
||||
CERTCTL_DATABASE_URL=postgres://user:pass@db:5432/certctl
|
||||
CERTCTL_CA_KEY_PATH=/run/secrets/ca.key
|
||||
```
|
||||
Credentials never in `docker-compose.yml` or Dockerfile.
|
||||
|
||||
- **Kubernetes Secrets** (operator responsibility) — Deploy control plane with:
|
||||
```yaml
|
||||
env:
|
||||
- name: CERTCTL_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: certctl-secrets
|
||||
key: database-url
|
||||
- name: CERTCTL_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: certctl-secrets
|
||||
key: api-key
|
||||
```
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Agent key directory listing (without keys): `ls -la /var/lib/certctl/keys` (shows file count, permissions, timestamps).
|
||||
- Deployment manifest (`docker-compose.yml` or Kubernetes YAML) showing secrets via env var or Secret object (not inline).
|
||||
- `.env` file (do not share contents, only confirm existence and git-ignore status).
|
||||
- API key hash verification: `GET /api/v1/auth/check` with API key, verifying hash matching without plaintext exposure.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Store `.env` and credential files outside version control.** Verify `.gitignore` includes `.env`, `*.key`, `ca.key`, etc.
|
||||
- **Restrict file system access to `/var/lib/certctl/keys` on agents** via OS-level permissions (Linux: `chmod 0700`, owned by agent user).
|
||||
- **Limit CA key file read access** — `CERTCTL_CA_KEY_PATH` should be readable only by certctl server process (OS permissions).
|
||||
- **Rotate API keys periodically** (recommendation: annually or when personnel changes). No audit trail for API key rotation (outside certctl scope).
|
||||
- **Backup private key stores** (agent key dirs, CA key file) as part of disaster recovery. Encrypt backups at rest.
|
||||
- **Monitor access logs** to `/var/lib/certctl/keys` and CA key file location (use OS audit or file integrity monitoring).
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
#### 3.7.3 — Key Rotation
|
||||
|
||||
**Requirement**: Rotate cryptographic keys upon expiration or compromise.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Automated Certificate Renewal** — Renewal policies trigger certificate renewal automatically:
|
||||
- Background scheduler checks every 60 minutes (configurable via `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL`).
|
||||
- For each policy, evaluates all managed certificates: if `(not-after - now) <= policy.renewal_threshold_days`, trigger renewal.
|
||||
- Renewal job created in AwaitingCSR state; agent receives work, generates new key pair, submits new CSR.
|
||||
- Issuer connector signs new CSR with new key; old key discarded by agent after new certificate installed.
|
||||
- New certificate deployed to target via deployment job.
|
||||
|
||||
- **Expiration-Based Rotation** — Certificate profiles (M11a) define `max_ttl_seconds` (e.g., 31536000 for 1 year, 3600 for short-lived certs):
|
||||
- Short-lived certificates (TTL < 1 hour) rotate every deployment cycle, providing defense-in-depth (RFC 5280 revocation not needed).
|
||||
- Longer-lived certs (90/180/365 days) rotated via renewal policy thresholds (30/14/7 day alerts).
|
||||
|
||||
- **Renewal Audit Trail** — Every renewal recorded:
|
||||
- `GET /api/v1/audit?type=certificate_renewed&resource_id={cert_id}` shows each renewal, old serial, new serial, issuer, actor.
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Renewal policy configuration: `GET /api/v1/policies` showing `renewal_threshold_days` and `alert_thresholds_days`.
|
||||
- Renewal job history: `GET /api/v1/jobs?type=Renewal&status=Completed` with timestamp, before/after serial numbers.
|
||||
- Certificate version history: `GET /api/v1/certificates/{id}/versions` showing all issued versions, dates, issuers.
|
||||
- Audit trail: `GET /api/v1/audit?type=certificate_renewed` for trending and compliance reporting.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Define renewal policies for all certificate profiles** with appropriate thresholds (typically 30 days before expiration for 90+ day certs, more aggressive for shorter-lived).
|
||||
- **Monitor renewal job success** via dashboard (M14 charts show renewal success trends) and alerts.
|
||||
- **Investigate renewal failures** (stuck AwaitingCSR, issuer connectivity, deployment errors) promptly to avoid expired certificates.
|
||||
- **Test renewal workflow in staging environment** before rolling out to production.
|
||||
- **Document key rotation schedule** for your organization (renewal policy thresholds, approval workflows if AwaitingApproval).
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
#### 3.7.4 — Key Destruction
|
||||
|
||||
**Requirement**: Render cryptographic keys unreadable and unusable when they reach the end of their cryptographic lifetime.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Certificate Revocation API** (M15a) — `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes:
|
||||
- `unspecified` — general revocation
|
||||
- `keyCompromise` — suspected key compromise
|
||||
- `caCompromise` — CA compromise
|
||||
- `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn` — lifecycle management
|
||||
- Revocation recorded in `certificate_revocations` table with timestamp and reason.
|
||||
- Issuer notified (best-effort; ACME lacks standard revocation, Local CA skips issuer step).
|
||||
- Revocation notifications sent to owner via email/webhook/Slack/Teams/PagerDuty.
|
||||
|
||||
- **CRL and OCSP Publication** (M15b) — Revoked certificates published in:
|
||||
- CRL: `GET /api/v1/crl` (JSON format) or `GET /api/v1/crl/{issuer_id}` (DER X.509, signed by CA, 24h validity)
|
||||
- OCSP: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain)
|
||||
- Clients checking certificate status via OCSP or CRL see revoked status within 24 hours.
|
||||
|
||||
- **Private Key Destruction on Agent** — When certificate renewed or revoked:
|
||||
- Agent removes old private key file from `CERTCTL_KEY_DIR` when new certificate deployed.
|
||||
- Job status tracking confirms old key is no longer needed.
|
||||
- No audit trail of key deletion (private keys don't pass through control plane).
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Revocation requests: `GET /api/v1/audit?type=certificate_revoked` with RFC 5280 reason codes.
|
||||
- CRL publication: HTTP GET `/api/v1/crl` and parse JSON to show revoked serial numbers and timestamps.
|
||||
- OCSP responder validation: Query `GET /api/v1/ocsp/{issuer}/{serial}` for a known-revoked cert; response includes `revoked` status.
|
||||
- Audit trail: Certificate status transitions (Active → Revoked) recorded in `audit_events`.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Revoke certificates immediately upon key compromise suspicion** using reason code `keyCompromise`.
|
||||
- **Revoke certificates at end of lifecycle** (host decommissioning, service sunset) using reason code `cessationOfOperation`.
|
||||
- **Monitor CRL/OCSP availability** — ensure clients can check revocation status (test with TLS validator tools).
|
||||
- **Establish certificate revocation procedure** (who can revoke, approval workflow if required, documentation).
|
||||
- **Physically destroy backup private keys** (if offline backups are kept) when certificate is revoked or after archival period expires.
|
||||
- **Test revocation workflow in staging** — issue test cert, revoke, verify OCSP/CRL reflects revocation within SLA.
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
---
|
||||
|
||||
## Requirement 8: Identify and Authenticate
|
||||
|
||||
**Objective**: Limit access to system components and cardholder data by business need-to-know, and authenticate and manage all access.
|
||||
|
||||
### 8.3 — Strong Authentication
|
||||
|
||||
**Requirement**: Authentication mechanisms must use strong cryptography and render authentication credentials (passwords, passphrases, keys) unreadable during transmission and storage.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **API Key Authentication** — All REST API endpoints require authentication (default):
|
||||
- Bearer token format: `Authorization: Bearer sk-...`
|
||||
- Key stored as SHA-256 hash in database (plaintext never persisted).
|
||||
- Comparison uses `crypto/subtle.ConstantTimeCompare` to prevent timing attacks.
|
||||
- Configuration: `CERTCTL_AUTH_TYPE=api-key` (enforced by default, no opt-out without explicit env var).
|
||||
|
||||
- **GUI Authentication Context** — Web dashboard login flow:
|
||||
- Login page (`/login`) accepts API key entry.
|
||||
- AuthProvider context stores API key in session (localStorage in browser, sent in Authorization header for all API calls).
|
||||
- 401 Unauthorized responses trigger automatic redirect to login.
|
||||
- Logout button clears session.
|
||||
- No session server-side (stateless API).
|
||||
|
||||
- **Credential Transmission** — All API traffic over TLS:
|
||||
- HTTPS enforced at server level (no plaintext HTTP).
|
||||
- API key transmitted in Authorization header (not URL parameter, not cookie).
|
||||
- Browser to server: TLS.
|
||||
- Agent to server: TLS.
|
||||
- No credential logging (API key hash only, never plaintext).
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- API configuration: `CERTCTL_AUTH_TYPE=api-key` in deployment manifest.
|
||||
- Database schema: `api_keys` table showing SHA-256 hash column, not plaintext.
|
||||
- API audit log: `GET /api/v1/audit?action=api_call` showing Bearer token validation (no plaintext keys logged).
|
||||
- TLS certificate on control plane: `openssl s_client -connect {server}:8443` showing valid certificate, TLS 1.2+, strong cipher.
|
||||
- GUI login flow: browser network tab showing Authorization header (token value redacted in compliance report).
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Issue API keys to users/systems** requiring API access (outside certctl; you maintain key registry).
|
||||
- **Rotate API keys periodically** (recommendation: annually, or when personnel changes).
|
||||
- **Revoke API keys immediately** when user leaves or token is compromised (set `enabled=false` in API key management — not yet implemented in v1, owner must track manually).
|
||||
- **Enforce strong TLS** on control plane: TLS 1.2+, modern ciphers (configure on reverse proxy or `CERTCTL_TLS_*` env vars if operator-controlled).
|
||||
- **Protect `.env` and credential files** where API key is defined (restrict file system access, no version control).
|
||||
- **Monitor API audit trail** for suspicious access patterns (many 401 errors, access from unexpected IPs, etc.).
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
### 8.6 — Application Account Management
|
||||
|
||||
**Requirement**: Users' system access must be restricted to the minimum level of application functions or data needed to perform duties. Application accounts (non-human) must use strong authentication.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **No Application Account Management in v1** — Certctl does not manage user accounts (no user directory, LDAP, OIDC).
|
||||
- All authentication via API key (service-to-service or human user with API key).
|
||||
- No per-user roles or permissions (that's V3 RBAC feature).
|
||||
- Single API key shared across team or one key per automation script (operator's responsibility to manage).
|
||||
|
||||
- **Credentials Not in Source Code** — Security hardening:
|
||||
- API keys via `CERTCTL_API_KEY` env var (not in `main.go`, Dockerfile, `docker-compose.yml`).
|
||||
- Database credentials via `CERTCTL_DATABASE_URL` in `.env` (git-ignored).
|
||||
- CA private key path via `CERTCTL_CA_CERT_PATH`/`CERTCTL_CA_KEY_PATH` (not inline).
|
||||
|
||||
- **Service Account Isolation** (planned for V3) — Future RBAC will support:
|
||||
- Automation script API keys with scoped permissions (e.g., read-only, renew-only, deploy-only).
|
||||
- OIDC/SSO for human users with fine-grained role assignment (admin, operator, viewer).
|
||||
- Audit trail showing which account/role performed each action.
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Deployment manifest (Dockerfile, docker-compose.yml) showing no hardcoded API keys, database credentials, or CA key paths.
|
||||
- `.env` file existence (confirm via CI or compliance check, without sharing contents).
|
||||
- `.gitignore` configuration showing `.env`, `*.key`, secrets excluded.
|
||||
- Code review: grep `main.go`, `config.go` for `CERTCTL_API_KEY` — should only see env var reference, not hardcoded values.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Manage API keys externally** (issue, rotate, revoke).
|
||||
- **Document who/what has API key access** (automation scripts, team members, third-party integrations).
|
||||
- **Rotate application credentials** (API keys, database passwords) according to your organization's policy.
|
||||
- **Segregate credentials** — one API key per automation script where possible, or use V3 RBAC scoping.
|
||||
- **Monitor application account usage** via audit trail — `GET /api/v1/audit` filtered by action/actor.
|
||||
|
||||
**Status**: **Available in part** (v1.0: credentials out of source code). **Planned V3**: scoped API keys and RBAC.
|
||||
|
||||
---
|
||||
|
||||
## Requirement 10: Log and Monitor
|
||||
|
||||
**Objective**: Log and monitor access to network resources and cardholder data.
|
||||
|
||||
### 10.2 — Implement Automated Audit Logging
|
||||
|
||||
**Requirement**: Automatically log and monitor all access to system components and records containing cardholder data.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Immutable API Audit Log** (M19) — Middleware captures every API call:
|
||||
- `audit_events` table (append-only, no UPDATE/DELETE):
|
||||
- `method`: HTTP method (GET, POST, PUT, DELETE)
|
||||
- `path`: API endpoint path (e.g., `/api/v1/certificates`)
|
||||
- `actor`: authenticated user/service (extracted from API key or context)
|
||||
- `body_hash`: SHA-256 hash of request body (truncated to 16 chars, first 8 chars shown in logs)
|
||||
- `status_code`: HTTP response status (200, 201, 400, 401, 404, 500, etc.)
|
||||
- `latency_ms`: request duration in milliseconds
|
||||
- `timestamp`: RFC 3339 timestamp
|
||||
|
||||
- **Certificate Lifecycle Events** — Higher-level events logged separately:
|
||||
- `certificate_issued` — new certificate created, issuer, profile, profile ID
|
||||
- `certificate_renewed` — certificate renewed, old/new serial, renewal policy
|
||||
- `certificate_revoked` — certificate revoked, RFC 5280 reason code
|
||||
- `certificate_deployed` — certificate deployed to target, agent, target type
|
||||
- `certificate_validated` — validation job result (success/failure reason)
|
||||
|
||||
- **Job Lifecycle Events** — Job status transitions:
|
||||
- `job_created` — renewal/issuance/deployment/validation job created
|
||||
- `job_status_updated` — job state change (Pending → AwaitingCSR → Running → Completed/Failed)
|
||||
|
||||
- **Policy and Configuration Events** — Administrative changes:
|
||||
- `policy_created`, `policy_updated`, `policy_deleted` — renewal policy changes
|
||||
- `profile_created`, `profile_updated`, `profile_deleted` — certificate profile changes
|
||||
- `issuer_created`, `issuer_deleted` — CA connector registration changes
|
||||
|
||||
- **Excluded Paths** — Health/readiness probes not logged to reduce noise:
|
||||
- `GET /health` (excluded by default)
|
||||
- `GET /ready` (excluded by default)
|
||||
- Configurable via `CERTCTL_AUDIT_EXCLUDE_PATHS` env var
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Audit trail export: `GET /api/v1/audit` or manual database query, showing sample events with timestamp, actor, action, resource.
|
||||
- API call audit log: Query `audit_events` table showing method, path, actor, status code for last 24-48 hours.
|
||||
- Configuration changes: `GET /api/v1/audit?type=policy_created,policy_updated,issuer_created` showing who changed what and when.
|
||||
- Certificate lifecycle: `GET /api/v1/audit?resource_type=certificate&resource_id={cert_id}` showing complete issuance → deployment → renewal/revocation history.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Enable audit logging** — it's on by default; verify `CERTCTL_AUDIT_EXCLUDE_PATHS` is not set to exclude certificate-related paths.
|
||||
- **Monitor audit log growth** — `audit_events` table will grow with every API call. Recommend database maintenance (log rotation policy, archival after 90 days, etc.).
|
||||
- **Export and archive audit logs** — periodically `SELECT * FROM audit_events WHERE timestamp > {date}` and export to secure storage (S3, syslog, SIEM).
|
||||
- **Establish audit review procedure** — QSA may request sample of logs; have export process documented.
|
||||
- **Test audit logging** — make API call, verify event appears in audit trail within seconds.
|
||||
|
||||
**Status**: **Available** (M19 shipped)
|
||||
|
||||
### 10.3 — Protect Audit Trail
|
||||
|
||||
**Requirement**: Promptly protect audit trail files from unauthorized modifications.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Append-Only Database Design** — PostgreSQL triggers and constraints prevent modification:
|
||||
- `audit_events` table has no `UPDATE` or `DELETE` triggers.
|
||||
- Application code never executes UPDATE/DELETE on `audit_events`.
|
||||
- Primary key is `id` (serial); new events always INSERT.
|
||||
|
||||
- **Read-Only API Access** — Audit events accessible only via read (`GET /api/v1/audit`):
|
||||
- No `POST /api/v1/audit/{id}` endpoint (no creation from API).
|
||||
- No `PUT /api/v1/audit/{id}` endpoint (no modification).
|
||||
- No `DELETE /api/v1/audit/{id}` endpoint (no deletion).
|
||||
- Only control plane can record events (via internal service layer, not exposed API).
|
||||
|
||||
- **Database Access Control** (operator responsibility) — PostgreSQL user permissions:
|
||||
- `certctl` application user: INSERT, SELECT on `audit_events`.
|
||||
- `certctl_read_only` user (for compliance/audit team): SELECT only on `audit_events`.
|
||||
- `postgres` superuser: restricted to DBA operations, logged separately by PostgreSQL.
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Database schema: `\d audit_events` showing columns, primary key, no UPDATE/DELETE triggers.
|
||||
- Application code review: `internal/service/audit.go` showing `RecordEvent(...)` as only INSERT operation.
|
||||
- API endpoint audit: grep `internal/api/handler/audit*.go` or `internal/api/router/router.go` — no PUT/DELETE routes for events.
|
||||
- PostgreSQL permissions: `psql -d certctl -c "\dp audit_events"` showing INSERT/SELECT grants only.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Restrict database access** — issue read-only PostgreSQL user for compliance/audit team (no write privileges).
|
||||
- **Enable PostgreSQL query logging** — log all database connections and operations for DBA audit trail.
|
||||
- **Backup audit logs** — regularly export `audit_events` to offsite storage (S3, archive tape, syslog aggregator) for long-term retention.
|
||||
- **Monitor database modifications** — alert if any UPDATE/DELETE is attempted on `audit_events` (log-based alerting or PostgreSQL event triggers).
|
||||
- **Encrypt audit exports** — if archiving to external storage, encrypt backups at rest.
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
### 10.4 — Promptly Review and Address Audit Trail Exceptions
|
||||
|
||||
**Requirement**: Promptly review audit logs and investigate exceptions/anomalies.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Dashboard Charts** (M14) — Real-time observability:
|
||||
- **Renewal Success Trends** (30-day line chart) — shows job success rate; spikes in failures warrant investigation.
|
||||
- **Certificate Status Distribution** (donut chart) — shows Expiring/Expired counts; high Expired = missed renewals.
|
||||
- **Expiration Timeline** (90-day weekly heatmap) — shows upcoming expirations; bunching = renewal policy tuning needed.
|
||||
- **Issuance Rate** (30-day bar chart) — shows certificate creation/renewal activity; anomalies (zero issuances for weeks) indicate stopped automation.
|
||||
|
||||
- **Stats API** (M14) — Machine-readable trends:
|
||||
- `GET /api/v1/stats/job-trends?days=30` — renewal/issuance/deployment success/failure counts per day.
|
||||
- `GET /api/v1/stats/summary` — total certs, counts by status.
|
||||
- `GET /api/v1/stats/expiration-timeline?days=90` — expiration buckets for forecasting.
|
||||
|
||||
- **Agent Fleet Overview** (M14) — Agent health visibility:
|
||||
- Pie chart: agent status distribution (healthy, offline, error).
|
||||
- Version breakdown: agent versions in use (identify outdated agents).
|
||||
- Per-agent detail: last heartbeat timestamp, OS/architecture, IP address, recent jobs.
|
||||
|
||||
- **Alert Notifications** (M3, M16a) — Configurable escalation:
|
||||
- Email alerts: certificate approaching expiration, renewal failure, revocation notification.
|
||||
- Webhook: custom HTTP POST to your monitoring system (Slack, Teams, PagerDuty, OpsGenie, custom webhook).
|
||||
- Deduplication: one alert per threshold/certificate per day (avoid alert fatigue).
|
||||
|
||||
- **Audit Trail Filtering and Export** (M13) — Compliance reporting:
|
||||
- `GET /api/v1/audit?actor={user}×tamp_after={date}` — filter audit log by actor, timestamp, type.
|
||||
- Export CSV/JSON via dashboard: audit page → select filters → "Export CSV" or "Export JSON".
|
||||
- Can export full audit trail for QSA review.
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Dashboard screenshots: expiration timeline, renewal success trends, status distribution.
|
||||
- Job trend report: `GET /api/v1/stats/job-trends?days=90` showing success/failure rates.
|
||||
- Agent fleet health: `GET /api/v1/agents` showing heartbeat status, version count distribution.
|
||||
- Audit log sample: `GET /api/v1/audit?limit=100` showing certificate issuance/renewal/revocation activity.
|
||||
- Alert configuration: screenshot of renewal policy `alert_thresholds_days` (30, 14, 7, 0) and notifier settings (email, Slack, etc.).
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Review dashboard charts weekly** — look for anomalies (high Expired count, failure spike, renewal stalled).
|
||||
- **Respond to alerts promptly** — expiration alert = investigate renewal (check job logs, issuer connectivity, agent heartbeat).
|
||||
- **Set alert thresholds appropriately** — default 30/14/7/0 days is a starting point; adjust per your SLA and staffing.
|
||||
- **Maintain alert distribution list** — ensure alerts reach the right on-call engineer/team.
|
||||
- **Archive and review audit logs** — export monthly/quarterly for compliance trending (e.g., "all certificate changes last quarter").
|
||||
- **Test alert delivery** — trigger a test renewal failure or manual revocation, verify alert is sent.
|
||||
|
||||
**Status**: **Available** (v1.0 shipped, M14 observable charts, M19 audit log)
|
||||
|
||||
### 10.7 — Retain and Protect Audit Trail History
|
||||
|
||||
**Requirement**: Retain audit trail history for at least one year and ensure it can be retrieved.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Immutable Audit Trail** (M19) — `audit_events` table stores all API calls and certificate lifecycle events with timestamps.
|
||||
- **No Automatic Purge** — Certctl does not delete audit events. They remain in PostgreSQL indefinitely.
|
||||
- **Queryable History** — All events accessible via `GET /api/v1/audit` with time range, actor, resource filters.
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Database retention policy: confirm `audit_events` table has no DELETE triggers or maintenance jobs that purge events.
|
||||
- Sample audit query: `SELECT COUNT(*) FROM audit_events WHERE timestamp > NOW() - INTERVAL '365 days'` showing one year+ of events.
|
||||
- Export procedure: documented process for exporting audit logs to cold storage (S3, archive tape, syslog).
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Configure PostgreSQL backup/retention** — certctl relies on database backups for audit trail protection.
|
||||
- Backup `audit_events` table daily or per your RPO/RTO.
|
||||
- Retain backups for at least 1 year (configure retention policy on backup system).
|
||||
- Test restore procedure annually.
|
||||
|
||||
- **Export and archive audit logs** — periodically export `SELECT * FROM audit_events WHERE timestamp > {start_date}` to offsite storage.
|
||||
- Recommendation: monthly exports to S3 with versioning enabled.
|
||||
- Encrypt exports at rest.
|
||||
- Retain archives for at least 3 years (adjust per your compliance requirements).
|
||||
|
||||
- **Monitor audit log growth** — `audit_events` table will grow ~1-5 MB/day depending on API call volume.
|
||||
- Estimate: 10,000 API calls/day = ~50 MB/month.
|
||||
- Plan PostgreSQL storage and backup capacity accordingly.
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
---
|
||||
|
||||
## Requirement 6: Develop and Maintain Secure Systems and Applications
|
||||
|
||||
**Objective**: Develop and maintain secure systems and applications.
|
||||
|
||||
### 6.3.1 — Security Coding Practices
|
||||
|
||||
**Requirement**: Develop all custom application code in accordance with secure coding practices and include authentication, access control, input validation, and error handling.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Input Validation** — Centralized validators enforce strong input constraints:
|
||||
- Common name: max 253 chars, DNS-safe characters only, no leading/trailing hyphens.
|
||||
- CSR PEM: must be valid PEM format (regex validation).
|
||||
- Policy type: whitelist enum (Issuance, Renewal, Revocation, etc.).
|
||||
- API key: alphanumeric + hyphens only.
|
||||
- Implemented in `internal/domain/validation.go` and called from all handler layer inputs.
|
||||
|
||||
- **Error Handling** — No sensitive data leakage in error responses:
|
||||
- HTTP 500 errors return generic "Internal Server Error" message, not stack trace.
|
||||
- Database errors logged internally (structured slog), not exposed to client.
|
||||
- 404 errors do not reveal whether resource exists (consistent "Not Found" regardless of auth vs. not-found).
|
||||
|
||||
- **No Hardcoded Credentials** — All secrets via environment variables:
|
||||
- `CERTCTL_API_KEY`, `CERTCTL_DATABASE_URL`, `CERTCTL_CA_KEY_PATH` — env vars only.
|
||||
- Credentials not in `main.go`, Dockerfile, `docker-compose.yml`, or Git history.
|
||||
- `.env` file git-ignored and excluded from version control.
|
||||
|
||||
- **Dependency Management** — Go module pinning (`go.mod`):
|
||||
- All external dependencies pinned to specific versions.
|
||||
- No wildcard versions or `latest` tags.
|
||||
- CI runs `go mod verify` to detect tampering.
|
||||
|
||||
**Evidence You Can Provide**:
|
||||
- Code review: `internal/domain/validation.go` showing input validation functions (Common name length, CSR PEM, policy type, etc.).
|
||||
- Error handling audit: `internal/api/handler/certificates.go` showing HTTP error responses (no stack traces).
|
||||
- Credentials in source code check: `grep -r "CERTCTL_API_KEY\|DATABASE_URL\|CA_KEY" cmd/ internal/ | grep -v ".env"` (should only show env var references, not values).
|
||||
- `go.mod` review: no wildcard versions, all pinned.
|
||||
- CI workflow: `.github/workflows/ci.yml` showing `go mod verify` step.
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Review dependency updates** — keep Go version current, update certctl dependencies regularly (security patches).
|
||||
- **Scan container images** — use Trivy, Clair, or similar to scan Docker images for known vulnerabilities.
|
||||
- **Maintain secure coding practices** in any custom issuer/target connectors you deploy (scripts for OpenSSL, BASH/PowerShell for IIS/F5).
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
### 6.5.10 — Broken Authentication and Cryptography Prevention
|
||||
|
||||
**Requirement**: Prevent broken authentication and cryptography weaknesses.
|
||||
|
||||
**certctl Support**:
|
||||
|
||||
- **Authentication** — API key with SHA-256 hashing, constant-time comparison (`crypto/subtle.ConstantTimeCompare`).
|
||||
- **Cryptography** — Go's `crypto/*` standard library (no weak ciphers). ECDSA P-256, RSA 2048+.
|
||||
- **TLS** — HTTPS enforced (no plaintext HTTP endpoints).
|
||||
- **No Sessions** — Stateless API (no session cookies, no session fixation risk).
|
||||
|
||||
**Status**: **Available** (v1.0 shipped)
|
||||
|
||||
---
|
||||
|
||||
## Requirement 7: Restrict Access by Business Need-to-Know
|
||||
|
||||
**Objective**: Limit access to system components and cardholder data by business need-to-know and ensure users are authenticated and authorized.
|
||||
|
||||
### 7.2 — Implement Access Control
|
||||
|
||||
**Requirement**: Ensure proper user identity management and implement access controls based on business need-to-know.
|
||||
|
||||
**certctl v1 Support** (limited):
|
||||
- **Certificate Ownership** (M11b) — Each certificate assigned to owner (person + email) and optional team. Ownership is metadata; access control is not enforced at API level.
|
||||
- **Agent Groups** (M11b) — Renewal policies target specific agent groups (OS, architecture, CIDR, version). Groups are used for policy targeting, not user access control.
|
||||
- **Interactive Approval** (M11b) — `AwaitingApproval` job state allows manual approval/rejection of renewals (enforcement of business workflows, not user access control).
|
||||
|
||||
**certctl v3 Support** (planned):
|
||||
- **OIDC/SSO** — Okta, Azure AD, Google integration. Users log in via identity provider.
|
||||
- **Role-Based Access Control (RBAC)** — Three roles: admin (all operations), operator (issue/renew/deploy), viewer (read-only). Roles assigned via OIDC claims or group membership.
|
||||
- **Profile/Owner Gating** — Operator can renew only certificates assigned to their team; viewer cannot modify anything.
|
||||
- **Audit Trail Attribution** — Every action shows which user/role performed it.
|
||||
|
||||
**Evidence You Can Provide** (v1):
|
||||
- Certificate ownership mapping: `GET /api/v1/certificates` showing owner, team fields (metadata only; access not controlled).
|
||||
- Agent group targeting: `GET /api/v1/policies` showing `agent_group_id` field.
|
||||
- Interactive approval workflow: job detail showing `AwaitingApproval` state, approve/reject endpoints in API docs.
|
||||
|
||||
**Operator Responsibility** (v1):
|
||||
- **Manage API key distribution** externally — only issue API keys to authorized users/systems.
|
||||
- **Implement reverse proxy auth** (Nginx, Apache, Okta proxy) in front of certctl to enforce OIDC/LDAP (outside certctl).
|
||||
- **Plan for V3 RBAC** — budget for upgrade when finer-grained access control is needed.
|
||||
|
||||
**Planned** (V3):
|
||||
- Upgrade to certctl Pro with OIDC/RBAC and per-role audit trail.
|
||||
|
||||
**Status**: **Available in part** (v1.0: ownership metadata, agent group targeting). **Planned V3**: OIDC/RBAC enforcement.
|
||||
|
||||
---
|
||||
|
||||
## Evidence Summary Table
|
||||
|
||||
| PCI-DSS Requirement | certctl Feature | API/UI Evidence | Database/Config | Audit Trail | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| **4.2.1** Strong Crypto | TLS cert issuance, ACME/step-ca/Local CA, RSA 2048+/ECDSA P-256 | `GET /api/v1/certificates` (key_type, key_size) | Certificate profiles | `GET /api/v1/audit?type=certificate_issued` | Available |
|
||||
| **4.2.2** Cert Inventory & Validation | Managed cert CRUD, discovery (M18b), expiration alerting, CRL/OCSP | `GET /api/v1/certificates`, `GET /api/v1/discovered-certificates`, `GET /api/v1/crl`, `GET /api/v1/ocsp/{issuer}/{serial}` | `managed_certificates`, `discovered_certificates` tables | `GET /api/v1/audit?type=certificate_*` | Available |
|
||||
| **3.6** Key Documentation | Profiles, owner/team tracking, issuer config, audit trail | `GET /api/v1/profiles`, `GET /api/v1/issuers`, certificate detail with owner/team | Profiles, certificate owner/team fields, issuer config | `GET /api/v1/audit?resource_type=certificate` | Available |
|
||||
| **3.7.1** Key Generation | Agent-side ECDSA P-256, server keygen (demo only) | Agent logs, renewal job detail, CSR audit | `CERTCTL_KEYGEN_MODE=agent` (config), job_type=AwaitingCSR | `GET /api/v1/audit?type=certificate_issued` with CSR hash | Available |
|
||||
| **3.7.2** Key Storage | Agent `/var/lib/certctl/keys` (0600), env var secrets, .env excluded | Deployment manifest (env var refs), agent key dir listing | `.env` file (git-ignored), `CERTCTL_KEY_DIR`, `CERTCTL_CA_KEY_PATH` | No API audit (keys off-platform) | Available |
|
||||
| **3.7.3** Key Rotation | Auto renewal, expiration thresholds, renewal jobs | Dashboard renewal trends, `GET /api/v1/jobs?type=Renewal`, certificate versions | Renewal policies, certificate version history | `GET /api/v1/audit?type=certificate_renewed` | Available |
|
||||
| **3.7.4** Key Destruction | Revocation API (RFC 5280), CRL/OCSP, private key cleanup | `POST /api/v1/certificates/{id}/revoke`, `GET /api/v1/crl`, OCSP endpoint | `certificate_revocations` table, CRL publication | `GET /api/v1/audit?type=certificate_revoked` | Available |
|
||||
| **8.3** Strong Authentication | API key (SHA-256 hash, TLS), GUI login, 401 redirect | GUI login screenshot, API key auth header, TLS cert | API key hash in database | `GET /api/v1/audit` showing API calls | Available |
|
||||
| **8.6** Acct Management | Credentials out of source, .env excluded, env var config | Code review (no hardcoded secrets), `.gitignore` check | Deployment manifests showing env var refs only | No account lifecycle audit (outside scope) | Available in part |
|
||||
| **10.2** Audit Logging | API audit middleware (M19), certificate lifecycle events | `GET /api/v1/audit` with filter/pagination | `audit_events` table (every API call) | Real-time via API | Available |
|
||||
| **10.3** Audit Protection | Append-only table design, read-only API, DB permissions | API endpoint audit (no PUT/DELETE on events), DB schema | `audit_events` table, PostgreSQL GRANT SELECT | Immutable by design | Available |
|
||||
| **10.4** Review & Alert | Dashboard charts, stats API, notifier integrations | Dashboard (renewal trends, status pie, expiration heatmap), `GET /api/v1/stats/*` | Job results, alert config in policies | `GET /api/v1/audit?type=job_*` | Available |
|
||||
| **10.7** Retention | 1+ year in PostgreSQL, export/archive procedures | Database query `SELECT COUNT(*) FROM audit_events WHERE timestamp > NOW() - INTERVAL '1 year'` | `audit_events` table retention (no auto-delete) | Manual export/archival (operator) | Available |
|
||||
| **6.3.1** Secure Coding | Input validation, error handling, no hardcoded secrets, dependency pinning | Code review (validation.go, handlers), error responses | `go.mod` with pinned versions, `.gitignore` | GitHub Actions CI with `go mod verify` | Available |
|
||||
| **7.2** Access Control | Ownership metadata, agent groups, interactive approval | `GET /api/v1/certificates` (owner/team), `GET /api/v1/agent-groups` | Certificate owner/team fields, agent group criteria | User identity from auth context | Available in part (V3: RBAC) |
|
||||
|
||||
---
|
||||
|
||||
## Operator Responsibilities
|
||||
|
||||
The following control objectives are **outside certctl's scope** and must be managed by your organization:
|
||||
|
||||
| Control Objective | Responsibility | Example Actions |
|
||||
|---|---|---|
|
||||
| **Network Segmentation** | Isolate certctl control plane from cardholder network | Place certctl on separate VLAN, firewall rules |
|
||||
| **Physical Security** | Restrict access to servers/databases | Data center access controls, logging |
|
||||
| **Personnel Screening** | Background checks for staff with access | HR/employment verification |
|
||||
| **Access Control Enforcement** | User authentication & authorization outside API | Implement reverse proxy with OIDC (V3: use certctl Pro RBAC) |
|
||||
| **Incident Response** | Procedures for certificate compromise or breach | Document key revocation process, alert escalation |
|
||||
| **Disaster Recovery** | Backup and restore procedures | Database backup schedule, offsite replication |
|
||||
| **Change Management** | Approval process for config/cert changes | CAB meetings, documented procedures |
|
||||
| **Vulnerability Scanning** | ASV scanning, penetration testing, code review | Annual PCI-DSS penetration test |
|
||||
| **Key Backup & Escrow** | Secure offline storage of CA private keys (if required) | Hardware security module (HSM) or encrypted vault |
|
||||
| **Audit Log Retention** | Long-term archival and protection of audit logs | Export to S3/syslog, retain 3+ years |
|
||||
| **QSA Engagement** | Schedule and coordination of compliance assessment | Annual audit with qualified security assessor |
|
||||
|
||||
---
|
||||
|
||||
## V3 Enhancements for PCI-DSS
|
||||
|
||||
Certctl v3 (Pro) adds paid features that strengthen PCI-DSS compliance posture:
|
||||
|
||||
| Feature | PCI-DSS Benefit |
|
||||
|---|---|
|
||||
| **OIDC/SSO Authentication** | Centralized identity management, audit integration with corporate directory |
|
||||
| **Role-Based Access Control (RBAC)** | Least-privilege enforcement: admin, operator, viewer roles with profile/team gating |
|
||||
| **Bulk Revocation by Profile/Owner/Agent** | Rapid incident response (revoke all certs in cardholder network in minutes) |
|
||||
| **NATS Event Bus with JetStream Audit Streaming** | Real-time event streaming to SIEM (Splunk, ELK, Datadog) for centralized audit trail |
|
||||
| **Certificate Health Scores** | Proactive risk identification (composite scoring: expiration proximity, rotation age, key strength) |
|
||||
| **Advanced Search DSL** | Complex audit queries (POST /search with nested AND/OR, regex, field projection) for compliance reporting |
|
||||
| **CT Log Monitoring** | Detect unauthorized certificate issuance (security vulnerability detection) |
|
||||
| **DigiCert Issuer Connector** | Enterprise CA integration for compliance audits |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps for Compliance
|
||||
|
||||
1. **Review this mapping with your QSA** — Confirm which requirements apply to your cardholder data environment.
|
||||
|
||||
2. **Configure certctl for your environment**:
|
||||
- Set `CERTCTL_KEYGEN_MODE=agent` in production.
|
||||
- Define certificate profiles with approved key types.
|
||||
- Configure renewal policies with appropriate thresholds (e.g., 30 days for 90-day certs).
|
||||
- Enable notifier integrations (email, Slack, PagerDuty) for alerts.
|
||||
- Plan `CERTCTL_DISCOVERY_DIRS` on agents to scan all certificate locations.
|
||||
|
||||
3. **Implement operator controls**:
|
||||
- Document certificate management procedures (issuance, renewal, revocation, archival).
|
||||
- Establish API key rotation schedule.
|
||||
- Set up audit log export and archival (monthly to S3, retain 1+ year).
|
||||
- Configure PostgreSQL backups (daily, 1+ year retention).
|
||||
- Plan incident response (who revokes certs, escalation process, timeline).
|
||||
|
||||
4. **Test compliance readiness**:
|
||||
- Trigger a test renewal and verify CRL/OCSP publication.
|
||||
- Export audit trail and verify it shows expected events.
|
||||
- Test revocation workflow and confirm OCSP reflects status within 24 hours.
|
||||
- Run discovery scan and verify unknown certs are detected and triaged.
|
||||
|
||||
5. **Prepare evidence for QSA**:
|
||||
- API endpoint documentation (OpenAPI spec: `api/openapi.yaml`).
|
||||
- Audit log sample (last 90 days of events).
|
||||
- Configuration export (profiles, policies, issuer/target definitions).
|
||||
- Deployment manifest (showing env var config, no hardcoded secrets).
|
||||
- Test certificates and CRL/OCSP query results.
|
||||
|
||||
6. **Plan for V3** (if RBAC/centralized audit required):
|
||||
- Evaluate certctl Pro for OIDC/SSO and NATS audit streaming.
|
||||
- Assess integration with existing identity provider (Okta, Azure AD, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
For additional guidance on certctl features and PCI-DSS mapping:
|
||||
- Review the [Architecture Guide](architecture.md) for system design.
|
||||
- Check [Connectors Documentation](connectors.md) for issuer/target/notifier capabilities.
|
||||
- Run the [Demo Guide](demo-guide.md) to see features in action.
|
||||
- Consult your QSA for final compliance determination.
|
||||
|
||||
**Last Updated**: March 24, 2026 (certctl v1.0 with M18b discovery and M19 audit logging)
|
||||
@@ -0,0 +1,552 @@
|
||||
# SOC 2 Type II Compliance Mapping
|
||||
|
||||
This guide maps certctl's implemented features to AICPA SOC 2 Trust Service Criteria (TSC). It is **not a SOC 2 certification claim** — rather, it helps security engineers, auditors, and evaluators understand how certctl supports your organization's SOC 2 compliance posture. Use this as evidence input for your own control assessment during SOC 2 audits.
|
||||
|
||||
## How to Use This Guide
|
||||
|
||||
SOC 2 audits require evidence that your infrastructure meets specific Trust Service Criteria. Auditors ask: "Does your certificate management tooling support CC6.1 logical access controls?" This guide answers by mapping certctl's features to specific criteria and pointing to evidence (API endpoints, configuration, audit trail).
|
||||
|
||||
Each section includes:
|
||||
|
||||
- **The TSC requirement** — what the auditor is looking for
|
||||
- **certctl's implementation** — which features address it
|
||||
- **Evidence location** — where to find proof (API endpoint, config variable, source code, audit events)
|
||||
- **V2 vs V3 status** — whether feature is in the free community edition (V2) or paid Pro edition (V3)
|
||||
- **Operator responsibility** — aspects your organization must handle outside of certctl
|
||||
|
||||
## CC6: Logical and Physical Access Controls
|
||||
|
||||
### CC6.1 — Logical Access Security
|
||||
|
||||
**Requirement**: The entity restricts logical access to digital and information assets and related facilities by applying user identity authentication, registration, access rights, and usage policies.
|
||||
|
||||
**certctl Implementation** (V2 — Community Edition):
|
||||
|
||||
- **API Key Authentication** — All API calls require a Bearer token (hashed with SHA-256, stored securely, validated with constant-time comparison) or are rejected with 401 Unauthorized. Environment: `CERTCTL_AUTH_TYPE` (default `api-key`; `none` requires explicit opt-in with log warning)
|
||||
- **GUI Authentication** — Web dashboard includes login screen requiring API key entry. Failed auth redirects to login on 401. Auth context persists across page navigation. Logout clears session.
|
||||
- **Configurable CORS** — API restricts cross-origin requests via `CERTCTL_CORS_ORIGINS` allowlist or wildcard. Preflight caching prevents chatty browser auth flows.
|
||||
- **Token Bucket Rate Limiting** — Per-IP rate limiting (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`) returns 429 Too Many Requests with Retry-After header. Prevents credential stuffing and brute-force attacks.
|
||||
- **No Password Storage** — certctl does not store user passwords. API keys are the sole authentication mechanism. Your API key generation, distribution, and rotation policies are your responsibility (see "Operator Responsibility" below).
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- API auth implementation: `internal/api/middleware/auth.go`
|
||||
- Auth check endpoint: `GET /api/v1/auth/check` (validates credentials)
|
||||
- Auth info endpoint: `GET /api/v1/auth/info` (returns current auth mode, served without auth so GUI detects mode)
|
||||
- Rate limiting middleware: `internal/api/middleware/rate_limit.go`
|
||||
- CORS configuration: `cmd/server/main.go`, search for `CERTCTL_CORS_ORIGINS`
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **OIDC / SSO Integration** — Optional OIDC providers (Okta, Azure AD, Google) with multi-tenant support. API key fallback for service accounts.
|
||||
- **API Key Scoping** — Per-resource or per-action permissions (e.g., "read certificates from production only" or "issue certs, no revoke")
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Generate and securely distribute API keys to authorized users and systems
|
||||
- Rotate API keys regularly (recommend quarterly)
|
||||
- Revoke API keys immediately upon employee departure
|
||||
- Do not commit API keys to version control (use `.env` or secrets management)
|
||||
- Implement your own IP allowlisting at the firewall if needed (certctl enforces CORS at the HTTP layer, not at network layer)
|
||||
|
||||
---
|
||||
|
||||
### CC6.2 — Prior to Issuing System Credentials
|
||||
|
||||
**Requirement**: The entity provisions, modifies, disables, and removes user identities and rights based on an authorization process that considers user responsibility level and changes in those responsibilities.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Ownership Attribution** — Certificates can be assigned to an owner (email + name). Owner information is stored and audited (see CC7.2). Ownership is tracked through the lifecycle (issuance, renewal, deployment, revocation). Ownership reassignment is audited via the immutable audit trail.
|
||||
- **Team Assignment** — Owners can be organized into teams. Certificate policies can route notifications to team email addresses.
|
||||
- **Audit Trail Attribution** — Every API call records the actor (extracted from the API key or auth context). The audit trail is immutable — no retroactive modification of who did what.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Ownership domain model: `internal/domain/certificate.go` (OwnerID field)
|
||||
- Owner CRUD API: `GET /api/v1/owners`, `POST /api/v1/owners`, `DELETE /api/v1/owners/{id}`
|
||||
- Team CRUD API: `GET /api/v1/teams`, `POST /api/v1/teams`, `DELETE /api/v1/teams/{id}`
|
||||
- Audit trail API: `GET /api/v1/audit` (actor field in every record)
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **RBAC (Role-Based Access Control)** — Predefined roles (Admin, Operator, Viewer) with profile-gated permissions. Administrators manage role assignments.
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Map certctl's ownership model to your organizational structure (departments, teams, on-call rotations)
|
||||
- Establish a formal access request and approval process
|
||||
- Remove ownership access when team members depart
|
||||
- Document your access review process (audit trail shows *who* made changes, but you must justify *why*)
|
||||
|
||||
---
|
||||
|
||||
### CC6.3 — Authentication Policies
|
||||
|
||||
**Requirement**: The entity determines, documents, communicates, and enforces authentication policies that support the identification and authentication of authorized internal and external users and the transmission of user credentials.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **API Key Policy** — All API access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup.
|
||||
- **Agent Authentication** — Agents authenticate to the server via API keys (same mechanism as users). Agent credentials are separate from user API keys.
|
||||
- **Private Key Policy** — Agent-side key generation is the default (`CERTCTL_KEYGEN_MODE=agent`). Server-side keygen (`CERTCTL_KEYGEN_MODE=server`) requires explicit configuration and logs a warning: "Server-side keygen enabled — private keys will be stored in PostgreSQL (development only)".
|
||||
- **Password Policy** — Not applicable; certctl uses API keys exclusively. Password management is delegated to your organization's IAM system if you integrate OIDC/SSO (V3).
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Auth type configuration: `internal/config/config.go`, `CERTCTL_AUTH_TYPE` env var
|
||||
- Startup logging: `cmd/server/main.go` (logs auth mode at server startup)
|
||||
- Keygen mode configuration: `internal/config/config.go`, `CERTCTL_KEYGEN_MODE` env var
|
||||
- Keygen mode warning: `cmd/server/main.go` and `cmd/agent/main.go`
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **OIDC Policy** — Mandatory MFA when OIDC is enabled
|
||||
- **API Key Expiration** — Automatic key rotation policies (e.g., 90-day expiration for user keys, no expiration for long-lived service account keys)
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Document your API key generation and distribution policy
|
||||
- Establish a formal change control process for auth configuration changes
|
||||
- Test authentication failures (e.g., expired keys, malformed tokens) in a non-production environment
|
||||
- Integrate certctl authentication into your organization's IAM audit reports (who has API keys, when were they issued, who has revoked them)
|
||||
|
||||
---
|
||||
|
||||
### CC6.7 — Information Transmission Protection
|
||||
|
||||
**Requirement**: The entity restricts the transmission, movement, and removal of information in a manner that prevents unauthorized disclosure, whether through digital or non-digital means.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **TLS for Control Plane** — All API communication occurs over HTTPS (TLS 1.2+). Server uses `tls.Dial()` for outbound connections to issuers and targets. Configuration: `CERTCTL_SERVER_ADDR` (default `:8443`).
|
||||
- **Agent-to-Server Communication** — Agents submit CSRs and heartbeats over HTTPS to the server using the same TLS stack.
|
||||
- **Private Key Isolation** — Agents generate ECDSA P-256 private keys locally (`crypto/ecdsa` + `crypto/elliptic`). Private keys are never transmitted to the server — agents submit CSRs only. Private keys are stored on agent filesystem (`CERTCTL_KEY_DIR`, default `/var/lib/certctl/keys`) with 0600 (owner read/write only) permissions. Server-side keygen mode logs a development warning; production must use agent-side keygen.
|
||||
- **Certificate Storage** — Signed certificates are stored in PostgreSQL as PEM text (along with metadata). Certificates are not secrets and may be transmitted plaintext. Private keys are never stored on the control plane in production (agent-side keygen mode).
|
||||
- **Deployment via Target Connectors** — Target connectors write certificates and keys to local filesystem or network appliance APIs. For NGINX/Apache httpd, files are written with restrictive permissions (0600 for keys). For F5/IIS (V3+), credentials are scoped to a proxy agent in the same network zone — the server never holds network appliance credentials.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- TLS configuration: deploy certctl behind a TLS-terminating reverse proxy (NGINX, HAProxy, or cloud load balancer) or use a TLS sidecar
|
||||
- Agent keygen mode: `cmd/agent/main.go` (ECDSA key generation, filesystem storage with 0600)
|
||||
- Private key handling: `internal/connector/target/nginx/nginx.go` and similar (cert/key file write)
|
||||
- Server-side keygen deprecation: `internal/service/renewal.go` (log warning when enabled)
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **Hardware Security Module (HSM) Support** — Optional HSM backend for CA key storage (SubCA and Local CA modes)
|
||||
- **Secrets Rotation** — Encrypted key rotation without server restart
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Enable TLS on the control plane in production (deploy behind a TLS-terminating reverse proxy or load balancer with valid certificates)
|
||||
- Enforce TLS on agent-to-server communication via firewall rules (no cleartext HTTP)
|
||||
- Protect agent filesystem key storage with:
|
||||
- File-level permissions (already 0600)
|
||||
- Encrypted filesystems (LUKS, BitLocker, or cloud provider equivalents)
|
||||
- Backup encryption (keys backed up to vault or HSM, never in cleartext backups)
|
||||
- Restrict PostgreSQL access to authorized services only (network isolation, authentication)
|
||||
- For target systems, ensure network traffic from agents to targets is encrypted (TLS, IPsec, or VPN)
|
||||
|
||||
---
|
||||
|
||||
## CC7: System Operations
|
||||
|
||||
### CC7.1 — System Monitoring
|
||||
|
||||
**Requirement**: The entity monitors system components and the operation of those components for anomalies that are indicative of malfunction, including the implementation of monitoring tools, the reporting of results of those monitoring activities, and the identification, documentation, analysis, and resolution of system anomalies.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **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:
|
||||
- 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
|
||||
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
|
||||
- `GET /api/v1/metrics/prometheus` — Prometheus exposition format (`text/plain; version=0.0.4`) for native scraping by Prometheus, Grafana Agent, Datadog, and other OpenMetrics-compatible collectors
|
||||
- **Gauges** — `certctl_certificate_total`, `certctl_certificate_active`, `certctl_certificate_expiring`, `certctl_certificate_expired`, `certctl_certificate_revoked`, `certctl_agent_total`, `certctl_agent_active`, `certctl_job_pending`
|
||||
- **Counters** — `certctl_job_completed_total`, `certctl_job_failed_total`
|
||||
- **Uptime** — `certctl_uptime_seconds` (seconds since server start)
|
||||
All values are point-in-time snapshots computed from database tables.
|
||||
- **Structured Logging** — All scheduler operations, API calls, and connector actions log via `slog` (Go's structured logger). Logs include timestamp, level (DEBUG/INFO/WARN/ERROR), structured fields (e.g., `actor`, `resource_id`, `latency_ms`), and request IDs for tracing.
|
||||
- **Request ID Propagation** — Each HTTP request gets a unique ID (`X-Request-ID` header). The ID is included in all correlated logs, making it easy to trace a single request through multiple service layers.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Health/readiness endpoints: `internal/api/handler/health.go`
|
||||
- Background scheduler: `internal/scheduler/scheduler.go` (Start method)
|
||||
- Metrics endpoint: `internal/api/handler/metrics.go`
|
||||
- Stats API endpoints (for detailed time-series): `internal/api/handler/stats.go`
|
||||
- `GET /api/v1/stats/summary` — dashboard KPIs
|
||||
- `GET /api/v1/stats/certificates-by-status` — cert counts by status
|
||||
- `GET /api/v1/stats/expiration-timeline?days=N` — cert expiry distribution
|
||||
- `GET /api/v1/stats/job-trends?days=N` — job completion/failure rates
|
||||
- `GET /api/v1/stats/issuance-rate?days=N` — cert issuance volume
|
||||
- Structured logging middleware: `internal/api/middleware/middleware.go`
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Configure log aggregation (e.g., ELK, Datadog, Splunk) to centralize certctl logs
|
||||
- Set up alerting on scheduler loop failures (e.g., "renewal loop failed to complete within 2h")
|
||||
- Configure health check monitoring (e.g., Prometheus scrape of `/health` and `/ready`)
|
||||
- Establish thresholds for metrics (e.g., alert if `pending_jobs > 50` or `agents_healthy < total_agents`)
|
||||
- Document your log retention policy (audit requirement often mandates 1+ years)
|
||||
- Integrate certctl metrics into your broader observability stack (Grafana dashboards, SLO tracking)
|
||||
|
||||
---
|
||||
|
||||
### CC7.2 — Anomaly Detection
|
||||
|
||||
**Requirement**: The entity monitors system components and the operation of those components for anomalies that are indicative of malfunction, including the implementation of monitoring tools, the reporting of results of those monitoring activities, and the identification, documentation, analysis, and resolution of system anomalies.
|
||||
|
||||
(This criterion overlaps CC7.1 and extends it to specific anomaly response mechanisms.)
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, path, query parameters, actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp.
|
||||
- **Audit Trail API** — `GET /api/v1/audit?actor=...&action=...&resource_id=...&created_after=...&created_before=...` allows searching for anomalous patterns (e.g., "who accessed certificate XYZ and when?", "did anyone revoke certs at 2 AM?").
|
||||
- **Expiration Threshold Alerting** — Certificate renewal policies define alert thresholds (days before expiry): default `[30, 14, 7, 0]`. When a certificate approaches a threshold, a notification is enqueued. Deduplication prevents duplicate alerts for the same cert at the same threshold. Auto status transition: cert moves to `Expiring` status at 30 days, `Expired` at 0 days.
|
||||
- **Certificate Status Auto-Transitions** — When a cert is issued, it's `Active`. As expiry approaches, status auto-transitions to `Expiring` (at 30d threshold). At expiry, status becomes `Expired`. Revoked certs move to `Revoked`. These transitions are recorded in the audit trail.
|
||||
- **Notification Routing** — Alerts are sent via configured notifiers (Email, Slack, Teams, PagerDuty, OpsGenie). Certificates are routed to their owner's email address (or team email if no individual owner). This allows on-call teams to react to anomalies (e.g., "your production cert will expire in 7 days, request renewal now").
|
||||
- **Deployment Rollback** — If a deployment fails or an older certificate needs to be reactivated, operators can trigger a "rollback" via the GUI. This redeploys a previous certificate version to the target. Rollback actions are audited.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Audit middleware: `internal/api/middleware/audit.go`
|
||||
- Audit trail API: `internal/api/handler/audit.go`, `GET /api/v1/audit`
|
||||
- Expiration alerting: `internal/service/renewal.go` (CheckRenewal method)
|
||||
- Notification dispatcher: `internal/scheduler/scheduler.go` (notificationTicker)
|
||||
- Status transitions: `internal/service/certificate.go` (auto status update logic)
|
||||
- Audit trail CLI export: `certctl-cli audit export --format csv` / `--format json`
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **SIEM Export** — Real-time audit event streaming to SIEM systems (via NATS event bus with JetStream sink)
|
||||
- **Anomaly Rules Engine** — Configurable rules (e.g., "alert if certificate revoked by non-admin", "alert if >10 certs issued in < 1 hour")
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Integrate audit trail into your SIEM / log analysis platform
|
||||
- Define alerting rules and thresholds for anomalies (e.g., "revocation of critical cert", "mass issuance")
|
||||
- Establish a formal incident response workflow (audit trail shows *what* happened; you must decide *what to do* about it)
|
||||
- Regularly review audit logs (e.g., monthly compliance audit of who accessed what)
|
||||
- Configure email/Slack/Teams integration so on-call teams are notified of cert expirations immediately
|
||||
- Encrypt audit trail backups (ACID guarantees don't prevent theft of database backups)
|
||||
|
||||
---
|
||||
|
||||
### CC7.3 — Incident Response
|
||||
|
||||
**Requirement**: The entity detects, investigates, and responds to incidents by executing a defined incident response and management process that includes preparation, detection and analysis, containment, eradication, recovery, and post-incident activities.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Revocation API** — `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes:
|
||||
- `unspecified` — catch-all
|
||||
- `keyCompromise` — private key was exposed
|
||||
- `caCompromise` — CA itself was compromised (rare)
|
||||
- `affiliationChanged` — certificate no longer applies to the organization
|
||||
- `superseded` — newer cert is in use
|
||||
- `cessationOfOperation` — service is shutting down
|
||||
- `certificateHold` — temporary revocation (can be "unhold" by reissue)
|
||||
- `privilegeWithdrawn` — access rights revoked
|
||||
Revocation is **immediate** (no approval workflow). The certificate is marked `Revoked` in inventory, an audit event is logged, and optional issuer notification is best-effort. All revoked certs are excluded from active deployments.
|
||||
- **CRL Endpoint** — `GET /api/v1/crl` returns a JSON-formatted Certificate Revocation List (serial, reason, timestamp for each revoked cert). `GET /api/v1/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA (useful for legacy clients that don't support OCSP).
|
||||
- **OCSP Responder** — `GET /api/v1/ocsp/{issuer_id}/{serial}` returns a signed OCSP response indicating whether a cert is good, revoked, or unknown. Clients (browsers, TLS libraries) query this endpoint to verify cert validity in real-time.
|
||||
- **Revocation Notifications** — When a cert is revoked, notifications are sent to:
|
||||
- Certificate owner (email)
|
||||
- Configured webhooks (if you have a SIEM that subscribes)
|
||||
- Slack/Teams channels (if notifiers are configured)
|
||||
- **Short-Lived Cert Exemption** — Certificates with TTL < 1 hour (configured in profile) skip CRL/OCSP publication. Expiry is the revocation mechanism for short-lived certs (e.g., Kubernetes pod certs, session tokens).
|
||||
- **Deployment Rollback** — If a revoked cert is still deployed (shouldn't happen, but race conditions exist), operators can manually redeploy a previous version via the GUI. Rollback is audited.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Revocation API: `internal/api/handler/certificates.go`, `POST /api/v1/certificates/{id}/revoke`
|
||||
- Revocation domain model: `internal/domain/revocation.go` (RevocationReason type with RFC 5280 mapping)
|
||||
- CRL generation: `internal/service/certificate.go` (GenerateDERCRL method)
|
||||
- OCSP signing: `internal/service/certificate.go` (GetOCSPResponse method)
|
||||
- Revocation notifications: `internal/service/notification.go` (SendRevocationNotification)
|
||||
- Short-lived exemption: `internal/domain/revocation.go` (IsShortLivedCert check)
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **Bulk Revocation** — Revoke all certs issued by a specific profile, owner, or agent in a single API call (useful for large-scale incidents like CA compromise)
|
||||
- **Revocation Automation** — Trigger revocation based on external events (e.g., employee termination, security breach alert from CT Log monitoring)
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Establish an incident response policy (e.g., "keyCompromise → immediate deployment to new cert + notify CISO")
|
||||
- Ensure CRL/OCSP are accessible to all systems using the certs (e.g., CDN or highly-available endpoints if you host on-premises)
|
||||
- Test revocation workflow in staging (verify that revoked certs are actually blocked by clients)
|
||||
- Document justification for revocation (audit trail records *that* a cert was revoked, but not *why* — you must document it separately)
|
||||
- Integrate revocation notifications into your on-call rotation (don't let revocation alerts get lost)
|
||||
|
||||
---
|
||||
|
||||
### CC7.4 — Identify and Develop Risk Mitigation Activities
|
||||
|
||||
**Requirement**: The entity identifies, develops, and implements risk mitigation activities for risks arising from potential business disruptions.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Renewal Job Tracking** — Renewal jobs track the certificate, target agents, and issuance outcome. Failed renewals are retried (configurable backoff). Job state diagram: Pending → Running → Completed (or Failed). Failed jobs trigger notifications.
|
||||
- **Agent Health Monitoring** — Health check loop (every 2m) pings all agents via heartbeat. If an agent misses 3 consecutive heartbeats, it's marked as `Unhealthy`. Unhealthy agents are excluded from new deployments.
|
||||
- **Job Cancellation** — Operators can cancel pending jobs via `POST /api/v1/jobs/{id}/cancel`. Useful when a renewal is already in progress elsewhere (multi-instance deployments) or when a certificate is being phased out.
|
||||
- **Interactive Approval** — Renewal/issuance jobs can be put in `AwaitingApproval` status. An authorized operator reviews the pending cert and approves or rejects it. Rejection records a reason in the audit trail. This provides a separation of duty between requestor and approver.
|
||||
- **Scheduled Scanning** — Agents scan configured directories for existing certs (M18b discovery). Operators triage discovered certs (claim = "we manage this now", dismiss = "this is unmanaged and we're OK with that"). Triage decisions are audited.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Job state machine: `internal/domain/job.go` (JobStatus enum)
|
||||
- Job retry logic: `internal/scheduler/scheduler.go` (jobProcessorTicker)
|
||||
- Agent health check: `internal/scheduler/scheduler.go` (healthCheckTicker)
|
||||
- Job cancellation: `internal/api/handler/jobs.go`, `POST /api/v1/jobs/{id}/cancel`
|
||||
- Approval workflow: `internal/api/handler/jobs.go`, `POST /api/v1/jobs/{id}/approve` / `reject`
|
||||
- Discovery scan results: `internal/api/handler/discovery.go`, `GET /api/v1/discovered-certificates`
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Monitor renewal job success rate (are certs being renewed before expiry?)
|
||||
- Set up alert for unhealthy agents (missing 3+ heartbeats = broken agent, take action)
|
||||
- Establish a formal approval policy (who can approve certs? do they need to involve CISO?)
|
||||
- Test job cancellation and recovery flows in staging
|
||||
- Review discovered certs regularly (are there unmanaged certs that should be managed?)
|
||||
- Document your disaster recovery process (what if control plane database is corrupted?)
|
||||
|
||||
---
|
||||
|
||||
## A1: Availability
|
||||
|
||||
### A1.1/A1.2 — Availability and Recovery
|
||||
|
||||
**Requirement**: The entity obtains or generates, uses, retains, and disposes of information to enable the entity to meet its objectives and respond to its responsibility to provide information.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Health Probes** — `/health` and `/ready` endpoints support container orchestration (Docker Compose, Kubernetes, etc.). Docker Compose defines health checks for the server and database. Kubernetes would use liveness/readiness probes pointing to these endpoints.
|
||||
- **Database Migrations (Idempotent)** — PostgreSQL migrations use `IF NOT EXISTS` and `ON CONFLICT ... DO NOTHING` patterns. Migrations can be safely reapplied — no risk of doubling data or dropping tables mid-migration.
|
||||
- **Agent Panic Recovery** — Agent binary includes panic recovery in job execution loops. If an agent crashes during a deployment, the control plane marks the job as failed and can retry on a healthy agent.
|
||||
- **Exponential Backoff** — Agent-to-server communication uses exponential backoff (starting at 1s, capped at 5m) to handle transient network failures. This prevents thundering herd when the control plane is temporarily down.
|
||||
- **Docker Compose Deployment** — Includes health checks for server and database. Services auto-restart on failure.
|
||||
- **PostgreSQL Connection Pooling** — Server uses `database/sql` with configurable `MaxOpenConns` and `MaxIdleConns` (default 25/5). Prevents connection exhaustion.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Health endpoints: `internal/api/handler/health.go`
|
||||
- Database migrations: `migrations/` directory (all use `IF NOT EXISTS`, idempotent patterns)
|
||||
- Agent panic recovery: `cmd/agent/main.go` (defer recover() in job execution)
|
||||
- Exponential backoff: `cmd/agent/main.go` (heartbeat and work poll backoff logic)
|
||||
- Connection pooling: `cmd/server/main.go` (SetMaxOpenConns, SetMaxIdleConns)
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **Multi-Region HA** — Control plane federation with etcd consensus (operator can run N replicas)
|
||||
- **PostgreSQL HA** — Replication standby with automatic failover (operator responsibility to configure)
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Configure PostgreSQL backups (e.g., WAL archiving, daily full backups). Certctl stores certificates but *also* stores renewal policies, audit trail, deployment history.
|
||||
- Test backup/restore process in staging (broken backups are discovered during incidents)
|
||||
- Monitor disk usage (PostgreSQL will fail if `/var` fills up)
|
||||
- Plan capacity (how many certs, agents, jobs can your PostgreSQL handle? Certctl is tested with 10k+ certs, 100+ agents, but your infra may differ)
|
||||
- Set up high-availability PostgreSQL if you need zero-downtime upgrades
|
||||
- Implement network segmentation (only authorized services can reach certctl API and database)
|
||||
|
||||
---
|
||||
|
||||
## CC8: Change Management
|
||||
|
||||
### CC8.1 — Change Control
|
||||
|
||||
**Requirement**: The entity identifies, selects, and develops risk mitigation activities for risks arising from potential business disruptions.
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Certificate Profiles** — Named profiles define allowed key types, max TTL, required SANs, and permitted EKUs. Changes to profiles are common (e.g., "increase max TTL from 1 year to 3 years"). All profile changes are audited (who changed what, when). Profile updates are versioned.
|
||||
- **Policy Engine** — Renewal policies define alert thresholds and approval workflows. Policy changes (e.g., "lower alert threshold from 30 days to 14 days") are audited. Policies have violation rules (e.g., "flag certs longer than 3 years") — violations are recorded in the audit trail.
|
||||
- **Target Configuration** — When a new target (NGINX server, HAProxy load balancer) is added, it's registered with a name and configuration (JSON). Target deletions require confirmation (to prevent accidental removal). All target changes are audited.
|
||||
- **Immutable Audit Trail** — Every change (profile, policy, target, cert, agent, owner, team, approval, revocation, deployment) is recorded in `audit_events`. Audit records are append-only; no retroactive modification is possible. Audit trail is encrypted at rest (operator responsibility).
|
||||
- **GitHub Actions CI** — Pull requests must pass:
|
||||
- Go unit tests (`go test ./...`) with coverage gates (service layer ≥30%, handler layer ≥50%)
|
||||
- Go vet (static analysis)
|
||||
- Frontend TypeScript type checking (`tsc`)
|
||||
- Frontend Vitest unit tests
|
||||
- Frontend Vite build (ensures no broken imports)
|
||||
Only after all checks pass can the PR be merged and deployed.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
- Profile CRUD: `internal/api/handler/profiles.go`, `GET /api/v1/profiles` / `POST` / `PUT` / `DELETE`
|
||||
- Policy CRUD: `internal/api/handler/policies.go`
|
||||
- Target CRUD: `internal/api/handler/targets.go`
|
||||
- Audit trail: `internal/api/handler/audit.go`, `GET /api/v1/audit` (records action, actor, resource_id, timestamp)
|
||||
- CI configuration: `.github/workflows/ci.yml` (test, vet, coverage gates, build checks)
|
||||
|
||||
**V3 Enhancement**:
|
||||
|
||||
- **Change Approval Workflow** — Optional approval gate before profile/policy changes go live
|
||||
- **Feature Flags** — Enable/disable new features without redeployment (backward compatibility during rolling upgrades)
|
||||
|
||||
**Operator Responsibility**:
|
||||
|
||||
- Implement formal change control (ticket system, approval, peer review)
|
||||
- Document the business justification for profile/policy changes
|
||||
- Test changes in a non-production environment before deploying to production
|
||||
- Have a rollback plan (can you revert a profile change instantly if it breaks issuance?)
|
||||
- Include certctl configuration changes in your change log (for audits and incident investigations)
|
||||
- Version control your certctl configuration (Docker Compose file, environment variables) so you can track changes
|
||||
|
||||
---
|
||||
|
||||
## Evidence Summary Table
|
||||
|
||||
| SOC 2 Criterion | certctl Feature | Evidence Location | V2 (Free) | V3 (Pro) | Operator Responsibility |
|
||||
|---|---|---|---|---|---|
|
||||
| **CC6.1** Logical Access Security | API Key Authentication (SHA-256 hashed, constant-time comparison) | `internal/api/middleware/auth.go` | ✅ | Enhanced | API key generation, distribution, rotation |
|
||||
| | GUI Login with API Key | `web/src/pages/LoginPage.tsx` | ✅ | Enhanced (OIDC) | NA |
|
||||
| | CORS Allowlist | `CERTCTL_CORS_ORIGINS` env var | ✅ | ✅ | Configure appropriately |
|
||||
| | Token Bucket Rate Limiting | `internal/api/middleware/rate_limit.go` | ✅ | ✅ | Monitor for brute-force attempts |
|
||||
| **CC6.2** Prior to Issuing System Credentials | Ownership Attribution | `GET /api/v1/owners`, audit trail records owner assignment | ✅ | Enhanced (RBAC) | Map to org structure, remove on departure |
|
||||
| | Team Assignment | `GET /api/v1/teams` | ✅ | ✅ | NA |
|
||||
| | Actor Attribution in Audit Trail | `GET /api/v1/audit` (actor field) | ✅ | ✅ | Justify all changes via separate documentation |
|
||||
| **CC6.3** Authentication Policies | API Key Enforcement | `CERTCTL_AUTH_TYPE=api-key` (default) | ✅ | Enhanced (OIDC, MFA) | Document policy, test failures, integrate into IAM audit |
|
||||
| | Agent Authentication | Separate API keys for agents | ✅ | ✅ | Rotate agent keys, monitor compromise |
|
||||
| | Agent-Side Key Generation | `CERTCTL_KEYGEN_MODE=agent` (default) | ✅ | ✅ | Protect agent filesystem keys via encryption/backup |
|
||||
| | Private Key Policy | Server-side keygen logs warning, disabled in production | ✅ | ✅ | Never use server-side keygen in production |
|
||||
| **CC6.7** Information Transmission Protection | TLS for Control Plane | Deploy behind TLS-terminating reverse proxy | ✅ | ✅ | Enable TLS in production via reverse proxy |
|
||||
| | Agent-to-Server HTTPS | Agents use HTTPS for all API calls | ✅ | ✅ | Enforce TLS via firewall rules |
|
||||
| | Private Key Isolation | Agent-side keygen (ECDSA P-256), keys stored 0600 on agent FS | ✅ | ✅ | Encrypt agent filesystems, backup securely |
|
||||
| | Pull-Only Deployment | Server never initiates outbound to agents/targets | ✅ | Enhanced (HSM, proxy agents) | Encrypt agent↔target comms, isolate proxy agents |
|
||||
| **CC7.1** System Monitoring | Health Endpoint | `GET /health`, `GET /ready` | ✅ | ✅ | Integrate into monitoring (Prometheus, DataDog) |
|
||||
| | 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 |
|
||||
| **CC7.2** Anomaly Detection | Immutable API Audit Trail | `internal/api/middleware/audit.go`, `GET /api/v1/audit` | ✅ | Enhanced (SIEM export) | Integrate into SIEM, search for anomalies, archive long-term |
|
||||
| | Expiration Threshold Alerting | Configurable per-policy (default 30/14/7/0 days) | ✅ | ✅ | Configure thresholds, integrate notifications |
|
||||
| | Status Auto-Transitions | Active → Expiring (30d) → Expired (0d) | ✅ | ✅ | Monitor status changes in audit trail |
|
||||
| | Notification Routing | Email, Slack, Teams, PagerDuty, OpsGenie | ✅ | ✅ | Configure notifiers, on-call integration |
|
||||
| | Deployment Rollback | Redeploy previous cert version via GUI | ✅ | ✅ | Audit rollback decisions |
|
||||
| **CC7.3** Incident Response | Revocation API (RFC 5280 reasons) | `POST /api/v1/certificates/{id}/revoke` | ✅ | Enhanced (bulk revocation) | Establish incident response policy |
|
||||
| | CRL Endpoint (JSON + DER) | `GET /api/v1/crl`, `GET /api/v1/crl/{issuer_id}` | ✅ | ✅ | Ensure CRL/OCSP accessible to all clients |
|
||||
| | OCSP Responder | `GET /api/v1/ocsp/{issuer_id}/{serial}` | ✅ | ✅ | Test revocation in staging |
|
||||
| | Revocation Notifications | Email, webhook, Slack/Teams on revocation | ✅ | ✅ | Integrate into on-call, document justification separately |
|
||||
| | Short-Lived Cert Exemption | TTL < 1h skip CRL/OCSP | ✅ | ✅ | Configure profiles appropriately |
|
||||
| **CC7.4** Risk Mitigation | Renewal Job Tracking | Job state machine (Pending → Running → Completed/Failed) | ✅ | ✅ | Monitor renewal success rate |
|
||||
| | Agent Health Monitoring | Health check loop (ping every 2m, mark unhealthy after 3 misses) | ✅ | ✅ | Alert on unhealthy agents, investigate |
|
||||
| | Job Cancellation | `POST /api/v1/jobs/{id}/cancel` | ✅ | ✅ | Test in staging |
|
||||
| | Interactive Approval | AwaitingApproval state, `POST /api/v1/jobs/{id}/approve\|reject` | ✅ | ✅ | Define approval policy, audit decisions |
|
||||
| | Certificate Discovery | Agents scan directories, triage (claim/dismiss) | ✅ | ✅ | Review discovered certs regularly |
|
||||
| **A1.1/A1.2** Availability and Recovery | Health Probes (Docker, Kubernetes) | `/health` and `/ready` endpoints | ✅ | ✅ | Use in container orchestration |
|
||||
| | Idempotent Migrations | `IF NOT EXISTS`, `ON CONFLICT ... DO NOTHING` | ✅ | ✅ | Test migration replay in staging |
|
||||
| | Agent Panic Recovery | Panic recovery in job loops | ✅ | ✅ | Monitor agent crashes in logs |
|
||||
| | Exponential Backoff | Agent heartbeat/work poll backoff (1s → 5m) | ✅ | ✅ | Monitor for control plane downtime |
|
||||
| | PostgreSQL Connection Pooling | MaxOpenConns=25, MaxIdleConns=5 (configurable) | ✅ | ✅ | Monitor connection usage |
|
||||
| **CC8.1** Change Control | Certificate Profiles | CRUD API + GUI, profile changes audited | ✅ | ✅ | Formal change control, test in staging |
|
||||
| | Policy Engine + Violations | CRUD API + GUI, policy changes audited | ✅ | ✅ | Document justification, implement approval workflow |
|
||||
| | Target Registration | CRUD API + GUI, changes audited | ✅ | ✅ | Confirm deletions, version control config |
|
||||
| | Immutable Audit Trail | Append-only `audit_events` table | ✅ | ✅ | Encrypt at rest, archive long-term, no manual edits |
|
||||
| | GitHub Actions CI | Unit tests, vet, coverage gates, build checks | ✅ | ✅ | Review PRs before merge, maintain test quality |
|
||||
|
||||
---
|
||||
|
||||
## What Requires Operator Action
|
||||
|
||||
**certctl is a tool, not a complete compliance solution.** Your organization must handle:
|
||||
|
||||
1. **Physical Security** — Protect the infrastructure (servers, network) running certctl. Certctl can't control who has physical access to your datacenter.
|
||||
|
||||
2. **Personnel Background Checks** — Before granting anyone API key access, conduct background checks per your policy. Certctl records *who* accessed *what*, but doesn't verify that people are trustworthy.
|
||||
|
||||
3. **Formal Incident Response Plan** — Certctl provides incident detection (anomalies in audit trail) and tools for response (revocation, rollback), but you must define *when* to use them and *who* decides.
|
||||
|
||||
4. **Access Review and Removal** — Certctl stores ownership, teams, and API keys. You must:
|
||||
- Regularly review who has access (quarterly or semi-annually)
|
||||
- Immediately revoke API keys for departing employees
|
||||
- Audit that removed access is actually removed (test that old keys fail)
|
||||
|
||||
5. **Log Retention and Archival** — Certctl logs to stdout (Docker) and stores audit events in PostgreSQL. You must:
|
||||
- Ship logs to a long-term archive (SIEM, S3, or equivalent)
|
||||
- Define retention policy (often 1-7 years per industry regulation)
|
||||
- Encrypt archived logs
|
||||
- Test that you can retrieve logs from archive (restoration drills)
|
||||
|
||||
6. **Encryption at Rest** — PostgreSQL data (including audit trail) is stored on disk. You must:
|
||||
- Enable transparent data encryption (TDE) on your database VM
|
||||
- Encrypt container persistent volumes (if using Kubernetes)
|
||||
- Encrypt database backups
|
||||
|
||||
7. **Network Segmentation** — Certctl API and database must be protected by network access controls. You must:
|
||||
- Firewall the control plane (only authorized services can connect)
|
||||
- Use VPN or private networks for agent-to-server communication
|
||||
- Isolate proxy agents (for F5, IIS, etc.) in the same network zone as their targets
|
||||
|
||||
8. **Capacity Planning** — Certctl's performance scales with your PostgreSQL. You must:
|
||||
- Estimate certificate inventory size (10k, 100k, 1M certs?)
|
||||
- Test Certctl with your expected scale in staging
|
||||
- Monitor disk usage, CPU, memory
|
||||
- Plan for growth (add PostgreSQL replicas, increase connection pool, etc.)
|
||||
|
||||
9. **Disaster Recovery** — Certctl data lives in PostgreSQL. You must:
|
||||
- Back up PostgreSQL regularly (daily or hourly, depending on RPO)
|
||||
- Test restore process in staging (broken backups discovered during incidents)
|
||||
- Have a runbook for failover to replica or recovery from backup
|
||||
- Document RTO/RPO targets (how long can cert management be down? how much data can you afford to lose?)
|
||||
|
||||
10. **Integration with Your IAM** — If using OIDC/SSO (V3), you must:
|
||||
- Configure your OIDC provider (Okta, Azure AD, Google)
|
||||
- Map user groups to Certctl roles (Admin, Operator, Viewer)
|
||||
- Manage MFA policy (enforce MFA if required)
|
||||
- Audit user provisioning/deprovisioning
|
||||
|
||||
11. **Documentation and Runbooks** — Certctl documents *what it does* (this guide), but you must document:
|
||||
- Your organization's certificate lifecycle policy (who requests, who approves, who deploys)
|
||||
- How to respond to specific incidents (cert compromise, CA compromise, agent down, renewal failed)
|
||||
- How to operate certctl (day-to-day tasks, escalation procedures)
|
||||
- Contact info for on-call teams
|
||||
|
||||
---
|
||||
|
||||
## V3 Enhancements
|
||||
|
||||
**certctl Pro (V3, paid edition) adds features that significantly strengthen SOC 2 evidence:**
|
||||
|
||||
- **OIDC / SSO Integration** — Integrate with Okta, Azure AD, Google to replace API keys with federated identity. Enables MFA enforcement and centralized access management. Auditors love federated identity (easier to remove access at source).
|
||||
|
||||
- **Role-Based Access Control (RBAC)** — Predefined roles (Admin: full access; Operator: issue/renew/revoke, no policy changes; Viewer: read-only) with profile-gated enforcement. Allows separation of duties (e.g., junior operator can't change global policy).
|
||||
|
||||
- **NATS Event Bus** — Real-time audit streaming to your SIEM. Hybrid model: HTTP for synchronous APIs, NATS for async events (cert.issued, cert.expiring, agent.heartbeat, job.completed). JetStream persistence for replay and durability.
|
||||
|
||||
- **SIEM Export** — Automated export of audit trail to Splunk, ELK, DataDog, etc. (webhooks, syslog, or pull-based APIs). Makes it easy for security teams to hunt for anomalies.
|
||||
|
||||
- **Advanced Search DSL** — `POST /api/v1/search` with tree-based filters (nested AND/OR, regex, field projection). Enables complex compliance queries (e.g., "all certs issued in the last 30 days by team X that are longer than 1 year").
|
||||
|
||||
- **Bulk Revocation** — Revoke all certs issued by a profile, owner, or agent in one operation. Critical for large-scale incidents (e.g., "a team's CA key was compromised, revoke all their certs").
|
||||
|
||||
- **Certificate Health Scores** — Composite risk scoring (e.g., "this cert has no short-lived TTL enforcement, extends past your policy max, and hasn't been renewed in 2 years" → health=30%). Helps prioritize remediation.
|
||||
|
||||
- **Compliance Scoring** — Audit readiness reporting per certificate (e.g., "compliance=95% — missing only a 3-year max-TTL constraint"). Exportable compliance report.
|
||||
|
||||
- **DigiCert Issuer Connector** — OV/EV certificate issuance for public-facing services (web servers, CDNs). Complements Local CA for internal use.
|
||||
|
||||
- **CT Log Monitoring** — Passive detection of unauthorized cert issuance. Monitors public CT logs for certs matching your domains and alerts if unexpected certs appear (e.g., attacker obtained a cert for your domain).
|
||||
|
||||
- **F5 BIG-IP Implementation** — Full target connector with iControl REST API. Agents can deploy certs to F5 load balancers.
|
||||
|
||||
- **IIS Implementation** — Dual-mode: agent-local PowerShell (default) for servers with agents, or proxy agent WinRM (agentless targets). Full Windows Server integration.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
certctl provides a strong foundation for SOC 2 compliance with API key authentication, immutable audit logging, automated alerting, and revocation capabilities. However, SOC 2 audits require evidence across your entire infrastructure — certctl is one piece. Use this guide to map certctl features to your audit questionnaire, then work with your auditors to identify gaps that must be filled by your own organizational policies and controls.
|
||||
|
||||
For a deeper SOC 2 discussion or a mock audit against this guide, contact your certctl Pro support team.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Compliance Mapping Guides
|
||||
|
||||
certctl is a certificate lifecycle management tool, not a compliance product. It doesn't make you compliant — your organization, policies, and processes do that. What certctl provides is tooling that supports the technical controls auditors and evaluators look for when assessing certificate and key management practices.
|
||||
|
||||
These guides map certctl's features to three widely referenced compliance frameworks. They're designed for security engineers, IT auditors, and procurement teams evaluating certctl for environments with regulatory requirements.
|
||||
|
||||
## What's Covered
|
||||
|
||||
**[SOC 2 Type II](compliance-soc2.md)** — Maps certctl features to AICPA Trust Service Criteria. Covers logical access controls (CC6), system operations and monitoring (CC7), change management (CC8), and availability (A1). Most relevant for organizations undergoing SOC 2 audits where certificate management is in scope.
|
||||
|
||||
**[PCI-DSS 4.0](compliance-pci-dss.md)** — Maps certctl features to PCI Data Security Standard version 4.0 requirements. Covers data-in-transit protection (Req 4), cryptographic key management (Req 3), authentication (Req 8), audit logging (Req 10), secure development (Req 6), and access control (Req 7). Most relevant for organizations handling cardholder data where TLS certificates protect transmission channels.
|
||||
|
||||
**[NIST SP 800-57](compliance-nist.md)** — Maps certctl's key management practices to NIST Special Publication 800-57 Part 1 Rev 5 (2020). Covers key generation, storage, cryptoperiods, key state lifecycle, algorithm selection, key transport, and revocation. Most relevant for organizations aligning with US federal cryptographic guidance or using NIST as a key management baseline.
|
||||
|
||||
## What These Guides Are Not
|
||||
|
||||
These are mapping guides, not certification claims. certctl is not SOC 2 certified, PCI-DSS validated, or NIST-assessed. The guides document how certctl's technical implementation supports the controls these frameworks require — they do not replace your auditor's assessment, your organization's policies, or your security team's judgment.
|
||||
|
||||
The guides also clearly identify gaps where certctl's current implementation doesn't fully align with a framework's recommendations, features planned for future versions, and areas where operator action is required regardless of what certctl provides.
|
||||
|
||||
## How to Use These Guides
|
||||
|
||||
If you're evaluating certctl for a regulated environment, start with the framework your auditor cares about. Each guide includes an evidence summary table mapping specific compliance criteria to certctl features, API endpoints, and configuration — the kind of specifics your auditor will ask for.
|
||||
|
||||
If you're preparing for an audit and certctl is already deployed, use the "Operator Responsibilities" section of each guide to identify what your organization must manage beyond what certctl provides.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Framework | Primary Concern | Key certctl Features |
|
||||
|---|---|---|
|
||||
| SOC 2 Type II | Trust service criteria for SaaS/infrastructure | API audit trail, auth controls, monitoring, change management |
|
||||
| PCI-DSS 4.0 | Cardholder data protection | TLS lifecycle, key management, immutable logging, access control |
|
||||
| NIST SP 800-57 | Cryptographic key management | Agent-side keygen, key isolation, algorithm selection, revocation |
|
||||
|
||||
## certctl Pro (V3) Enhancements
|
||||
|
||||
Several compliance-relevant features are planned for certctl Pro:
|
||||
|
||||
- **OIDC/SSO** — Enterprise identity provider integration (SOC 2 CC6.1, PCI-DSS 8.3)
|
||||
- **RBAC** — Role-based access control with admin/operator/viewer roles (SOC 2 CC6.3, PCI-DSS 7.2)
|
||||
- **NATS Audit Streaming** — Real-time audit event streaming to SIEM systems (SOC 2 CC7.2, PCI-DSS 10.2)
|
||||
- **Bulk Revocation** — Fleet-wide incident response capability (NIST SP 800-57 Section 5.4)
|
||||
- **Health/Compliance Scoring** — Automated compliance posture assessment per certificate
|
||||
+90
-11
@@ -1,6 +1,6 @@
|
||||
# Understanding Certificates: A Beginner's Guide
|
||||
|
||||
If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why managing them at scale is hard enough to need a tool like certctl.
|
||||
If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why the industry's move toward shorter certificate lifespans — down to 47 days by 2029 — makes automated lifecycle management essential.
|
||||
|
||||
## What Is a TLS Certificate?
|
||||
|
||||
@@ -12,11 +12,15 @@ Think of it like a notarized ID badge for a website. The badge says "I am api.ex
|
||||
|
||||
## Why Do Certificates Expire?
|
||||
|
||||
Every certificate has an expiration date, typically 90 days for Let's Encrypt or up to 1 year for commercial CAs. This isn't a bug — it's a security feature. Short lifetimes limit the damage if a private key is compromised, and they force organizations to prove they still control their domains.
|
||||
Every certificate has an expiration date. This isn't a bug — it's a security feature. Short lifetimes limit the damage if a private key is compromised, and they force organizations to prove they still control their domains.
|
||||
|
||||
The problem? When you have 5 certificates, tracking expiry dates is trivial. When you have 500 certificates spread across NGINX servers, F5 load balancers, and IIS boxes in three environments, it becomes a ticking time bomb. One missed renewal means a production outage — your site goes down, your API returns errors, and your customers see scary browser warnings.
|
||||
Certificate lifespans have been shrinking steadily. A decade ago, certificates lasted up to 5 years. Then the CA/Browser Forum — the industry body that sets certificate rules — reduced the maximum to 3 years, then 2 years, then 398 days. In April 2025, they passed Ballot SC-081v3 with zero opposition (25 CAs in favor, 5 abstentions, all 4 browser vendors in favor), setting a phased reduction to **200 days** (March 2026), **100 days** (March 2027), and **47 days** (March 2029). Let's Encrypt already issues 90-day certificates by default.
|
||||
|
||||
**This is the core problem certctl solves**: automated tracking, renewal, and deployment of certificates across your entire infrastructure.
|
||||
The trend is clear: shorter lifespans, more frequent renewals, and zero tolerance for manual processes.
|
||||
|
||||
When you have 5 certificates, tracking expiry dates is trivial. When you have 500 certificates spread across NGINX servers, Apache instances, HAProxy load balancers, F5 appliances, and IIS boxes in three environments — and each certificate needs renewal every 47 days — manual management becomes impossible. One missed renewal means a production outage: your site goes down, your API returns errors, and your customers see browser warnings.
|
||||
|
||||
**This is the core problem certctl solves**: end-to-end automation of the certificate lifecycle — issuance, renewal, and deployment — across your entire infrastructure, with no human intervention required.
|
||||
|
||||
## The Cast of Characters
|
||||
|
||||
@@ -26,11 +30,13 @@ A CA is the trusted third party that signs your certificates. When a CA signs a
|
||||
|
||||
Common CAs include Let's Encrypt (free, automated), DigiCert, Sectigo, and your organization's internal/private CA. Each issues certificates through different protocols and APIs.
|
||||
|
||||
certctl includes a built-in **Local CA** that can operate in two modes: self-signed (default, for development and demos) or as a **subordinate CA** under an enterprise root like Active Directory Certificate Services (ADCS). In sub-CA mode, you load a CA certificate and key signed by your enterprise root, and all certificates certctl issues automatically chain to the enterprise trust hierarchy — no manual trust configuration needed on clients that already trust your enterprise root. certctl also integrates with **step-ca** (Smallstep's private CA) via its native /sign API, providing a lightweight alternative to ACME for internal PKI.
|
||||
|
||||
### ACME Protocol
|
||||
|
||||
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01) or creating a DNS record (DNS-01).
|
||||
|
||||
certctl speaks ACME natively via HTTP-01 challenges, so it can request certificates from Let's Encrypt or any ACME-compatible CA without manual intervention. DNS-01 challenge support (required for wildcard certificates) is planned for V2.
|
||||
certctl speaks ACME natively with both HTTP-01 and DNS-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
||||
|
||||
### Private Key
|
||||
|
||||
@@ -58,7 +64,7 @@ The control plane never touches private keys. It coordinates the certificate lif
|
||||
|
||||
### Agents
|
||||
|
||||
Agents are lightweight processes that run on or near your infrastructure. They do the actual work: generating private keys, creating Certificate Signing Requests (CSRs), receiving signed certificates, and deploying them to servers. An agent might run on the same machine as your NGINX server, or on a management host that has SSH access to your web servers.
|
||||
Agents are lightweight processes that run on or near your infrastructure. They do the actual work: generating private keys, creating Certificate Signing Requests (CSRs), receiving signed certificates, and deploying them to target systems. An agent typically runs on the same machine as the target (e.g., your NGINX or IIS server), deploying certificates locally. For network appliances where you can't install an agent, a proxy agent in the same network zone handles deployment via the appliance's API.
|
||||
|
||||
The flow looks like this:
|
||||
|
||||
@@ -72,9 +78,13 @@ The flow looks like this:
|
||||
|
||||
At no point does the private key leave the agent. This is a fundamental security property.
|
||||
|
||||
Agents also report **metadata** about themselves — their operating system, CPU architecture, IP address, hostname, and version — with every heartbeat. This gives ops teams fleet-wide visibility (e.g., "how many agents are running on ARM?", "which agents are still on v1.0.0?") and powers **agent groups** — dynamic device grouping where policies can be scoped to specific agent criteria like OS type, architecture, or network subnet.
|
||||
|
||||
### Deployment Targets
|
||||
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, F5 BIG-IP load balancers, Microsoft IIS servers. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX config, calling the F5 REST API, running PowerShell commands on IIS via WinRM).
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, Apache httpd servers, HAProxy load balancers, F5 BIG-IP appliances, Microsoft IIS servers. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX or Apache config, building a combined PEM for HAProxy).
|
||||
|
||||
For targets where an agent runs directly on the machine (NGINX, Apache, HAProxy, IIS), the agent deploys certificates locally — no remote access needed. For network appliances where you can't install an agent (F5 BIG-IP, Palo Alto, etc.), a **proxy agent** in the same network zone picks up the deployment job and calls the appliance's API. The server never initiates outbound connections to any target.
|
||||
|
||||
## The Certificate Lifecycle
|
||||
|
||||
@@ -112,7 +122,45 @@ certctl is for organizations that need visibility, automation, and accountabilit
|
||||
|
||||
### Teams and Owners
|
||||
|
||||
Every certificate belongs to a **team** and has an **owner**. This answers the question "whose problem is it when this cert expires?" In a large organization, the platform team might own infrastructure certs while the payments team owns payment gateway certs.
|
||||
Every certificate belongs to a **team** and has an **owner**. This answers the question "whose problem is it when this cert expires?" In a large organization, the platform team might own infrastructure certs while the payments team owns payment gateway certs. Notifications are routed to the owner's email address automatically.
|
||||
|
||||
### Agent Groups
|
||||
|
||||
Agent groups let you organize agents by criteria — OS, architecture, IP subnet, or version — for dynamic policy scoping. For example, you can create a group matching all Linux agents and scope a renewal policy to that group. Groups can use dynamic matching criteria (agents automatically join when they match) or manual membership (explicitly include/exclude specific agents). Agent groups are managed via the GUI and API.
|
||||
|
||||
### Certificate Profiles
|
||||
|
||||
Certificate profiles define the cryptographic and lifecycle constraints for a class of certificates. A profile specifies which key types are allowed (e.g., RSA-2048, ECDSA P-256), the maximum validity period, and other enrollment rules. When a certificate is assigned to a profile, certctl enforces these constraints during issuance — if an agent submits a CSR with a disallowed key type, issuance is rejected.
|
||||
|
||||
Profiles answer the question "what kind of certificate is this?" while policies answer "is this certificate compliant?" A production TLS profile might allow only ECDSA P-256 with a 90-day max TTL, while a development profile might allow RSA-2048 with a 365-day TTL. Short-lived profiles (TTL under 1 hour) enable machine-to-machine authentication patterns where certificates are issued frequently and expire quickly — these are exempt from CRL/OCSP since expiry itself is sufficient revocation.
|
||||
|
||||
Profiles are managed via the API (`/api/v1/profiles`) and the GUI, and can be assigned to certificates during creation or updated later.
|
||||
|
||||
### Interactive Renewal Approval
|
||||
|
||||
For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal.
|
||||
|
||||
### Certificate Revocation
|
||||
|
||||
When a private key is compromised, a certificate is superseded, or a service is decommissioned, you need to revoke the certificate immediately — not wait for it to expire. Revocation tells clients "stop trusting this certificate right now."
|
||||
|
||||
certctl implements revocation using three complementary mechanisms:
|
||||
|
||||
**Revocation API**: `POST /api/v1/certificates/{id}/revoke` marks a certificate as revoked in the inventory, records the revocation in a dedicated `certificate_revocations` table, notifies the issuing CA (best-effort — the revocation succeeds even if the CA is unreachable), creates an audit trail entry, and sends notifications. You can specify an RFC 5280 reason code (keyCompromise, superseded, cessationOfOperation, etc.) or let it default to "unspecified."
|
||||
|
||||
**Certificate Revocation List (CRL)**: certctl serves both a JSON-formatted CRL at `GET /api/v1/crl` and DER-encoded X.509 CRLs per issuer at `GET /api/v1/crl/{issuer_id}`. The DER CRL is signed by the issuing CA's key and has 24-hour validity — clients can download it periodically to check revocation status offline.
|
||||
|
||||
**OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}`. It returns signed OCSP responses (good, revoked, or unknown) so clients can verify certificate status without downloading the full CRL.
|
||||
|
||||
Short-lived certificates (those assigned to profiles with TTL under 1 hour) are exempt from CRL and OCSP — their rapid expiry is considered sufficient revocation. This is a deliberate design choice to reduce infrastructure overhead for ephemeral machine-to-machine credentials.
|
||||
|
||||
### Short-Lived Certificates
|
||||
|
||||
Short-lived certificates are certificates with a TTL under 1 hour, typically used for service-to-service authentication in microservice architectures. Instead of revoking these certificates when something goes wrong, you simply stop issuing new ones — the existing certificates expire within minutes.
|
||||
|
||||
certctl provides a dedicated dashboard view for short-lived credentials that shows active certificates with live TTL countdowns, auto-refreshes every 10 seconds, and filters by profile. This gives ops teams real-time visibility into ephemeral credential activity without cluttering the main certificate inventory.
|
||||
|
||||
Short-lived certificates are defined by their profile — assign a certificate to a profile with `max_validity_days` that translates to under 1 hour, and certctl automatically treats it as short-lived: no CRL/OCSP entries, no revocation overhead, just rapid issuance and natural expiry.
|
||||
|
||||
### Policies
|
||||
|
||||
@@ -120,7 +168,7 @@ Policies are guardrails. You can enforce rules like "production certificates mus
|
||||
|
||||
### Jobs
|
||||
|
||||
Every action in certctl — issuing a certificate, renewing one, deploying to a target — is tracked as a **job**. Jobs have states (Pending, Running, Completed, Failed, Cancelled), retry logic, and a full audit trail. If a deployment fails, you can see exactly what happened and when.
|
||||
Every action in certctl — issuing a certificate, renewing one, deploying to a target — is tracked as a **job**. Jobs have states (Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled), retry logic, and a full audit trail. AwaitingCSR means the job is waiting for an agent to generate a key and submit a CSR. AwaitingApproval means the job requires human approval before proceeding (used with non-auto-renew policies). If a deployment fails, you can see exactly what happened and when.
|
||||
|
||||
### Audit Trail
|
||||
|
||||
@@ -128,10 +176,41 @@ Every action is logged: who did it, what changed, when, and why. This is essenti
|
||||
|
||||
### Notifications
|
||||
|
||||
certctl can alert you when certificates are expiring, when renewals fail, when deployments succeed, or when policy violations are detected. Notifications go out via email or webhooks, with Slack support planned.
|
||||
certctl can alert you when certificates are expiring, when renewals fail, when deployments succeed, or when policy violations are detected. Notifications are delivered via six channels: Email, Webhook, Slack, Microsoft Teams, PagerDuty, and OpsGenie. Each notifier is configured independently via environment variables and can be enabled or disabled as needed.
|
||||
|
||||
### CLI
|
||||
|
||||
certctl ships with a command-line tool (`certctl-cli`) for operators who prefer terminal workflows or need to integrate certctl into shell scripts and CI/CD pipelines. The CLI wraps the REST API with 10 subcommands: `list-certs`, `get-cert`, `renew-cert`, `revoke-cert`, `list-agents`, `list-jobs`, `health`, `metrics`, and `import` (for bulk PEM import).
|
||||
|
||||
The CLI supports both table and JSON output formats (`--format table` or `--format json`), connects to the server via `CERTCTL_SERVER_URL` and authenticates with `CERTCTL_API_KEY`. It's built with Go's standard library only — no external dependencies.
|
||||
|
||||
### MCP Server (AI Integration)
|
||||
|
||||
certctl includes an MCP (Model Context Protocol) server that exposes all 78 API endpoints as MCP tools. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?"
|
||||
|
||||
The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key.
|
||||
|
||||
### Certificate Discovery
|
||||
|
||||
Certificate discovery is the process of automatically finding existing certificates in your infrastructure — certificates you didn't issue through certctl, possibly issued by other CAs or tools. This is essential for building a complete inventory before you can manage everything.
|
||||
|
||||
**How it works:** There are two discovery modes. *Filesystem discovery* — agents scan configured directories (configured via `CERTCTL_DISCOVERY_DIRS`) for certificate files. On startup and every 6 hours, the agent walks directories recursively, parses PEM and DER files, extracts metadata, and reports findings to the control plane. *Network discovery* — the control plane itself probes TLS endpoints across configured CIDR ranges and ports (enabled via `CERTCTL_NETWORK_SCAN_ENABLED=true`). It connects to each endpoint, extracts certificates from the TLS handshake, and feeds results into the same discovery pipeline. This finds certificates on services you may not have agents on. In both cases, the server deduplicates by fingerprint and stores discovered certs with a status: **Unmanaged** (discovered but not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage it).
|
||||
|
||||
This gives you a three-step triage workflow:
|
||||
1. **Discover** — Agents find all existing certs on your infrastructure
|
||||
2. **Triage** — Operators review discoveries and decide: claim it (enroll for management), or dismiss it (not worth managing)
|
||||
3. **Baseline** — Once triaged, you have a complete baseline of what's deployed, what you're managing, and what's unmanaged
|
||||
|
||||
This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter.
|
||||
|
||||
### Observability
|
||||
|
||||
certctl exposes metrics in two formats: a JSON endpoint at `GET /api/v1/metrics` and a Prometheus exposition format at `GET /api/v1/metrics/prometheus` (compatible with Prometheus, Grafana Agent, Datadog Agent, and Victoria Metrics). Both provide gauges (certificate totals by status, agent counts, pending jobs), counters (completed/failed jobs), and uptime. Five stats endpoints power the dashboard charts: summary statistics, certificates by status, expiration timeline, job trends, and issuance rate.
|
||||
|
||||
The agent fleet overview page groups agents by OS, architecture, and version, showing distribution charts that help ops teams track fleet health and identify outdated agents. All API requests are logged via structured `slog` middleware with request IDs for correlation.
|
||||
|
||||
## What's Next
|
||||
|
||||
Now that you understand the concepts, head to the [Quick Start Guide](quickstart.md) to get certctl running locally in under 5 minutes. You'll see a pre-loaded dashboard with demo certificates, explore the API, and understand how everything fits together.
|
||||
|
||||
For a deeper look at the system design, see the [Architecture Guide](architecture.md).
|
||||
For a deeper look at the system design, see the [Architecture Guide](architecture.md). For terminal-based workflows, check out the CLI Guide (docs coming soon). For AI-native integration, see the [MCP Server Guide](mcp.md). For the full API reference, see the [OpenAPI Spec Guide](openapi.md).
|
||||
|
||||
+306
-37
@@ -6,11 +6,11 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
|
||||
Three types of connectors:
|
||||
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA, ACME implemented; step-ca, ADCS, OpenSSL planned V2; DigiCert, Entrust, GlobalSign, EJBCA, Vault PKI, Google CAS planned V3)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX implemented; F5, IIS interface only; Apache httpd, HAProxy planned V2; AWS ALB, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets planned V3)
|
||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks; Slack, Teams, PagerDuty, OpsGenie planned V2.1)
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
||||
|
||||
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents.
|
||||
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
||||
|
||||
## Issuer Connector
|
||||
|
||||
@@ -58,38 +58,44 @@ type RenewalRequest struct {
|
||||
CommonName string
|
||||
SANs []string
|
||||
CSRPEM string
|
||||
OrderID string // optional, for tracking
|
||||
OrderID *string // optional, for tracking (pointer — nil when not provided)
|
||||
}
|
||||
|
||||
type RevocationRequest struct {
|
||||
Serial string
|
||||
Reason string // optional
|
||||
Reason *string // optional (pointer — nil when not provided)
|
||||
}
|
||||
|
||||
type OrderStatus struct {
|
||||
OrderID string
|
||||
Status string // "pending", "valid", "invalid", "expired"
|
||||
Message string
|
||||
CertPEM string
|
||||
ChainPEM string
|
||||
Serial string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
Status string // "pending", "valid", "invalid", "expired"
|
||||
Message *string // optional (pointer fields are omitted from JSON when nil)
|
||||
CertPEM *string // populated when order is complete
|
||||
ChainPEM *string // populated when order is complete
|
||||
Serial *string // populated when order is complete
|
||||
NotBefore *time.Time // populated when order is complete
|
||||
NotAfter *time.Time // populated when order is complete
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
### Built-in: Local CA
|
||||
|
||||
The Local CA issuer generates self-signed certificates using Go's `crypto/x509` library. It creates a CA on first use (in memory), issues certificates with proper serial numbers, validity periods, SANs, and key usage extensions.
|
||||
The Local CA issuer signs certificates using Go's `crypto/x509` library. It supports two modes:
|
||||
|
||||
This issuer is designed for development and demos only — certificates are self-signed and not trusted by browsers.
|
||||
**Self-signed mode (default):** Creates a CA on first use (in memory), issues certificates with proper serial numbers, validity periods, SANs, and key usage extensions. Designed for development and demos — certificates are self-signed and not trusted by browsers.
|
||||
|
||||
**Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`.
|
||||
|
||||
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation via `GET /api/v1/crl/{issuer_id}` with 24-hour validity. An embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` returns signed OCSP responses for issued certificates (good/revoked/unknown status). Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"ca_common_name": "CertCtl Local CA",
|
||||
"validity_days": 90
|
||||
"validity_days": 90,
|
||||
"ca_cert_path": "/etc/certctl/ca/ca.pem",
|
||||
"ca_key_path": "/etc/certctl/ca/ca-key.pem"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -97,9 +103,13 @@ Location: `internal/connector/issuer/local/local.go`
|
||||
|
||||
### Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)
|
||||
|
||||
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports HTTP-01 challenge solving via a built-in temporary HTTP server that starts on demand during certificate issuance.
|
||||
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports two challenge methods:
|
||||
|
||||
Configuration:
|
||||
**HTTP-01 (default):** A built-in temporary HTTP server starts on demand during certificate issuance. The domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet.
|
||||
|
||||
**DNS-01 (for wildcards):** Creates DNS TXT records via user-provided scripts. Required for wildcard certificates (`*.example.com`) and hosts that can't serve HTTP on port 80. The connector invokes external scripts to create and clean up `_acme-challenge` TXT records, making it compatible with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
||||
|
||||
HTTP-01 configuration:
|
||||
```json
|
||||
{
|
||||
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
@@ -108,27 +118,94 @@ Configuration:
|
||||
}
|
||||
```
|
||||
|
||||
For HTTP-01 to work, the domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet. The connector automatically registers an ACME account, creates orders, solves challenges, finalizes with the CSR, and downloads the issued certificate chain.
|
||||
DNS-01 configuration:
|
||||
```json
|
||||
{
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"email": "admin@example.com",
|
||||
"challenge_type": "dns-01",
|
||||
"dns_present_script": "/etc/certctl/dns/create-record.sh",
|
||||
"dns_cleanup_script": "/etc/certctl/dns/delete-record.sh",
|
||||
"dns_propagation_wait": 30
|
||||
}
|
||||
```
|
||||
|
||||
**Limitation:** v1 supports HTTP-01 challenges only. DNS-01 challenge support (required for wildcard certificates and hosts that can't serve HTTP on port 80) is planned for V2, including provider-specific DNS adapters (Cloudflare, Route53, etc.) and custom validation script hooks.
|
||||
DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name, e.g., `_acme-challenge.example.com`), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it.
|
||||
|
||||
Environment variables for the default ACME connector:
|
||||
- `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL
|
||||
- `CERTCTL_ACME_EMAIL` — Contact email for account registration
|
||||
- `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default) or `dns-01`
|
||||
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 only)
|
||||
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only)
|
||||
|
||||
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
||||
|
||||
Location: `internal/connector/issuer/acme/acme.go`
|
||||
**Note:** ACME-issued certificates rely on the Local CA for CRL/OCSP endpoints if they are stored in certctl's inventory. For issuers with their own public CRL/OCSP infrastructure (e.g., Let's Encrypt), clients should validate against the issuer's endpoints instead.
|
||||
|
||||
### Planned Issuers (V2)
|
||||
Location: `internal/connector/issuer/acme/acme.go`, `internal/connector/issuer/acme/dns.go`
|
||||
|
||||
The following issuer connectors are planned for V2:
|
||||
### Built-in: step-ca (Smallstep Private CA)
|
||||
|
||||
- **step-ca** — Smallstep's private CA and ACME server. Would allow certctl to issue certificates from a self-hosted step-ca instance via its ACME or provisioner APIs.
|
||||
- **OpenSSL / Custom CA** — Support for external CAs that use OpenSSL-based signing workflows, including custom script hooks for organizations with existing CA tooling.
|
||||
- **ADCS (Active Directory Certificate Services)** — Microsoft's enterprise CA. Would allow certctl to request certificates from an existing ADCS infrastructure, useful for organizations that need lifecycle management around their Windows PKI.
|
||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA.
|
||||
- **DigiCert** — Commercial CA integration via DigiCert's REST API.
|
||||
The step-ca connector integrates with Smallstep's step-ca private certificate authority using its native `/sign` API with JWK provisioner authentication. This is simpler than ACME for internal PKI — no challenge solving, no domain validation, just CSR + auth token → signed certificate.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"ca_url": "https://ca.internal:9000",
|
||||
"provisioner_name": "certctl",
|
||||
"provisioner_key_path": "/etc/certctl/stepca/provisioner.json",
|
||||
"provisioner_password": "...",
|
||||
"root_cert_path": "/etc/certctl/stepca/root_ca.crt",
|
||||
"validity_days": 90
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
- `CERTCTL_STEPCA_URL` — step-ca server URL
|
||||
- `CERTCTL_STEPCA_PROVISIONER` — JWK provisioner name
|
||||
- `CERTCTL_STEPCA_KEY_PATH` — Path to provisioner private key (JWK JSON)
|
||||
- `CERTCTL_STEPCA_PASSWORD` — Provisioner key password
|
||||
|
||||
The connector is registered in the issuer registry under `iss-stepca`. step-ca also works with the existing ACME connector (point `iss-acme-*` at step-ca's ACME directory URL for ACME-based issuance).
|
||||
|
||||
**Note:** step-ca-issued certificates rely on step-ca's own CRL/OCSP infrastructure. certctl's local CRL/OCSP endpoints (`GET /api/v1/crl/{issuer_id}` and `GET /api/v1/ocsp/{issuer_id}/{serial}`) are populated from step-ca's revocation data if available, but clients should validate against step-ca's endpoints for the authoritative status.
|
||||
|
||||
Location: `internal/connector/issuer/stepca/stepca.go`
|
||||
|
||||
### OpenSSL / Custom CA
|
||||
|
||||
Script-based issuer connector for organizations with existing CA tooling. Delegates certificate signing, revocation, and CRL generation to user-provided shell scripts.
|
||||
|
||||
**Configuration:**
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CERTCTL_OPENSSL_SIGN_SCRIPT` | Yes | Script that receives CSR on stdin and outputs signed PEM cert on stdout |
|
||||
| `CERTCTL_OPENSSL_REVOKE_SCRIPT` | No | Script to revoke a certificate (receives serial number as argument) |
|
||||
| `CERTCTL_OPENSSL_CRL_SCRIPT` | No | Script that outputs DER-encoded CRL on stdout |
|
||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | No | Script execution timeout (default: 30s) |
|
||||
|
||||
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information.
|
||||
|
||||
### Revocation Across Issuers
|
||||
|
||||
All issuer connectors implement `RevokeCertificate(ctx, serial, reason)`. When a certificate is revoked via `POST /api/v1/certificates/{id}/revoke`, certctl notifies the issuing CA on a best-effort basis — the revocation succeeds in certctl's inventory even if the CA notification fails (e.g., CA is temporarily unreachable). This ensures revocation is never blocked by external dependencies.
|
||||
|
||||
Each issuer handles revocation differently:
|
||||
|
||||
- **Local CA**: Updates the in-memory revocation list. DER-encoded CRLs and OCSP responses are generated from this list.
|
||||
- **ACME**: ACME v2 has limited revocation support — certctl records the revocation locally and serves it via CRL/OCSP.
|
||||
- **step-ca**: Calls step-ca's `/revoke` API endpoint. Clients should check step-ca's own CRL/OCSP for authoritative status.
|
||||
- **OpenSSL/Custom CA**: Invokes the configured revoke script (`CERTCTL_OPENSSL_REVOKE_SCRIPT`) with the serial number as an argument.
|
||||
|
||||
### Planned Issuers
|
||||
|
||||
The following issuer connectors are planned for future milestones:
|
||||
|
||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA (planned for V4.0+).
|
||||
- **DigiCert** — Commercial CA integration via DigiCert's REST API (planned for V3 paid release).
|
||||
|
||||
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.
|
||||
|
||||
### Building a Custom Issuer
|
||||
|
||||
@@ -280,9 +357,47 @@ The `reload_command` defaults to `systemctl reload nginx` but can be overridden
|
||||
|
||||
Location: `internal/connector/target/nginx/nginx.go`
|
||||
|
||||
### Planned: F5 BIG-IP (Interface Only)
|
||||
### Built-in: Apache httpd
|
||||
|
||||
The F5 BIG-IP target connector interface is built with the iControl REST flow mapped out, but the actual API calls are not yet implemented. The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status. Implementation is planned for V2.
|
||||
The Apache httpd connector follows the same pattern as NGINX: it writes separate certificate, chain, and key files to disk, validates the Apache configuration with `apachectl configtest`, and performs a graceful reload. The key difference is that private keys are written with 0600 permissions (owner-only read) for security, while cert and chain files use 0644.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"cert_path": "/etc/apache2/ssl/cert.pem",
|
||||
"chain_path": "/etc/apache2/ssl/chain.pem",
|
||||
"key_path": "/etc/apache2/ssl/key.pem",
|
||||
"reload_command": "apachectl graceful",
|
||||
"validate_command": "apachectl configtest"
|
||||
}
|
||||
```
|
||||
|
||||
The `reload_command` can be customized for different environments (e.g., `systemctl reload apache2` for systemd, `httpd -k graceful` for RHEL/CentOS). Validation output is captured and included in error messages for debugging.
|
||||
|
||||
Location: `internal/connector/target/apache/apache.go`
|
||||
|
||||
### Built-in: HAProxy
|
||||
|
||||
The HAProxy connector differs from NGINX and Apache because HAProxy expects all TLS material in a single combined PEM file (certificate + chain + private key concatenated). The connector builds this combined file, writes it with 0600 permissions (since it contains the private key), optionally validates the HAProxy configuration, and reloads.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"pem_path": "/etc/haproxy/certs/site.pem",
|
||||
"reload_command": "systemctl reload haproxy",
|
||||
"validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
|
||||
}
|
||||
```
|
||||
|
||||
The combined PEM is built in this order: server certificate, intermediate/chain certificates, private key. The `validate_command` is optional — if omitted, the connector skips config validation and goes straight to reload.
|
||||
|
||||
Location: `internal/connector/target/haproxy/haproxy.go`
|
||||
|
||||
### V3 (Paid): F5 BIG-IP (Interface Only)
|
||||
|
||||
The F5 BIG-IP target connector interface is built with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it. Implementation is planned for the paid V3 release.
|
||||
|
||||
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status. Implementation is planned for a future release.
|
||||
|
||||
Configuration (defined, not yet functional):
|
||||
```json
|
||||
@@ -295,24 +410,33 @@ Configuration (defined, not yet functional):
|
||||
}
|
||||
```
|
||||
|
||||
Note: F5 credentials are stored on the proxy agent, not on the control plane server. This limits the credential blast radius to the proxy agent's network zone.
|
||||
|
||||
Location: `internal/connector/target/f5/f5.go`
|
||||
|
||||
### Planned: IIS (Interface Only)
|
||||
### V3 (Paid): IIS (Interface Only, Dual-Mode)
|
||||
|
||||
The IIS target connector interface is built with the WinRM/PowerShell flow mapped out, but the actual remote execution is not yet implemented. The planned flow is: transfer a PFX bundle to the Windows server via WinRM, run `Import-PfxCertificate` to install it into the certificate store, and run `Set-WebBinding` to bind the certificate to the IIS site. Implementation is planned for V2.
|
||||
The IIS target connector supports two deployment modes planned for the paid V3 release:
|
||||
|
||||
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
||||
|
||||
**Proxy agent WinRM (for agentless targets):** For Windows servers where you don't want to install an agent, a nearby Windows agent acts as a proxy and reaches the IIS box via WinRM. The proxy agent picks up the deployment job, transfers the PFX bundle over WinRM, and runs the PowerShell commands remotely. WinRM credentials are stored on the proxy agent, not on the control plane.
|
||||
|
||||
Configuration (defined, not yet functional):
|
||||
```json
|
||||
{
|
||||
"host": "iis-server.internal.example.com",
|
||||
"username": "Administrator",
|
||||
"password": "...",
|
||||
"mode": "local",
|
||||
"site_name": "Default Web Site",
|
||||
"cert_store": "WebHosting",
|
||||
"use_https": true
|
||||
"winrm_host": "",
|
||||
"winrm_username": "",
|
||||
"winrm_password": "",
|
||||
"winrm_use_https": true
|
||||
}
|
||||
```
|
||||
|
||||
When `mode` is `"local"`, the `winrm_*` fields are ignored. When `mode` is `"proxy"`, the agent connects to the remote IIS server via WinRM using the provided credentials.
|
||||
|
||||
Location: `internal/connector/target/iis/iis.go`
|
||||
|
||||
## Notifier Connector
|
||||
@@ -344,7 +468,16 @@ type Connector interface {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in notifiers: **Email** (SMTP) and **Webhook** (HTTP POST).
|
||||
Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incoming webhook), **Microsoft Teams** (MessageCard webhook), **PagerDuty** (Events API v2), and **OpsGenie** (Alert API v2).
|
||||
|
||||
Each notifier is enabled by its configuration env var:
|
||||
|
||||
| Notifier | Env Var | Description |
|
||||
|----------|---------|-------------|
|
||||
| Slack | `CERTCTL_SLACK_WEBHOOK_URL` | Incoming webhook URL. Optional: `CERTCTL_SLACK_CHANNEL`, `CERTCTL_SLACK_USERNAME` |
|
||||
| Teams | `CERTCTL_TEAMS_WEBHOOK_URL` | Incoming webhook URL (MessageCard format) |
|
||||
| PagerDuty | `CERTCTL_PAGERDUTY_ROUTING_KEY` | Events API v2 routing key. Optional: `CERTCTL_PAGERDUTY_SEVERITY` (default: "warning") |
|
||||
| OpsGenie | `CERTCTL_OPSGENIE_API_KEY` | Alert API GenieKey. Optional: `CERTCTL_OPSGENIE_PRIORITY` (default: "P3") |
|
||||
|
||||
In demo mode, notifications are marked as "sent" even without a configured notifier — this prevents error spam in the logs while still generating notification records for the dashboard to display.
|
||||
|
||||
@@ -448,6 +581,142 @@ docker rm -f nginx
|
||||
6. **Idempotent operations** — Deploying the same certificate twice should succeed, not fail
|
||||
7. **Report metadata** — Return deployment duration, target address, and other useful data in results
|
||||
|
||||
## Agent Discovery Scanner
|
||||
|
||||
Agents include a built-in certificate discovery scanner that walks configured directories and reports unmanaged certificates to the control plane. This is useful for discovering existing certificates already deployed in your infrastructure, so you can bring them under certctl's management.
|
||||
|
||||
### Configuration
|
||||
|
||||
Enable discovery on an agent by setting `CERTCTL_DISCOVERY_DIRS` to a comma-separated list of directories:
|
||||
|
||||
```bash
|
||||
export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/etc/apache2/ssl"
|
||||
```
|
||||
|
||||
Or via command-line flag:
|
||||
|
||||
```bash
|
||||
./agent --agent-id agent-nginx-01 --discovery-dirs "/etc/nginx/certs,/etc/ssl/certs"
|
||||
```
|
||||
|
||||
The agent scans these directories on startup and every 6 hours, looking for certificate files in PEM or DER format (extensions: `.pem`, `.crt`, `.cer`, `.cert`, `.der`).
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Scan**: Agent recursively walks directories, extracts certificates
|
||||
2. **Deduplicate**: Control plane deduplicates by SHA-256 fingerprint (same cert in multiple locations is one discovery)
|
||||
3. **Store**: Discovered certificates stored with metadata (agent ID, file path, found date, fingerprint)
|
||||
4. **Triage**: Operators query discovered certs via API, claim to link to managed certificates, or dismiss false positives
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```bash
|
||||
# List discovered certificates (filter by agent, status)
|
||||
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-01&status=new" | jq .
|
||||
|
||||
# Get discovery detail
|
||||
curl -s http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID | jq .
|
||||
|
||||
# Claim a discovered cert (link to managed certificate)
|
||||
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
||||
|
||||
# Dismiss a discovery
|
||||
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/dismiss | jq .
|
||||
|
||||
# View discovery scan history
|
||||
curl -s http://localhost:8443/api/v1/discovery-scans | jq .
|
||||
|
||||
# Summary counts (new, claimed, dismissed)
|
||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Inventory audit** — Find all TLS certificates running in your infrastructure
|
||||
- **Migration** — Onboard existing certificates that were issued outside certctl
|
||||
- **Compliance** — Detect rogue/unauthorized certificates in monitored directories
|
||||
- **Integration** — Pull certificate data from systems that pre-generate certs (e.g., Kubernetes CertManager)
|
||||
|
||||
## Network Certificate Scanner (M21)
|
||||
|
||||
The control plane includes a built-in active TLS scanner that probes network endpoints and discovers certificates without requiring agent deployment. This complements the agent-based filesystem discovery with network-level visibility.
|
||||
|
||||
### Configuration
|
||||
|
||||
Enable network scanning on the server:
|
||||
|
||||
```bash
|
||||
export CERTCTL_NETWORK_SCAN_ENABLED=true
|
||||
export CERTCTL_NETWORK_SCAN_INTERVAL=6h # default
|
||||
```
|
||||
|
||||
### Creating Scan Targets
|
||||
|
||||
Network scan targets define which CIDR ranges and ports to probe:
|
||||
|
||||
```bash
|
||||
# Create a scan target for your internal network
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Production Web Servers",
|
||||
"cidrs": ["10.0.1.0/24", "10.0.2.0/24"],
|
||||
"ports": [443, 8443, 6443],
|
||||
"enabled": true,
|
||||
"scan_interval_hours": 6,
|
||||
"timeout_ms": 5000
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Expand**: CIDR ranges are expanded to individual IPs (safety cap at /20 = 4096 IPs)
|
||||
2. **Probe**: Concurrent TLS connections (50 goroutines) with configurable timeout per endpoint
|
||||
3. **Extract**: Certificate metadata extracted from TLS handshake (CN, SANs, serial, issuer, key info, fingerprint)
|
||||
4. **Pipeline**: Results fed into the same `DiscoveryService.ProcessDiscoveryReport()` as filesystem discovery
|
||||
5. **Deduplicate**: Sentinel agent ID (`server-scanner`) with source_path as `ip:port` ensures proper dedup
|
||||
6. **Triage**: Discovered certs appear in `GET /api/v1/discovered-certificates` with `agent_id=server-scanner`
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```bash
|
||||
# List all scan targets
|
||||
curl -s http://localhost:8443/api/v1/network-scan-targets | jq .
|
||||
|
||||
# Create a scan target
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "DMZ", "cidrs": ["172.16.0.0/24"], "ports": [443]}' | jq .
|
||||
|
||||
# Get a specific target (includes last_scan_at, last_scan_certs_found)
|
||||
curl -s http://localhost:8443/api/v1/network-scan-targets/nst-dmz | jq .
|
||||
|
||||
# Trigger an immediate scan (doesn't wait for scheduler)
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-dmz/scan | jq .
|
||||
|
||||
# Update scan configuration
|
||||
curl -s -X PUT http://localhost:8443/api/v1/network-scan-targets/nst-dmz \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ports": [443, 8443, 9443], "timeout_ms": 3000}' | jq .
|
||||
|
||||
# Delete a scan target
|
||||
curl -s -X DELETE http://localhost:8443/api/v1/network-scan-targets/nst-dmz
|
||||
```
|
||||
|
||||
### Scheduler Integration
|
||||
|
||||
When `CERTCTL_NETWORK_SCAN_ENABLED=true`, the server runs a 6th scheduler loop (alongside renewal, jobs, health, notifications, and short-lived expiry). It scans all enabled targets at the configured interval (default 6h). Each target tracks `last_scan_at`, `last_scan_duration_ms`, and `last_scan_certs_found` for monitoring scan health.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Network inventory** — "What TLS certs are deployed across my network?" without deploying agents
|
||||
- **Shadow certificate detection** — Find certificates on services you didn't know were running TLS
|
||||
- **Compliance scanning** — Prove to auditors that all TLS endpoints are inventoried
|
||||
- **Migration assessment** — Scan a network range before onboarding to certctl management
|
||||
- **Expiration monitoring** — Discover soon-to-expire certs on network endpoints before they cause outages
|
||||
|
||||
## What's Next
|
||||
|
||||
- [Architecture Guide](architecture.md) — Understanding the full system design
|
||||
|
||||
+614
-38
@@ -33,13 +33,118 @@ flowchart LR
|
||||
B --> C[Create\nCertificate]
|
||||
C --> D[Trigger\nRenewal]
|
||||
D --> E[Trigger\nDeployment]
|
||||
E --> F[Inspect Audit\n& Notifications]
|
||||
E --> F[Revoke a\nCertificate]
|
||||
F --> G[Check Stats\n& Metrics]
|
||||
G --> H[Inspect Audit\n& Notifications]
|
||||
```
|
||||
|
||||
Each step corresponds to a real operation that certctl would perform in production. The difference here is that we're driving each step manually via curl instead of letting the scheduler and agents handle it automatically.
|
||||
|
||||
---
|
||||
|
||||
## Alternative Issuers Reference
|
||||
|
||||
certctl ships with multiple issuer connectors. The demo uses the Local CA, but here's how to set up others:
|
||||
|
||||
### Sub-CA Mode (Local CA chained to enterprise root)
|
||||
|
||||
For enterprises with ADCS, root CAs, or intermediate CAs:
|
||||
|
||||
```bash
|
||||
# Place your CA certificate and key on the server
|
||||
export CERTCTL_CA_CERT_PATH="/etc/certctl/ca-cert.pem"
|
||||
export CERTCTL_CA_KEY_PATH="/etc/certctl/ca-key.pem"
|
||||
|
||||
# Restart the server. The Local CA connector loads the cert+key from disk
|
||||
# All issued certificates now chain to your enterprise root
|
||||
docker compose -f deploy/docker-compose.yml restart server
|
||||
```
|
||||
|
||||
The CA key can be RSA, ECDSA, or PKCS#8 format. The connector validates that the certificate has `IsCA=true` and `KeyUsageCertSign`.
|
||||
|
||||
### ACME with DNS-01 Challenges (Wildcard Certificates)
|
||||
|
||||
For Let's Encrypt or other ACME providers with wildcard support:
|
||||
|
||||
```bash
|
||||
# Configure ACME DNS-01 with a DNS provider script
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE="dns-01"
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT="/usr/local/bin/dns-present.sh"
|
||||
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT="/usr/local/bin/dns-cleanup.sh"
|
||||
export CERTCTL_ACME_DNS_PROPAGATION_WAIT="10" # seconds to wait for DNS propagation
|
||||
|
||||
# Example dns-present.sh for Cloudflare:
|
||||
# #!/bin/bash
|
||||
# RECORD_NAME=$1
|
||||
# RECORD_VALUE=$2
|
||||
# curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/dns_records" \
|
||||
# -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
# -d "{\"type\":\"TXT\",\"name\":\"$RECORD_NAME\",\"content\":\"$RECORD_VALUE\"}"
|
||||
```
|
||||
|
||||
Then issue wildcard certificates:
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "mc-wildcard-api",
|
||||
"name": "Wildcard API Certificate",
|
||||
"common_name": "*.api.example.com",
|
||||
"sans": ["*.api.example.com", "api.example.com"],
|
||||
"issuer_id": "iss-acme",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"status": "Pending"
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
### step-ca (Smallstep Private CA)
|
||||
|
||||
For organizations running step-ca as their private CA:
|
||||
|
||||
```bash
|
||||
# Configure step-ca connector
|
||||
export CERTCTL_STEPCA_URL="https://ca.internal.example.com"
|
||||
export CERTCTL_STEPCA_FINGERPRINT="your-ca-fingerprint" # From `step ca bootstrap`
|
||||
export CERTCTL_STEPCA_PROVISIONER="certctl-admin" # Name of the JWK provisioner
|
||||
export CERTCTL_STEPCA_PROVISIONER_JWK="/etc/certctl/provisioner.json" # Path to JWK private key
|
||||
```
|
||||
|
||||
Then use step-ca as the issuer:
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/certificates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "mc-stepca-cert",
|
||||
"name": "Certificate from step-ca",
|
||||
"common_name": "service.internal.example.com",
|
||||
"issuer_id": "iss-stepca",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"status": "Pending"
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
### OpenSSL / Custom CA (Script-based)
|
||||
|
||||
For custom signing workflows via shell scripts:
|
||||
|
||||
```bash
|
||||
# Configure OpenSSL connector with user-provided scripts
|
||||
export CERTCTL_OPENSSL_SIGN_SCRIPT="/usr/local/bin/custom-sign.sh"
|
||||
export CERTCTL_OPENSSL_REVOKE_SCRIPT="/usr/local/bin/custom-revoke.sh"
|
||||
export CERTCTL_OPENSSL_CRL_SCRIPT="/usr/local/bin/custom-crl.sh"
|
||||
export CERTCTL_OPENSSL_TIMEOUT_SECONDS="30"
|
||||
|
||||
# Example custom-sign.sh:
|
||||
# #!/bin/bash
|
||||
# CSR_PEM=$1
|
||||
# VALIDITY_DAYS=$2
|
||||
# # Do something custom with the CSR and return signed certificate
|
||||
# openssl ca -in <(echo "$CSR_PEM") -days $VALIDITY_DAYS -out /tmp/signed.pem
|
||||
# cat /tmp/signed.pem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Build the Organization Structure
|
||||
|
||||
### Create a new team
|
||||
@@ -99,12 +204,12 @@ You should see:
|
||||
{
|
||||
"id": "iss-local",
|
||||
"name": "Local Dev CA",
|
||||
"type": "GenericCA",
|
||||
"type": "local",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:** The issuer record was inserted during database seeding (`migrations/seed_demo.sql`). The `type` field (`GenericCA`) maps to a connector implementation. When the server starts, it registers connector instances in an `issuerRegistry` map keyed by issuer ID. When a certificate needs issuance, the service layer looks up the issuer ID in this registry to find the right connector.
|
||||
**How it works:** The issuer record was inserted during database seeding (`migrations/seed_demo.sql`). The `type` field (`local`) maps to a connector implementation. When the server starts, it registers connector instances in an `issuerRegistry` map keyed by issuer ID. When a certificate needs issuance, the service layer looks up the issuer ID in this registry to find the right connector.
|
||||
|
||||
**How the Local CA works internally:** The Local CA connector (`internal/connector/issuer/local/local.go`) generates a self-signed root CA certificate on first use using Go's `crypto/x509` package. The CA key pair lives in memory only — it's regenerated each time the server restarts, which means all certificates it issued become untrusted on restart (acceptable for dev/demo). When it receives an `IssuanceRequest` containing a CSR (Certificate Signing Request), it:
|
||||
|
||||
@@ -116,7 +221,7 @@ You should see:
|
||||
|
||||
The result is a structurally valid X.509 certificate — browsers won't trust it (no root CA in their trust store), but it exercises the exact same code paths that a production ACME or Vault issuer would.
|
||||
|
||||
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault, ADCS), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA and ACME (HTTP-01); step-ca, ADCS, OpenSSL/custom CA are planned for V2; DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3.
|
||||
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). OpenSSL/Custom CA is planned for V2; DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -127,15 +232,14 @@ flowchart TD
|
||||
D["GetOrderStatus(orderID)"]
|
||||
end
|
||||
|
||||
A --> E["Local CA\n(crypto/x509)"]
|
||||
A --> E["Local CA\n(self-signed or sub-CA)"]
|
||||
A --> F["ACME\n(Let's Encrypt)"]
|
||||
A --> G["step-ca\n(planned V2)"]
|
||||
A --> H["OpenSSL / Custom CA\n(planned V2)"]
|
||||
A --> I["ADCS\n(planned V2)"]
|
||||
A --> J["DigiCert API\n(planned V2.3)"]
|
||||
A --> K["Vault PKI\n(planned V3)"]
|
||||
A --> L["Entrust / GlobalSign\n(planned V3)"]
|
||||
A --> M["Google CAS / EJBCA\n(planned V3)"]
|
||||
A --> G["step-ca\n(implemented)"]
|
||||
A --> H["OpenSSL / Custom CA\n(script-based)"]
|
||||
A --> J["DigiCert API\n(planned)"]
|
||||
A --> K["Vault PKI\n(planned)"]
|
||||
A --> L["Entrust / GlobalSign\n(planned)"]
|
||||
A --> M["Google CAS / EJBCA\n(planned)"]
|
||||
```
|
||||
|
||||
---
|
||||
@@ -268,6 +372,39 @@ curl -s "$API/api/v1/jobs" | jq '.data[] | select(.certificate_id == "mc-demo-ap
|
||||
|
||||
---
|
||||
|
||||
## Part 4.5: Manage Deployment Targets
|
||||
|
||||
Before deploying, you need targets. The demo seeds 5 targets, but you can also create, update, and delete them via API:
|
||||
|
||||
```bash
|
||||
# List all targets
|
||||
curl -s "$API/api/v1/targets" | jq '.data[] | {id, name, type, agent_id}'
|
||||
|
||||
# Create a new NGINX target
|
||||
curl -s -X POST "$API/api/v1/targets" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "tgt-nginx-api",
|
||||
"name": "API NGINX",
|
||||
"type": "nginx",
|
||||
"agent_id": "ag-web-prod",
|
||||
"config": {"cert_path": "/etc/nginx/certs/api.crt", "key_path": "/etc/nginx/certs/api.key", "reload_command": "systemctl reload nginx"},
|
||||
"enabled": true
|
||||
}' | jq .
|
||||
|
||||
# Update a target
|
||||
curl -s -X PUT "$API/api/v1/targets/tgt-nginx-api" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "API NGINX (updated)", "type": "nginx", "agent_id": "ag-web-prod", "config": {"cert_path": "/etc/nginx/certs/api.crt"}, "enabled": true}' | jq .
|
||||
|
||||
# Delete a target
|
||||
curl -s -X DELETE "$API/api/v1/targets/tgt-nginx-api"
|
||||
```
|
||||
|
||||
Each target type (NGINX, Apache, HAProxy, F5, IIS) accepts different configuration fields. The `config` JSON is validated at deployment time by the target connector.
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Deploy the Certificate
|
||||
|
||||
Trigger deployment to see the deployment workflow:
|
||||
@@ -308,13 +445,13 @@ sequenceDiagram
|
||||
TC->>T: Run: nginx -t (validate config)
|
||||
TC->>T: Run: systemctl reload nginx
|
||||
TC-->>A: {success: true, deployed_at: "..."}
|
||||
else F5 Target
|
||||
TC->>T: POST /mgmt/tm/sys/crypto/cert (upload cert)
|
||||
TC->>T: PUT /mgmt/tm/ltm/virtual (bind to virtual server)
|
||||
else F5 Target (via proxy agent)
|
||||
TC->>T: iControl REST: POST /mgmt/tm/sys/crypto/cert
|
||||
TC->>T: iControl REST: PUT /mgmt/tm/ltm/virtual
|
||||
TC-->>A: {success: true, deployed_at: "..."}
|
||||
else IIS Target
|
||||
TC->>T: WinRM: Import-PfxCertificate
|
||||
TC->>T: WinRM: Set-WebBinding -SslFlags
|
||||
else IIS Target (agent-local)
|
||||
TC->>T: PowerShell: Import-PfxCertificate
|
||||
TC->>T: PowerShell: Set-WebBinding -SslFlags
|
||||
TC-->>A: {success: true, deployed_at: "..."}
|
||||
end
|
||||
|
||||
@@ -354,29 +491,47 @@ curl -s -X POST "$API/api/v1/agents/agent-nginx-prod/jobs/JOB_ID/status" \
|
||||
|
||||
---
|
||||
|
||||
## Part 6: View the Audit Trail
|
||||
## Part 6: View the Audit Trail (Immutable API Audit Log)
|
||||
|
||||
Every action you've taken has been recorded. Check the audit trail:
|
||||
Every API call and state change is recorded in an immutable, append-only audit trail. Check the recent audit events:
|
||||
|
||||
```bash
|
||||
curl -s $API/api/v1/audit | jq '.data[0:5]'
|
||||
# List recent audit events
|
||||
curl -s $API/api/v1/audit | jq '.data[0:10]'
|
||||
|
||||
# Filter by action (e.g., all certificate creations)
|
||||
curl -s "$API/api/v1/audit?action=certificate_created" | jq '.data[] | {actor, action, resource_id, timestamp}'
|
||||
|
||||
# Filter by resource (e.g., all actions on mc-demo-api)
|
||||
curl -s "$API/api/v1/audit?resource_id=mc-demo-api" | jq '.data[] | {actor, action, timestamp}'
|
||||
|
||||
# Filter by actor (e.g., all actions by a specific owner)
|
||||
curl -s "$API/api/v1/audit?actor=o-demo-user" | jq '.data[] | {action, resource_type, timestamp}'
|
||||
|
||||
# Time-range filter (e.g., last hour)
|
||||
curl -s "$API/api/v1/audit?created_after=2026-03-24T09:00:00Z" | jq '.data | length'
|
||||
|
||||
# Export audit trail (CSV format via GUI)
|
||||
# Available on the Audit page with applied filters
|
||||
```
|
||||
|
||||
**How it works:** The `audit_events` table is append-only — there is no `UPDATE` or `DELETE` in the `AuditRepository` interface. This is a deliberate design decision for compliance. Every service method that mutates state calls `AuditService.Create()` with:
|
||||
**How it works:** The `audit_events` table is append-only — there is no `UPDATE` or `DELETE` in the `AuditRepository` interface. Every API call (including this audit query) is recorded by the API audit middleware with:
|
||||
|
||||
| Field | Source | Example |
|
||||
|-------|--------|---------|
|
||||
| `actor` | The authenticated user or system component | `"o-demo-user"`, `"system"`, `"agent-prod-01"` |
|
||||
| `actor` | The authenticated user extracted from auth context | `"o-demo-user"`, `"system"`, `"agent-prod-01"`, `"anonymous"` |
|
||||
| `actor_type` | Category of the actor | `"User"`, `"System"`, `"Agent"` |
|
||||
| `action` | What happened | `"certificate_created"`, `"renewal_triggered"`, `"deployment_completed"` |
|
||||
| `resource_type` | What was affected | `"certificate"`, `"team"`, `"agent"` |
|
||||
| `action` | What happened | `"certificate_created"`, `"renewal_triggered"`, `"deployment_completed"`, `"api_call"` |
|
||||
| `resource_type` | What was affected | `"certificate"`, `"team"`, `"agent"`, `"audit"` |
|
||||
| `resource_id` | Specific resource | `"mc-demo-api"` |
|
||||
| `details` | Arbitrary JSON context | `{"environment": "staging", "issuer": "iss-local"}` |
|
||||
| `details` | Arbitrary JSON context | `{"environment": "staging", "issuer": "iss-local", "body_hash": "abc123..." }` |
|
||||
| `timestamp` | When it happened (server clock) | `"2026-03-14T10:30:00Z"` |
|
||||
|
||||
**Why immutable audit:** Compliance frameworks (SOC 2 Type II, PCI-DSS, ISO 27001) require tamper-evident audit logs. By making the repository interface append-only, even a compromised API server can't retroactively delete or modify audit records. In a production deployment, you'd also stream these to an external SIEM (Splunk, Datadog) for additional protection.
|
||||
The audit middleware (M19) records every HTTP request: method, path, status code, actor, request body SHA-256 hash, and latency. This creates a complete API audit trail without blocking responses (logging happens asynchronously).
|
||||
|
||||
**Check the dashboard.** The "Audit" view shows the full timeline of all actions across the system.
|
||||
**Why immutable audit:** Compliance frameworks (SOC 2 Type II, PCI-DSS, ISO 27001) require tamper-evident audit logs. By making the repository interface append-only and recording API calls, even a compromised API server can't retroactively delete or modify audit records. In a production deployment, you'd also stream these to an external SIEM (Splunk, Datadog) for additional protection.
|
||||
|
||||
**Check the dashboard.** The "Audit" view shows the full timeline of all actions across the system with filtering and CSV/JSON export.
|
||||
|
||||
---
|
||||
|
||||
@@ -388,7 +543,7 @@ Certctl sends notifications for certificate lifecycle events. Check what notific
|
||||
curl -s $API/api/v1/notifications | jq '.data[0:5]'
|
||||
```
|
||||
|
||||
**How it works:** The `NotificationService` generates notification records in the `notification_events` table whenever significant events occur — expiration warnings at configurable thresholds (30, 14, 7, 0 days by default), renewal success/failure, deployment results, and policy violations. Each notification has a `channel` (Email, Webhook) and a `recipient`.
|
||||
**How it works:** The `NotificationService` generates notification records in the `notification_events` table whenever significant events occur — expiration warnings at configurable thresholds (30, 14, 7, 0 days by default), renewal success/failure, deployment results, and policy violations. Each notification has a `channel` (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) and a `recipient`.
|
||||
|
||||
**Threshold-Based Alerting:** Each renewal policy defines configurable alert thresholds via the `alert_thresholds_days` field (e.g., `[30, 14, 7, 0]` for the standard policy, `[14, 7, 3, 0]` for the urgent policy). The scheduler checks which thresholds each certificate has crossed and sends one notification per threshold, deduplicated so the same alert is never sent twice. Certificates are automatically transitioned to `Expiring` status when entering the alert window and `Expired` when they hit 0 days.
|
||||
|
||||
@@ -409,6 +564,36 @@ flowchart TD
|
||||
|
||||
**Why graceful notifier fallback:** In demo mode, no SMTP server or webhook endpoint is configured. Rather than spamming error logs with "notifier not found" every 60 seconds (which was the original behavior — we fixed this), the service marks notifications as "sent" when no notifier is registered for the channel. This keeps the notification records visible in the dashboard without requiring external infrastructure.
|
||||
|
||||
### Configuring Notifier Connectors
|
||||
|
||||
In production, enable notifiers by setting environment variables:
|
||||
|
||||
**Slack:**
|
||||
```bash
|
||||
export CERTCTL_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
|
||||
export CERTCTL_SLACK_CHANNEL="cert-alerts" # Optional, overrides channel in webhook
|
||||
export CERTCTL_SLACK_USERNAME="CertCTL" # Optional, defaults to "CertCTL"
|
||||
```
|
||||
|
||||
**Microsoft Teams:**
|
||||
```bash
|
||||
export CERTCTL_TEAMS_WEBHOOK_URL="https://outlook.webhook.office.com/webhookb2/..."
|
||||
```
|
||||
|
||||
**PagerDuty:**
|
||||
```bash
|
||||
export CERTCTL_PAGERDUTY_ROUTING_KEY="your-routing-key"
|
||||
export CERTCTL_PAGERDUTY_SEVERITY="warning" # Or: critical, error, info
|
||||
```
|
||||
|
||||
**OpsGenie:**
|
||||
```bash
|
||||
export CERTCTL_OPSGENIE_API_KEY="your-api-key"
|
||||
export CERTCTL_OPSGENIE_PRIORITY="P3" # Or: P1, P2, P4, P5
|
||||
```
|
||||
|
||||
When certificates expire, renewal fails, or policies are violated, certctl sends notifications via the configured channels. Each notifier connector implements the `Notifier` interface: `Send(ctx context.Context, recipient, subject, body string) error`. The notification processor handles retries and failure recording.
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Create a Second Certificate and Compare
|
||||
@@ -448,6 +633,50 @@ curl -s -X POST $API/api/v1/certificates \
|
||||
|
||||
---
|
||||
|
||||
## Part 8.5: Revoke a Certificate
|
||||
|
||||
Let's revoke the payments gateway certificate — simulating a key compromise scenario:
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/certificates/mc-demo-payments/revoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "keyCompromise"}' | jq .
|
||||
```
|
||||
|
||||
**How it works:** The `RevokeCertificateWithActor` service method executes a 7-step process:
|
||||
|
||||
1. Validates the certificate is eligible (not already revoked, not archived)
|
||||
2. Retrieves the latest certificate version to get the serial number
|
||||
3. Updates the certificate status to "Revoked" with a timestamp and reason
|
||||
4. Records the revocation in the `certificate_revocations` table (idempotent via ON CONFLICT)
|
||||
5. Notifies the issuing CA (best-effort — revocation succeeds even if the CA is unreachable)
|
||||
6. Creates an audit trail entry
|
||||
7. Sends revocation notifications via configured channels
|
||||
|
||||
Check the CRL (Certificate Revocation List):
|
||||
|
||||
```bash
|
||||
# JSON-formatted CRL
|
||||
curl -s $API/api/v1/crl | jq .
|
||||
|
||||
# DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection)
|
||||
curl -s $API/api/v1/crl/iss-local -o /tmp/crl.der
|
||||
openssl crl -inform DER -in /tmp/crl.der -text -noout
|
||||
```
|
||||
|
||||
Check OCSP status:
|
||||
|
||||
```bash
|
||||
# Replace SERIAL with the actual serial number from the certificate version
|
||||
curl -s $API/api/v1/ocsp/iss-local/SERIAL | jq .
|
||||
```
|
||||
|
||||
**Why RFC 5280 reason codes:** The reason code isn't just metadata — it tells clients *why* the certificate was revoked. A `keyCompromise` revocation means the private key was exposed and the certificate should be distrusted immediately. A `superseded` revocation means a newer certificate replaced it — less urgent. CRLs and OCSP responses include the reason code so client software can make informed trust decisions.
|
||||
|
||||
**Check the dashboard.** Click the payments certificate — you'll see a revocation banner with the reason code and timestamp.
|
||||
|
||||
---
|
||||
|
||||
## Part 9: Policy Violations
|
||||
|
||||
Let's see what happens when a certificate doesn't meet policy requirements. Check existing policy rules:
|
||||
@@ -479,6 +708,332 @@ curl -s "$API/api/v1/policies/pr-max-certificate-lifetime/violations" | jq .
|
||||
|
||||
---
|
||||
|
||||
## Part 9.5: Dashboard Stats and Metrics
|
||||
|
||||
certctl exposes operational metrics so you can monitor the health of your certificate infrastructure:
|
||||
|
||||
```bash
|
||||
# Dashboard summary — total certs, expiring, expired, active
|
||||
curl -s $API/api/v1/stats/summary | jq .
|
||||
|
||||
# Certificates grouped by status
|
||||
curl -s $API/api/v1/stats/certificates-by-status | jq .
|
||||
|
||||
# Expiration timeline — how many certs expire in the next 90 days
|
||||
curl -s "$API/api/v1/stats/expiration-timeline?days=90" | jq .
|
||||
|
||||
# Job trends — completed vs failed jobs over 30 days
|
||||
curl -s "$API/api/v1/stats/job-trends?days=30" | jq .
|
||||
|
||||
# Issuance rate — new certificates per day over 30 days
|
||||
curl -s "$API/api/v1/stats/issuance-rate?days=30" | jq .
|
||||
|
||||
# System metrics — gauges, counters, uptime (JSON)
|
||||
curl -s $API/api/v1/metrics | jq .
|
||||
|
||||
# System metrics — Prometheus exposition format (for Prometheus/Grafana/Datadog scraping)
|
||||
curl -s $API/api/v1/metrics/prometheus
|
||||
```
|
||||
|
||||
**How it works:** The `StatsService` computes aggregations in Go from existing repository List methods — no additional SQL queries or materialized views. This keeps the database schema simple while providing real-time dashboard data. The JSON metrics endpoint returns gauges (cert totals by status, agent counts, pending jobs), counters (completed/failed jobs), and server uptime. The Prometheus endpoint (`/api/v1/metrics/prometheus`) exposes the same data in Prometheus exposition format (`text/plain; version=0.0.4`) with `certctl_` prefixed metric names — ready for scraping by Prometheus, Grafana Agent, Datadog Agent, or Victoria Metrics.
|
||||
|
||||
**In the dashboard**, these stats power four interactive charts: an expiration heatmap, renewal success rate trends, certificate status distribution, and issuance rate. The agent fleet overview page uses agent metadata to group by OS, architecture, and version.
|
||||
|
||||
---
|
||||
|
||||
## Part 10: Certificate Profiles
|
||||
|
||||
Profiles define the cryptographic constraints for a class of certificates. Let's explore the demo profiles:
|
||||
|
||||
```bash
|
||||
# List all profiles
|
||||
curl -s $API/api/v1/profiles | jq '.data[] | {id, name, allowed_key_algorithms, max_validity_days}'
|
||||
```
|
||||
|
||||
Create a new profile for high-security certificates:
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/profiles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "prof-demo-hsec",
|
||||
"name": "Demo High Security",
|
||||
"description": "ECDSA-only with 90-day max TTL",
|
||||
"allowed_key_algorithms": [{"algorithm": "ECDSA", "min_size": 256}],
|
||||
"max_validity_days": 90,
|
||||
"allowed_ekus": ["serverAuth"],
|
||||
"enabled": true
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
**How it works:** Certificate profiles are stored in the `certificate_profiles` table with a `allowed_key_algorithms` JSONB column that defines which key types and minimum sizes are acceptable. When a certificate is assigned to a profile, the profile constraints are enforced during CSR validation. The `max_validity_days` field controls the maximum certificate lifetime — profiles with values translating to under 1 hour enable short-lived certificate mode, where certs are exempt from CRL/OCSP.
|
||||
|
||||
**Why profiles matter:** Without profiles, any agent can submit a CSR with any key type and any validity period. Profiles create crypto policy guardrails — "production TLS certs must use ECDSA P-256 with 90-day max TTL" — that prevent configuration drift and enforce compliance requirements across the fleet.
|
||||
|
||||
**In the dashboard**, click "Profiles" in the sidebar to see and manage certificate profiles.
|
||||
|
||||
---
|
||||
|
||||
## Part 11: Agent Groups
|
||||
|
||||
Agent groups let you organize your agent fleet by criteria for dynamic policy scoping:
|
||||
|
||||
```bash
|
||||
# List existing agent groups
|
||||
curl -s $API/api/v1/agent-groups | jq '.data[] | {id, name, match_os, match_architecture}'
|
||||
```
|
||||
|
||||
Create a group that matches all Linux agents:
|
||||
|
||||
```bash
|
||||
curl -s -X POST $API/api/v1/agent-groups \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "ag-demo-linux",
|
||||
"name": "Demo Linux Agents",
|
||||
"description": "All agents running Linux",
|
||||
"match_os": "linux",
|
||||
"enabled": true
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
**How it works:** Agent groups use dynamic matching criteria — `match_os`, `match_architecture`, `match_ip_cidr`, and `match_version` — that are compared against agent metadata reported via heartbeat. Agents automatically join groups when their metadata matches the criteria. Manual membership (explicit include/exclude) is also supported for edge cases. Renewal policies can be scoped to agent groups via the `agent_group_id` foreign key, so you can say "this renewal policy applies only to Linux agents."
|
||||
|
||||
**In the dashboard**, click "Agent Groups" to see groups with visual match criteria badges. The "Fleet Overview" page shows OS/architecture distribution charts powered by agent metadata.
|
||||
|
||||
---
|
||||
|
||||
## Part 12: Interactive Approval Workflow
|
||||
|
||||
For high-value certificates, you may want human oversight before renewal proceeds. Create a policy that requires approval:
|
||||
|
||||
```bash
|
||||
# Check jobs that need approval
|
||||
curl -s "$API/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, type, certificate_id, status}'
|
||||
```
|
||||
|
||||
If there are jobs awaiting approval, approve or reject them:
|
||||
|
||||
```bash
|
||||
# Approve a job
|
||||
curl -s -X POST $API/api/v1/jobs/JOB_ID/approve \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Verified key type meets compliance requirements"}' | jq .
|
||||
|
||||
# Reject a job
|
||||
curl -s -X POST $API/api/v1/jobs/JOB_ID/reject \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Key type does not meet PCI requirements"}' | jq .
|
||||
```
|
||||
|
||||
**How it works:** When a renewal policy has `auto_renew` set to false, renewal jobs enter the `AwaitingApproval` state instead of being processed immediately. An operator must explicitly approve or reject the job via the API or the GUI. Approved jobs transition to `Pending` and are picked up by the job processor. Rejected jobs move to `Cancelled` with the provided reason recorded in the audit trail.
|
||||
|
||||
**Why interactive approval:** Not every certificate renewal should be automatic. PCI-scoped certificates, certs with specific compliance requirements, or certificates being migrated between issuers benefit from a human checkpoint. The AwaitingApproval state creates that checkpoint without blocking the entire job pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Part 13: Advanced Query Features
|
||||
|
||||
certctl's API supports sorting, filtering, cursor pagination, and sparse field selection:
|
||||
|
||||
```bash
|
||||
# Sort by expiration date (ascending)
|
||||
curl -s "$API/api/v1/certificates?sort=notAfter" | jq '.data[] | {id, common_name, expires_at}'
|
||||
|
||||
# Sort descending (prefix with -)
|
||||
curl -s "$API/api/v1/certificates?sort=-createdAt" | jq '.data[0:3]'
|
||||
|
||||
# Time-range filter: certs expiring before May 2026
|
||||
curl -s "$API/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq '.data | length'
|
||||
|
||||
# Sparse fields: only return id, status, and expiry
|
||||
curl -s "$API/api/v1/certificates?fields=id,status,expires_at" | jq '.data[0]'
|
||||
|
||||
# Cursor pagination: page through results efficiently
|
||||
curl -s "$API/api/v1/certificates?page_size=3" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
|
||||
|
||||
# View deployment targets for a certificate
|
||||
curl -s "$API/api/v1/certificates/mc-demo-api/deployments" | jq .
|
||||
```
|
||||
|
||||
**How it works:** Sort uses a whitelist of allowed fields (notAfter, createdAt, updatedAt, commonName, name, status, environment) mapped to SQL columns. Cursor pagination uses keyset pagination (`(created_at, id) < (cursor_time, cursor_id)`) which is more efficient than OFFSET-based pagination for large datasets. Sparse fields marshal the full object to JSON, then strip unrequested keys — lightweight but effective. Time-range filters add WHERE clauses to the SQL query.
|
||||
|
||||
**Why cursor pagination:** Page-based pagination (`?page=50&per_page=100`) requires the database to skip rows, which gets slower as page numbers increase. Cursor-based pagination (`?cursor=<token>&page_size=100`) uses an indexed seek, maintaining constant performance regardless of how deep you paginate. For large certificate inventories (thousands of certs), this is the difference between sub-millisecond and multi-second queries.
|
||||
|
||||
---
|
||||
|
||||
## Part 14: CLI Tool (M16b)
|
||||
|
||||
certctl includes a standalone CLI tool for command-line users:
|
||||
|
||||
```bash
|
||||
# Build the CLI
|
||||
cd cmd/cli && go build -o certctl-cli .
|
||||
|
||||
# Export credentials
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
# List certificates (JSON or table format)
|
||||
./certctl-cli list-certs --format table
|
||||
|
||||
# Get certificate details
|
||||
./certctl-cli get-cert mc-demo-api
|
||||
|
||||
# Trigger renewal
|
||||
./certctl-cli renew-cert mc-demo-api
|
||||
|
||||
# Revoke a certificate with RFC 5280 reason
|
||||
./certctl-cli revoke-cert mc-demo-payments --reason keyCompromise
|
||||
|
||||
# List agents
|
||||
./certctl-cli list-agents
|
||||
|
||||
# List pending jobs
|
||||
./certctl-cli list-jobs
|
||||
|
||||
# Check system health
|
||||
./certctl-cli health
|
||||
|
||||
# Export metrics
|
||||
./certctl-cli metrics --format json
|
||||
|
||||
# Bulk import certificates from a PEM file
|
||||
./certctl-cli import /path/to/certificates.pem
|
||||
```
|
||||
|
||||
**How it works:** The CLI tool is a self-contained Go binary with zero external dependencies (just the stdlib: flag, net/http, encoding/json, text/tabwriter). It reads credentials from environment variables or command-line flags, calls the REST API endpoints, and formats output as JSON or ASCII tables. This makes it perfect for scripts, CI/CD pipelines, and automation workflows.
|
||||
|
||||
---
|
||||
|
||||
## Part 15: MCP Server for AI Integration (M18a)
|
||||
|
||||
certctl exposes all 78 API endpoints as tools via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants:
|
||||
|
||||
```bash
|
||||
# Build the MCP server
|
||||
cd cmd/mcp-server && go build -o mcp-server .
|
||||
|
||||
# Export credentials
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
# Start the MCP server (listens on stdin/stdout)
|
||||
./mcp-server
|
||||
```
|
||||
|
||||
**How it works:** The MCP server uses the official Model Context Protocol Go SDK to expose stateless HTTP proxies to all 78 API endpoints. Each MCP tool corresponds to one or more REST endpoints and includes:
|
||||
|
||||
- **Input schema** — typed arguments with JSON schema hints for LLM-friendly introspection
|
||||
- **Binary support** — handles DER-encoded CRL and OCSP responses without mangling
|
||||
- **Error translation** — converts HTTP errors to user-readable messages
|
||||
|
||||
**Example usage from Claude:**
|
||||
|
||||
```
|
||||
User: What certificates are expiring in the next 30 days?
|
||||
|
||||
Claude uses the MCP tools to:
|
||||
1. Call tools.listCertificates with filters: {status: "Expiring"}
|
||||
2. Parse the response
|
||||
3. Display: "mc-api-prod expires in 12 days. mc-cdn-prod expires in 8 days..."
|
||||
|
||||
User: Revoke mc-payments due to key compromise
|
||||
|
||||
Claude uses the MCP tools to:
|
||||
1. Call tools.revokeCertificate with id="mc-payments" reason="keyCompromise"
|
||||
2. Return the audit trail entry showing revocation recorded
|
||||
```
|
||||
|
||||
The MCP server is perfect for:
|
||||
- Compliance audits — "Show me all certificates with PCI tags and their revocation status"
|
||||
- Incident response — "Revoke all certificates issued by the OpenSSL CA issued before 2026-01-01"
|
||||
- Operational queries — "What's the renewal success rate over the last 30 days?"
|
||||
|
||||
---
|
||||
|
||||
## Part 16: Certificate Discovery (M18b + M21)
|
||||
|
||||
certctl discovers existing certificates two ways: **filesystem scanning** (agents scan local directories) and **network scanning** (the server probes TLS endpoints). Both feed into the same triage pipeline.
|
||||
|
||||
### Filesystem Discovery (Agent-Side)
|
||||
|
||||
Configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled:
|
||||
|
||||
```bash
|
||||
# Stop the existing agent
|
||||
docker compose -f deploy/docker-compose.yml stop agent
|
||||
|
||||
# Restart with discovery enabled (scans /tmp/certs every 6 hours, or on startup)
|
||||
docker compose -f deploy/docker-compose.yml run -e CERTCTL_DISCOVERY_DIRS=/tmp/certs agent certctl-agent
|
||||
```
|
||||
|
||||
Or with the CLI flag:
|
||||
|
||||
```bash
|
||||
certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/certs --server http://localhost:8443 --api-key test-key-123
|
||||
```
|
||||
|
||||
### Network Discovery (Server-Side)
|
||||
|
||||
The server can also discover certificates by actively probing TLS endpoints — no agent required. Create a scan target and trigger a scan:
|
||||
|
||||
```bash
|
||||
# Create a network scan target
|
||||
curl -s -X POST $API/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Demo Local Scan",
|
||||
"cidrs": ["127.0.0.1/32"],
|
||||
"ports": [8443],
|
||||
"enabled": true,
|
||||
"scan_interval_hours": 6,
|
||||
"timeout_ms": 5000
|
||||
}' | jq .
|
||||
|
||||
# Trigger an immediate scan (otherwise runs every 6 hours)
|
||||
NST_ID=$(curl -s $API/api/v1/network-scan-targets | jq -r '.data[0].id')
|
||||
curl -s -X POST "$API/api/v1/network-scan-targets/$NST_ID/scan" | jq .
|
||||
|
||||
# List scan targets and their results
|
||||
curl -s $API/api/v1/network-scan-targets | jq .
|
||||
```
|
||||
|
||||
Network-discovered certificates appear in the same discovery pipeline as filesystem-discovered ones, with `agent_id=server-scanner` and `source_format=network`.
|
||||
|
||||
### Triage Discovered Certificates
|
||||
|
||||
Both discovery sources feed into the same triage workflow. Check what was found:
|
||||
|
||||
```bash
|
||||
# List discovered certificates (should show unmanaged certs found by agents and network scans)
|
||||
curl -s "$API/api/v1/discovered-certificates?status=Unmanaged" | jq '.data[] | {id, common_name, expires_at, issuer_dn, status}'
|
||||
|
||||
# Get a summary of all discoveries
|
||||
curl -s $API/api/v1/discovery-summary | jq .
|
||||
```
|
||||
|
||||
If certificates were found, you'll see entries with `status: "Unmanaged"`. Triage them — claim the ones you want to manage or dismiss the ones you don't:
|
||||
|
||||
```bash
|
||||
# Claim a certificate (link it to a managed cert, or create new enrollment)
|
||||
DISCOVERED_ID=$(curl -s "$API/api/v1/discovered-certificates?status=Unmanaged" | jq -r '.data[0].id')
|
||||
curl -s -X POST "$API/api/v1/discovered-certificates/$DISCOVERED_ID/claim" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Migrating from external CA to certctl"}' | jq .
|
||||
|
||||
# Or dismiss a certificate
|
||||
curl -s -X POST "$API/api/v1/discovered-certificates/$DISCOVERED_ID/dismiss" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Self-signed test cert, not production"}' | jq .
|
||||
```
|
||||
|
||||
**How it works:** Filesystem discovery: the agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, extracts metadata (common name, SANs, issuer, expiration, key type, fingerprint) from all PEM and DER files, and POSTs findings to `POST /api/v1/agents/{id}/discoveries`. Network discovery: the server expands CIDR ranges (capped at /20 = 4096 IPs), connects to each IP:port via TLS, extracts the peer certificate chain, and stores results using `server-scanner` as a sentinel agent ID. Both sources deduplicate by fingerprint and store results with a status: **Unmanaged** (discovered, not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage). This gives you a triage workflow: discover → review → claim or dismiss.
|
||||
|
||||
**In the dashboard**, click "Discovered Certificates" in the sidebar to see what agents and network scans found — claim unmanaged certs to bring them under certctl's management, or dismiss them.
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Architecture Summary
|
||||
|
||||
Here's what we just walked through, mapped to the system architecture:
|
||||
@@ -490,19 +1045,23 @@ flowchart TB
|
||||
U2 --> U3["POST /certificates"]
|
||||
U3 --> U4["POST /certificates/{id}/renew"]
|
||||
U4 --> U5["POST /certificates/{id}/deploy"]
|
||||
U5 --> U6["GET /audit"]
|
||||
U5 --> U5b["POST /certificates/{id}/revoke"]
|
||||
U5b --> U6["GET /stats + /metrics"]
|
||||
U6 --> U7["POST /profiles"]
|
||||
U7 --> U8["POST /agent-groups"]
|
||||
U8 --> U9["GET /audit"]
|
||||
end
|
||||
|
||||
subgraph "Control Plane (certctl-server)"
|
||||
API["REST API\nGo net/http"]
|
||||
SVC["Service Layer\nBusiness Logic"]
|
||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
||||
SCHED["Scheduler\n4 background loops"]
|
||||
SCHED["Scheduler\n6 background loops"]
|
||||
CONN["Connector Registry\nIssuer + Target + Notifier"]
|
||||
end
|
||||
|
||||
subgraph "Data Store"
|
||||
PG["PostgreSQL 16\n14 tables, TEXT PKs"]
|
||||
PG["PostgreSQL 16\n21 tables, TEXT PKs"]
|
||||
end
|
||||
|
||||
subgraph "Agent (certctl-agent)"
|
||||
@@ -616,7 +1175,20 @@ echo -e "${YELLOW}Step 9: Recent audit events...${NC}"
|
||||
curl -s $API/api/v1/audit | jq '.data[0:3] | .[] | {action, resource_type, resource_id, timestamp}'
|
||||
echo ""
|
||||
|
||||
# Step 10: Summary
|
||||
# Step 10: Revoke the certificate
|
||||
echo -e "${YELLOW}Step 10: Revoking certificate...${NC}"
|
||||
curl -s -X POST $API/api/v1/certificates/$CERT_ID/revoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "superseded"}' | jq .
|
||||
echo -e "${GREEN}Certificate revoked${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 11: Check stats
|
||||
echo -e "${YELLOW}Step 11: Dashboard summary...${NC}"
|
||||
curl -s $API/api/v1/stats/summary | jq .
|
||||
echo ""
|
||||
|
||||
# Step 12: Summary
|
||||
echo -e "${BLUE}=== Demo Complete ===${NC}"
|
||||
echo ""
|
||||
echo "What happened:"
|
||||
@@ -624,7 +1196,9 @@ echo " 1. Created a team and owner for accountability"
|
||||
echo " 2. Created a managed certificate tracked by certctl"
|
||||
echo " 3. Triggered renewal (would contact the Local CA in production flow)"
|
||||
echo " 4. Triggered deployment (would push to NGINX/F5/IIS targets)"
|
||||
echo " 5. All actions recorded in the audit trail"
|
||||
echo " 5. Revoked the certificate with RFC 5280 reason codes"
|
||||
echo " 6. Checked dashboard stats and metrics"
|
||||
echo " 7. All actions recorded in the audit trail"
|
||||
echo ""
|
||||
echo -e "Open ${GREEN}http://localhost:8443${NC} to see everything in the dashboard."
|
||||
echo "Look for certificate: $CERT_ID"
|
||||
@@ -646,10 +1220,12 @@ If you're using this demo to present certctl to decision-makers, here's the narr
|
||||
1. **Start with the dashboard** — "This is your certificate inventory. Every TLS certificate across your infrastructure, in one place."
|
||||
2. **Point to expiring certs** — "These certificates would have caused outages. Certctl catches them automatically."
|
||||
3. **Show the cert you just created** — "I just created this via the API. It's already tracked, assigned to a team, and will be renewed automatically."
|
||||
4. **Show the audit trail** — "Complete traceability. Every action, every change, every deployment — timestamped and attributed."
|
||||
5. **Show policies** — "Guardrails. We enforce that every certificate has an owner, uses approved CAs, and stays within allowed environments."
|
||||
6. **Show agents** — "Private keys never touch the control plane. Agents handle cryptographic operations locally on your infrastructure."
|
||||
7. **Show the API** — "Everything is API-first. The dashboard is just one consumer. You can integrate with CI/CD, Terraform, or custom tooling."
|
||||
4. **Show revocation** — "If a key is compromised, one-click revocation with RFC 5280 reason codes. CRL and OCSP endpoints are served automatically."
|
||||
5. **Show the audit trail** — "Complete traceability. Every action, every change, every deployment — timestamped and attributed."
|
||||
6. **Show policies** — "Guardrails. We enforce that every certificate has an owner, uses approved CAs, and stays within allowed environments."
|
||||
7. **Show agents** — "Private keys never touch the control plane. Agents handle cryptographic operations locally on your infrastructure."
|
||||
8. **Show dashboard stats** — "Real-time metrics: expiration trends, job success rates, certificate distribution. Everything you need to operate with confidence."
|
||||
9. **Show the CLI and MCP server** — "Terminal users get a CLI tool. AI assistants get MCP integration. Everything is API-first."
|
||||
|
||||
## Teardown
|
||||
|
||||
|
||||
+141
-13
@@ -1,6 +1,6 @@
|
||||
# certctl Demo Guide
|
||||
|
||||
A 5-7 minute guided walkthrough of certctl's dashboard and API. Perfect for stakeholder presentations and team demos.
|
||||
A 5-10 minute guided walkthrough of certctl's dashboard and API. Perfect for stakeholder presentations and team demos.
|
||||
|
||||
New to certificates? Read the [Concepts Guide](concepts.md) first. Want a hands-on demo where you issue certificates yourself? See the [Advanced Demo](demo-advanced.md).
|
||||
|
||||
@@ -28,7 +28,7 @@ The main dashboard shows at a glance:
|
||||
- **Active** — healthy certificates with time remaining
|
||||
- **Renewal success rate** — percentage of automated renewals that succeeded
|
||||
|
||||
Below the stats, you'll see an **expiry timeline** showing how many certs expire in each time bucket (7/14/30/60/90 days), and a **recent activity feed** with the latest audit events.
|
||||
Below the stats, interactive charts provide deeper visibility: an **expiration heatmap** (90-day weekly buckets), **renewal success rate trends** (30-day line chart), **certificate status distribution** (donut chart), and **issuance rate** (30-day bar chart).
|
||||
|
||||
### Certificates View
|
||||
Click "Certificates" in the sidebar to see the full inventory:
|
||||
@@ -57,9 +57,24 @@ Click "Agents" in the sidebar. Four agents are online, one (`iis-prod-agent`) we
|
||||
**6. "What policies are enforced?"**
|
||||
Click "Policies" to see the active rules: required owner metadata, allowed environments, max certificate lifetime, minimum renewal window. Check the violations list to see which certs are non-compliant.
|
||||
|
||||
## API Walkthrough
|
||||
**7. "Can I revoke a compromised cert?"**
|
||||
Click any active certificate, then click the "Revoke" button. A modal appears with RFC 5280 reason codes (Key Compromise, Superseded, Cessation of Operation, etc.). After revocation, the cert shows a revocation banner with the reason and timestamp.
|
||||
|
||||
The dashboard is backed by a real REST API. Try these while the demo is running:
|
||||
**8. "Show me short-lived credentials"**
|
||||
Click "Short-Lived" in the sidebar. This view shows certificates with TTL under 1 hour — live countdown timers, auto-refresh every 10 seconds, and profile-based filtering. These are for service-to-service auth where rapid expiry replaces revocation.
|
||||
|
||||
**9. "What about bulk operations?"**
|
||||
On the Certificates page, select multiple certificates using the checkboxes. A bulk action bar appears with options to trigger renewal, revoke (with reason codes), or reassign ownership — all with progress tracking.
|
||||
|
||||
**10. "How do I see the deployment history?"**
|
||||
Click any certificate, then scroll to the deployment timeline. A visual 4-step timeline shows the lifecycle: Requested → Issued → Deploying → Active. Previous versions show a rollback button.
|
||||
|
||||
**11. "What about certificates already running in production?"**
|
||||
Enable discovery on agents by setting `CERTCTL_DISCOVERY_DIRS` to directories containing certificates (e.g., `/etc/nginx/certs`). Agents scan on startup and every 6 hours, report findings to the control plane. For network-based discovery without agents, enable `CERTCTL_NETWORK_SCAN_ENABLED=true` and configure scan targets via the API — the server probes TLS endpoints on configured CIDR ranges and ports. Click "Discovered Certificates" to see what agents and network scans found — claim unmanaged certs to bring them under certctl's management, or dismiss them.
|
||||
|
||||
## REST API Walkthrough
|
||||
|
||||
The dashboard is backed by a real REST API (91 endpoints). Try these while the demo is running:
|
||||
|
||||
```bash
|
||||
# List all certificates
|
||||
@@ -68,13 +83,22 @@ curl -s http://localhost:8443/api/v1/certificates | jq .
|
||||
# Get expiring certs
|
||||
curl -s "http://localhost:8443/api/v1/certificates?status=expiring" | jq .
|
||||
|
||||
# Advanced query: sort by expiration, sparse fields, cursor pagination
|
||||
curl -s "http://localhost:8443/api/v1/certificates?sort=-expires_at&fields=id,common_name,expires_at" | jq .
|
||||
|
||||
# Time-range filter: certs expiring before June 2026
|
||||
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-06-01T00:00:00Z" | jq .
|
||||
|
||||
# Get a specific certificate
|
||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq .
|
||||
|
||||
# Get deployment targets for a certificate
|
||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
||||
|
||||
# List agents
|
||||
curl -s http://localhost:8443/api/v1/agents | jq .
|
||||
|
||||
# View audit trail
|
||||
# View audit trail (immutable API audit log of all actions)
|
||||
curl -s http://localhost:8443/api/v1/audit | jq .
|
||||
|
||||
# View policy violations (replace POLICY_ID with a real policy ID, e.g. pr-require-owner)
|
||||
@@ -82,9 +106,107 @@ curl -s http://localhost:8443/api/v1/policies/pr-require-owner/violations | jq .
|
||||
|
||||
# Check system health
|
||||
curl -s http://localhost:8443/health | jq .
|
||||
|
||||
# Dashboard stats and metrics
|
||||
curl -s http://localhost:8443/api/v1/stats/summary | jq .
|
||||
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
||||
curl -s http://localhost:8443/api/v1/stats/expiration-timeline | jq .
|
||||
curl -s http://localhost:8443/api/v1/stats/job-trends | jq .
|
||||
curl -s http://localhost:8443/api/v1/stats/issuance-rate | jq .
|
||||
curl -s http://localhost:8443/api/v1/metrics | jq .
|
||||
curl -s http://localhost:8443/api/v1/metrics/prometheus # Prometheus format
|
||||
|
||||
# Certificate profiles
|
||||
curl -s http://localhost:8443/api/v1/profiles | jq .
|
||||
|
||||
# Agent groups
|
||||
curl -s http://localhost:8443/api/v1/agent-groups | jq .
|
||||
|
||||
# Revoke a certificate
|
||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/revoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "superseded"}' | jq .
|
||||
|
||||
# CRL and OCSP endpoints
|
||||
curl -s http://localhost:8443/api/v1/crl | jq .
|
||||
curl -s http://localhost:8443/api/v1/crl/iss-local -o /tmp/crl.der
|
||||
|
||||
# List discovered certificates
|
||||
curl -s http://localhost:8443/api/v1/discovered-certificates | jq .
|
||||
|
||||
# Discovery summary (counts by status)
|
||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
||||
|
||||
# Network scan targets (active TLS scanning)
|
||||
curl -s http://localhost:8443/api/v1/network-scan-targets | jq .
|
||||
```
|
||||
|
||||
## Demo Without Docker
|
||||
## CLI Tool
|
||||
|
||||
certctl ships with a command-line tool (`certctl-cli`) for terminal users:
|
||||
|
||||
```bash
|
||||
# Build the CLI
|
||||
cd cmd/cli && go build -o certctl-cli .
|
||||
|
||||
# Set credentials
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
# List certificates (JSON or table format)
|
||||
./certctl-cli list-certs --format json
|
||||
./certctl-cli list-certs --format table
|
||||
|
||||
# Get certificate details
|
||||
./certctl-cli get-cert mc-api-prod
|
||||
|
||||
# Trigger renewal
|
||||
./certctl-cli renew-cert mc-api-prod
|
||||
|
||||
# Revoke a certificate (with RFC 5280 reason)
|
||||
./certctl-cli revoke-cert mc-api-prod --reason keyCompromise
|
||||
|
||||
# List agents
|
||||
./certctl-cli list-agents
|
||||
|
||||
# List pending jobs
|
||||
./certctl-cli list-jobs
|
||||
|
||||
# Bulk import certificates from PEM files
|
||||
./certctl-cli import /path/to/certs.pem
|
||||
|
||||
# Check health and metrics
|
||||
./certctl-cli health
|
||||
./certctl-cli metrics
|
||||
```
|
||||
|
||||
## MCP Server for AI Integration
|
||||
|
||||
certctl exposes its 78 API endpoints as tools via the Model Context Protocol (MCP), enabling integration with Claude, Cursor, and other AI assistants:
|
||||
|
||||
```bash
|
||||
# Build and run the MCP server
|
||||
cd cmd/mcp-server && go build -o mcp-server .
|
||||
|
||||
export CERTCTL_SERVER_URL="http://localhost:8443"
|
||||
export CERTCTL_API_KEY="test-key-123"
|
||||
|
||||
./mcp-server
|
||||
```
|
||||
|
||||
The MCP server:
|
||||
- Exposes all 78 API endpoints as MCP tools with typed schemas
|
||||
- Handles binary responses (DER CRL, OCSP responses)
|
||||
- Uses stdio transport for Claude/Cursor/OpenClaw integration
|
||||
- Zero external dependencies — pure Go with official MCP SDK
|
||||
|
||||
You can then ask Claude questions like:
|
||||
- "What certificates are expiring in the next 30 days?"
|
||||
- "Revoke the payments certificate due to key compromise"
|
||||
- "Show me the audit trail for the last 10 actions"
|
||||
- "List all certificates with PCI compliance tags"
|
||||
|
||||
## Dashboard Demo Mode
|
||||
|
||||
The dashboard includes a **Demo Mode** that works without any backend. Build and serve the frontend with Vite:
|
||||
|
||||
@@ -109,15 +231,21 @@ The `-v` flag removes the PostgreSQL data volume so you get a clean slate next t
|
||||
|
||||
If you're demoing to a team or customer, here's a suggested flow:
|
||||
|
||||
1. **Start with the dashboard** — "This is your certificate inventory at a glance"
|
||||
1. **Start with the dashboard** — "This is your certificate inventory at a glance, with real-time charts showing expiration trends and renewal health"
|
||||
2. **Show the expiring certs** — "These three would have caused outages without this platform"
|
||||
3. **Click into auth-production** — "Here's the full lifecycle: who owns it, where it's deployed, when it was last renewed"
|
||||
4. **Show the failed VPN cert** — "The system tried 3 times, then alerted the team via webhook"
|
||||
5. **Show agents** — "Agents run on your infrastructure, handle key generation locally, and report back"
|
||||
6. **Show policies** — "Guardrails prevent teams from going outside approved scope"
|
||||
7. **Show the API** — "Everything you see here is API-first, so you can automate on top of it"
|
||||
3. **Click into auth-production** — "Here's the full lifecycle: who owns it, where it's deployed, deployment timeline, when it was last renewed"
|
||||
4. **Show revocation** — "If a key is compromised, one click revokes the cert with an RFC 5280 reason code. CRL and OCSP are served automatically"
|
||||
5. **Show the failed VPN cert** — "The system tried 3 times, then alerted the team via Slack, Teams, PagerDuty, or OpsGenie"
|
||||
6. **Show agents and fleet overview** — "Agents run on your infrastructure, handle key generation locally (ECDSA P-256). Fleet view shows OS, architecture, and version distribution"
|
||||
7. **Show profiles** — "Certificate profiles enforce crypto constraints — key types, max TTL, compliance requirements"
|
||||
8. **Show policies** — "Guardrails prevent teams from going outside approved scope"
|
||||
9. **Show bulk operations** — "Select multiple certs, trigger renewal or revoke in bulk with progress tracking"
|
||||
10. **Show certificate discovery** — "We discover certificates two ways: agents scan local filesystems, and the server actively probes TLS endpoints on your network. We deduplicate by fingerprint, show you what we found, and let you claim them or dismiss them"
|
||||
11. **Show the immutable audit trail** — "Every action in the system is recorded: who did it, what they did, when, what changed. Export to CSV/JSON for compliance"
|
||||
12. **Show advanced query features** — "Sort by any field, filter by date range, paginate efficiently with cursor-based pagination, select just the fields you need"
|
||||
13. **Show the CLI and MCP server** — "Terminal users get `certctl-cli` with 10 subcommands. AI assistants get MCP integration with 78 tools. Everything is API-first"
|
||||
|
||||
The whole walkthrough takes 5-7 minutes.
|
||||
The whole walkthrough takes 5-10 minutes.
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
+1216
File diff suppressed because it is too large
Load Diff
+191
@@ -0,0 +1,191 @@
|
||||
# MCP Server Guide
|
||||
|
||||
certctl ships with an MCP (Model Context Protocol) server that lets AI assistants manage your certificate infrastructure through natural language. Ask Claude to "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?" and the MCP server translates that into API calls against your certctl instance.
|
||||
|
||||
This guide covers setup, configuration, and usage with Claude, Cursor, and other MCP-compatible tools.
|
||||
|
||||
## What Is MCP?
|
||||
|
||||
MCP is an open protocol that connects AI assistants to external tools and data sources. Instead of copying and pasting API responses into a chat window, MCP lets the AI call your tools directly. The certctl MCP server exposes all 78 API endpoints as MCP tools — the AI sees typed schemas describing what each tool does, what parameters it accepts, and what it returns.
|
||||
|
||||
The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport. It's a stateless HTTP proxy: every MCP tool call becomes an HTTP request to the certctl REST API. No new state, no new database tables, no new attack surface beyond what the API already exposes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need:
|
||||
|
||||
1. A running certctl server (see [Quick Start](quickstart.md))
|
||||
2. The MCP server binary — either built from source or from a Docker image
|
||||
3. An MCP-compatible AI client (Claude Desktop, Cursor, VS Code with Copilot, etc.)
|
||||
|
||||
## Building the MCP Server
|
||||
|
||||
```bash
|
||||
cd certctl
|
||||
go build -o certctl-mcp ./cmd/mcp-server/
|
||||
```
|
||||
|
||||
The binary has zero runtime dependencies beyond the certctl server it connects to.
|
||||
|
||||
## Configuration
|
||||
|
||||
The MCP server reads two environment variables:
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | No | `http://localhost:8443` | URL of the certctl REST API |
|
||||
| `CERTCTL_API_KEY` | No | (empty) | API key for authentication (passed as `Bearer` token) |
|
||||
|
||||
If your certctl server has auth enabled (the default), you must provide the API key. The MCP server passes it through to every HTTP request.
|
||||
|
||||
## Setting Up with Claude Desktop
|
||||
|
||||
Add this to your Claude Desktop MCP configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"certctl": {
|
||||
"command": "/path/to/certctl-mcp",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Desktop. You should see "certctl" appear in the MCP tools list with 78 available tools.
|
||||
|
||||
## Setting Up with Cursor
|
||||
|
||||
In Cursor, go to Settings → MCP Servers and add:
|
||||
|
||||
```json
|
||||
{
|
||||
"certctl": {
|
||||
"command": "/path/to/certctl-mcp",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Setting Up with Claude Code
|
||||
|
||||
Add certctl as an MCP server in your project's `.mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"certctl": {
|
||||
"command": "/path/to/certctl-mcp",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
The MCP server registers 78 tools organized across 16 resource domains:
|
||||
|
||||
| Domain | Tools | Examples |
|
||||
|--------|-------|---------|
|
||||
| Certificates | 9 | List, get, create, update, archive, versions, renew, deploy, revoke |
|
||||
| CRL & OCSP | 3 | Get JSON CRL, get DER CRL by issuer, check OCSP status |
|
||||
| Issuers | 6 | List, get, create, update, delete, test connection |
|
||||
| Targets | 5 | List, get, create, update, delete |
|
||||
| Agents | 8 | List, get, register, heartbeat, CSR submit, certificate pickup, get work, report job status |
|
||||
| Jobs | 5 | List, get, cancel, approve, reject |
|
||||
| Policies | 6 | List, get, create, update, delete, list violations |
|
||||
| Profiles | 5 | List, get, create, update, delete |
|
||||
| Teams | 5 | List, get, create, update, delete |
|
||||
| Owners | 5 | List, get, create, update, delete |
|
||||
| Agent Groups | 6 | List, get, create, update, delete, list members |
|
||||
| Audit | 2 | List events (with filters), get event by ID |
|
||||
| Notifications | 3 | List, get, mark as read |
|
||||
| Stats | 5 | Summary, certs by status, expiration timeline, job trends, issuance rate |
|
||||
| Metrics | 1 | System metrics (gauges, counters, uptime) |
|
||||
| Health | 4 | Health check, readiness probe, auth info, auth check |
|
||||
|
||||
Every tool has typed input parameters with `jsonschema` descriptions, so the AI knows exactly what arguments to provide and what each field means.
|
||||
|
||||
## Example Conversations
|
||||
|
||||
Once configured, you can interact with certctl through natural language:
|
||||
|
||||
**"Show me all certificates expiring in the next 14 days"**
|
||||
The AI calls `certctl_list_certificates` with `status=Expiring` and interprets the results.
|
||||
|
||||
**"Renew the API production certificate"**
|
||||
The AI calls `certctl_trigger_renewal` with `id=mc-api-prod`.
|
||||
|
||||
**"Who owns the payments gateway cert?"**
|
||||
The AI calls `certctl_get_certificate` with `id=mc-payments-prod` and reads the `owner_id` and `team_id` fields.
|
||||
|
||||
**"Are any agents offline?"**
|
||||
The AI calls `certctl_list_agents` and checks the heartbeat timestamps.
|
||||
|
||||
**"Revoke the old VPN cert — the key was compromised"**
|
||||
The AI calls `certctl_revoke_certificate` with `id=mc-vpn-old` and `reason=keyCompromise`.
|
||||
|
||||
**"Give me a summary of the certificate fleet"**
|
||||
The AI calls `certctl_dashboard_summary` for aggregate stats, then optionally `certctl_certificates_by_status` for the breakdown.
|
||||
|
||||
**"Create a new cert for staging.api.example.com owned by the platform team"**
|
||||
The AI calls `certctl_create_certificate` with the common name, team ID, and owner ID.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AI["AI Assistant\n(Claude, Cursor)"]
|
||||
MCP["certctl MCP\ncmd/mcp-server/"]
|
||||
SERVER["certctl Server\n:8443"]
|
||||
|
||||
AI <-->|"stdio"| MCP
|
||||
MCP -->|"HTTP + Bearer token"| SERVER
|
||||
|
||||
MCP ~~~ TOOLS["78 tools · 16 domains\nTyped input structs"]
|
||||
```
|
||||
|
||||
The MCP server is intentionally thin:
|
||||
|
||||
- **No state** — every request is a pass-through HTTP call. Restart it anytime.
|
||||
- **No new auth** — uses the same API key as the REST API.
|
||||
- **No new dependencies** — just the official MCP Go SDK (`modelcontextprotocol/go-sdk`).
|
||||
- **No new attack surface** — the AI can only do what the API key allows.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
The MCP server inherits the security properties of the REST API:
|
||||
|
||||
- **API key scoping**: The MCP server uses whatever API key you configure. If certctl gets API key scoping in a future release (per-resource or per-action permissions), the MCP server will automatically respect those restrictions.
|
||||
- **Audit trail**: Every tool call results in an HTTP request that's logged in the API audit middleware — actor, method, path, status, and latency are all recorded.
|
||||
- **Read-only usage**: For read-only AI access, you could configure a restricted API key (when key scoping ships). Until then, be aware that the AI can call write endpoints (create, update, delete, revoke) if the API key permits it.
|
||||
- **No private key exposure**: The MCP server never sees or transmits private keys — the same architectural guarantee as the REST API.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"MCP server not connecting"**
|
||||
Check that `CERTCTL_SERVER_URL` is reachable from where the MCP binary runs. Try `curl $CERTCTL_SERVER_URL/health` to verify.
|
||||
|
||||
**"401 Unauthorized on every tool call"**
|
||||
Your `CERTCTL_API_KEY` is missing or wrong. Check the key matches what the certctl server expects.
|
||||
|
||||
**"Tool calls return empty results"**
|
||||
The certctl server might have no data. Run the demo seed (`docker compose up`) to populate demo data, or check that your database has records.
|
||||
|
||||
## What's Next
|
||||
|
||||
- [Quick Start](quickstart.md) — Get certctl running locally
|
||||
- [OpenAPI Spec](openapi.md) — Full API reference and SDK generation
|
||||
- [Architecture](architecture.md) — System design deep dive
|
||||
- [Concepts](concepts.md) — Certificate lifecycle fundamentals
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# OpenAPI Specification Guide
|
||||
|
||||
certctl ships with a complete OpenAPI 3.1 specification at `api/openapi.yaml`. This spec documents all 78 API operations currently specified, every request/response schema, pagination conventions, authentication requirements, and error formats. It's the single source of truth for the documented REST API. (Note: The spec will be updated to include 7 additional certificate discovery endpoints from M18b.)
|
||||
|
||||
This guide covers how to use the spec for API exploration, client SDK generation, and integration testing.
|
||||
|
||||
## Where to Find It
|
||||
|
||||
The spec lives at `api/openapi.yaml` in the repository root. It's versioned alongside the code and updated with every API change.
|
||||
|
||||
```bash
|
||||
# View the spec
|
||||
cat api/openapi.yaml
|
||||
|
||||
# Count operations
|
||||
grep "operationId:" api/openapi.yaml | wc -l
|
||||
# 78 (includes health + ready, 7 discovery endpoints pending spec update)
|
||||
```
|
||||
|
||||
## Viewing with Swagger UI
|
||||
|
||||
The fastest way to explore the API interactively is Swagger UI. Run it as a Docker container pointing at the spec:
|
||||
|
||||
```bash
|
||||
# From the certctl repo root
|
||||
docker run -p 8080:8080 \
|
||||
-e SWAGGER_JSON=/spec/openapi.yaml \
|
||||
-v $(pwd)/api:/spec \
|
||||
swaggerapi/swagger-ui
|
||||
```
|
||||
|
||||
Open http://localhost:8080 to see the full API reference with "Try it out" buttons for every endpoint.
|
||||
|
||||
Alternatively, use Redoc for a cleaner read-only view:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:80 \
|
||||
-e SPEC_URL=/spec/openapi.yaml \
|
||||
-v $(pwd)/api:/usr/share/nginx/html/spec \
|
||||
redocly/redoc
|
||||
```
|
||||
|
||||
## API Structure
|
||||
|
||||
The spec organizes endpoints into 16 tags:
|
||||
|
||||
| Tag | Endpoints | Description |
|
||||
|-----|-----------|-------------|
|
||||
| Certificates | 12 | CRUD, versions, renewal, deployment, revocation, deployments |
|
||||
| CRL & OCSP | 3 | JSON CRL, DER CRL per issuer, OCSP responder |
|
||||
| Issuers | 5 | CA connector management |
|
||||
| Targets | 5 | Deployment target management |
|
||||
| Agents | 7 | Registration, heartbeat, CSR submission, work polling |
|
||||
| Jobs | 5 | Job queue with approve/reject |
|
||||
| Policies | 5 | Policy rules and violations |
|
||||
| Profiles | 5 | Certificate enrollment profiles |
|
||||
| Teams | 5 | Team management |
|
||||
| Owners | 5 | Certificate owners |
|
||||
| Agent Groups | 5 | Dynamic agent grouping |
|
||||
| Audit | 2 | Immutable audit trail |
|
||||
| Notifications | 3 | Notification events |
|
||||
| Stats | 5 | Dashboard statistics |
|
||||
| Metrics | 1 | System metrics |
|
||||
| Health | 3 | Health, readiness, auth info |
|
||||
|
||||
## Authentication
|
||||
|
||||
The spec declares a `bearerAuth` security scheme applied globally. All endpoints under `/api/v1/` require a Bearer token by default:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your-api-key" \
|
||||
http://localhost:8443/api/v1/certificates
|
||||
```
|
||||
|
||||
Three endpoints are exempt from auth (declared with `security: []` in the spec): `/health`, `/ready`, and `/api/v1/auth/info`. The auth info endpoint tells clients whether authentication is enabled and what type is required — useful for GUIs that need to show/hide a login screen.
|
||||
|
||||
## Pagination Convention
|
||||
|
||||
All list endpoints follow the same pagination pattern:
|
||||
|
||||
**Request parameters:**
|
||||
- `page` (integer, default 1) — page number
|
||||
- `per_page` (integer, default 50, max 500) — results per page
|
||||
|
||||
**Response envelope:**
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"per_page": 50
|
||||
}
|
||||
```
|
||||
|
||||
Certificates also support cursor-based pagination for large datasets:
|
||||
- `cursor` (string) — opaque cursor token from previous response
|
||||
- `page_size` (integer) — results per page when using cursor mode
|
||||
|
||||
## Generating Client SDKs
|
||||
|
||||
The OpenAPI spec can generate typed client libraries for any language. Here are examples using common generators:
|
||||
|
||||
### TypeScript (openapi-typescript-codegen)
|
||||
|
||||
```bash
|
||||
npx openapi-typescript-codegen \
|
||||
--input api/openapi.yaml \
|
||||
--output src/generated/certctl \
|
||||
--client axios
|
||||
```
|
||||
|
||||
### Python (openapi-python-client)
|
||||
|
||||
```bash
|
||||
pip install openapi-python-client
|
||||
openapi-python-client generate --path api/openapi.yaml
|
||||
```
|
||||
|
||||
### Go (oapi-codegen)
|
||||
|
||||
```bash
|
||||
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
|
||||
oapi-codegen -generate types,client -package certctl api/openapi.yaml > certctl_client.go
|
||||
```
|
||||
|
||||
### Java (OpenAPI Generator)
|
||||
|
||||
```bash
|
||||
npx @openapitools/openapi-generator-cli generate \
|
||||
-i api/openapi.yaml \
|
||||
-g java \
|
||||
-o generated/java-client
|
||||
```
|
||||
|
||||
## Validating the Spec
|
||||
|
||||
Verify the spec is valid OpenAPI 3.1:
|
||||
|
||||
```bash
|
||||
# Using spectral (recommended)
|
||||
npx @stoplight/spectral-cli lint api/openapi.yaml
|
||||
|
||||
# Using swagger-cli
|
||||
npx @apidevtools/swagger-cli validate api/openapi.yaml
|
||||
```
|
||||
|
||||
## Using with Postman
|
||||
|
||||
Import the spec directly into Postman:
|
||||
|
||||
1. Open Postman → Import → File → select `api/openapi.yaml`
|
||||
2. Postman creates a collection with all 78 documented operations organized by tag
|
||||
3. Set the `baseUrl` variable to `http://localhost:8443`
|
||||
4. Add an `Authorization: Bearer your-api-key` header to the collection
|
||||
|
||||
## Key Schemas
|
||||
|
||||
The spec defines typed schemas for all domain objects. Key schemas to know:
|
||||
|
||||
| Schema | Description |
|
||||
|--------|-------------|
|
||||
| `ManagedCertificate` | Core certificate record with status, expiry, owner, tags, profile |
|
||||
| `CertificateVersion` | Individual cert version with PEM, serial, fingerprint, validity |
|
||||
| `Agent` | Agent with heartbeat, metadata (OS, arch, IP, version), capabilities |
|
||||
| `Job` | Job record with type, status (7 states), certificate/target references |
|
||||
| `PolicyRule` | Policy with type (5 types), config, severity, enabled state |
|
||||
| `CertificateProfile` | Enrollment profile with allowed key types, max TTL, constraints |
|
||||
| `AuditEvent` | Immutable audit record with actor, action, resource, timestamp |
|
||||
| `RevocationReason` | RFC 5280 reason code enum (8 values) |
|
||||
| `DashboardSummary` | Aggregate stats (total certs, expiring, agents, jobs) |
|
||||
|
||||
## Integration Testing
|
||||
|
||||
Use the spec to generate contract tests that verify the API matches the spec:
|
||||
|
||||
```bash
|
||||
# Using schemathesis for fuzz testing against the spec
|
||||
pip install schemathesis
|
||||
schemathesis run api/openapi.yaml \
|
||||
--base-url http://localhost:8443 \
|
||||
--header "Authorization: Bearer your-api-key"
|
||||
```
|
||||
|
||||
This sends randomized valid requests to every endpoint and verifies the responses match the declared schemas.
|
||||
|
||||
## What's Next
|
||||
|
||||
- [MCP Server Guide](mcp.md) — AI-native access to the certctl API
|
||||
- [Quick Start](quickstart.md) — Get certctl running locally
|
||||
- [Connector Guide](connectors.md) — Build custom issuer and target connectors
|
||||
- [Architecture](architecture.md) — System design deep dive
|
||||
+169
-2
@@ -1,6 +1,6 @@
|
||||
# Quick Start Guide
|
||||
|
||||
Get certctl running locally and managing certificates in under 5 minutes.
|
||||
Get certctl running locally and managing certificates in under 5 minutes. With TLS certificate lifespans dropping to 47 days by 2029, automated lifecycle management isn't optional — it's infrastructure. This guide gets you hands-on with certctl's automation loop: tracking, renewing, and deploying certificates without manual intervention.
|
||||
|
||||
New to certificates? Read the [Concepts Guide](concepts.md) first — it explains TLS, CAs, and private keys in plain language.
|
||||
|
||||
@@ -206,6 +206,24 @@ curl -s http://localhost:8443/api/v1/audit | jq '.data[0:3]'
|
||||
|
||||
Refresh the dashboard at http://localhost:8443 — your new certificate appears in the inventory.
|
||||
|
||||
### Step 5: Revoke a certificate
|
||||
|
||||
If a certificate's private key is compromised or the service is decommissioned, revoke it:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "superseded"}' | jq .
|
||||
```
|
||||
|
||||
Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`. If you omit the reason, it defaults to `unspecified`.
|
||||
|
||||
Check the CRL to confirm:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8443/api/v1/crl | jq .
|
||||
```
|
||||
|
||||
## Understanding the Demo Data
|
||||
|
||||
The demo comes pre-loaded with realistic data so you can explore certctl's features immediately:
|
||||
@@ -214,14 +232,107 @@ The demo comes pre-loaded with realistic data so you can explore certctl's featu
|
||||
|----------|-------|---------|
|
||||
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
||||
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
||||
| Issuers | 3 | Local Dev CA, Let's Encrypt Staging, DigiCert |
|
||||
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
|
||||
| Agents | 5 | nginx-prod, nginx-staging, f5-prod, iis-prod, data-agent |
|
||||
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
|
||||
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
|
||||
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
||||
| Profiles | 3 | Default TLS, Short-Lived, High-Security |
|
||||
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
||||
|
||||
Certificates have varied statuses so you can see what each state looks like in the dashboard: healthy certs with 45+ days remaining, certs about to expire (5-12 days), certs that already expired, and a failed renewal.
|
||||
|
||||
## Advanced API Features
|
||||
|
||||
### Sorting and filtering
|
||||
|
||||
```bash
|
||||
# Sort certificates by expiration date (ascending)
|
||||
curl -s "http://localhost:8443/api/v1/certificates?sort=notAfter" | jq .
|
||||
|
||||
# Sort descending (prefix with -)
|
||||
curl -s "http://localhost:8443/api/v1/certificates?sort=-createdAt" | jq .
|
||||
|
||||
# Time-range filters (RFC3339 format)
|
||||
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
|
||||
curl -s "http://localhost:8443/api/v1/certificates?created_after=2026-03-01T00:00:00Z" | jq .
|
||||
```
|
||||
|
||||
Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commonName`, `name`, `status`, `environment`.
|
||||
|
||||
### Sparse field selection
|
||||
|
||||
Request only the fields you need to reduce response size:
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8443/api/v1/certificates?fields=id,common_name,status,expires_at" | jq .
|
||||
```
|
||||
|
||||
### Cursor-based pagination
|
||||
|
||||
For large datasets, cursor pagination is more efficient than page-based:
|
||||
|
||||
```bash
|
||||
# First page
|
||||
curl -s "http://localhost:8443/api/v1/certificates?page_size=5" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
|
||||
|
||||
# Next page (use the next_cursor from the previous response)
|
||||
curl -s "http://localhost:8443/api/v1/certificates?cursor=<next_cursor_value>&page_size=5" | jq .
|
||||
```
|
||||
|
||||
### Stats and metrics
|
||||
|
||||
```bash
|
||||
# Dashboard summary
|
||||
curl -s http://localhost:8443/api/v1/stats/summary | jq .
|
||||
|
||||
# Certificates by status
|
||||
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
||||
|
||||
# Expiration timeline (next 90 days)
|
||||
curl -s "http://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
|
||||
|
||||
# Job trends (last 30 days)
|
||||
curl -s "http://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
|
||||
|
||||
# System metrics (JSON)
|
||||
curl -s http://localhost:8443/api/v1/metrics | jq .
|
||||
|
||||
# System metrics (Prometheus format — for scraping by Prometheus, Grafana Agent, Datadog)
|
||||
curl -s http://localhost:8443/api/v1/metrics/prometheus
|
||||
```
|
||||
|
||||
### Certificate profiles
|
||||
|
||||
```bash
|
||||
# List all profiles
|
||||
curl -s http://localhost:8443/api/v1/profiles | jq .
|
||||
|
||||
# Get a specific profile
|
||||
curl -s http://localhost:8443/api/v1/profiles/prof-default | jq .
|
||||
```
|
||||
|
||||
### Certificate deployments
|
||||
|
||||
```bash
|
||||
# View deployment targets for a certificate
|
||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
||||
```
|
||||
|
||||
### Interactive approval workflow
|
||||
|
||||
```bash
|
||||
# Approve a pending job
|
||||
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Approved for production deployment"}' | jq .
|
||||
|
||||
# Reject a pending job
|
||||
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "Key type does not meet compliance requirements"}' | jq .
|
||||
```
|
||||
|
||||
## Tear Down
|
||||
|
||||
```bash
|
||||
@@ -230,9 +341,65 @@ docker compose -f deploy/docker-compose.yml down -v
|
||||
|
||||
The `-v` flag removes the PostgreSQL data volume so you get a clean slate next time.
|
||||
|
||||
### Certificate Discovery
|
||||
|
||||
Agents can scan your infrastructure for existing certificates you're not yet managing:
|
||||
|
||||
```bash
|
||||
# Configure agent to scan directories
|
||||
export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/var/lib/certs"
|
||||
|
||||
# Agent scans on startup + every 6 hours, reports findings to control plane
|
||||
```
|
||||
|
||||
Query discovered certificates:
|
||||
|
||||
```bash
|
||||
# List all discovered certs from a specific agent
|
||||
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
|
||||
|
||||
# Get discovery summary (counts by status)
|
||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
||||
|
||||
# Claim a discovered cert (link to managed cert)
|
||||
curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
|
||||
```
|
||||
|
||||
### Network Certificate Discovery
|
||||
|
||||
The server can also discover certificates by scanning TLS endpoints directly — no agent required:
|
||||
|
||||
```bash
|
||||
# Enable network scanning (set in environment or docker-compose)
|
||||
export CERTCTL_NETWORK_SCAN_ENABLED=true
|
||||
|
||||
# Create a scan target (e.g., scan your internal network on port 443)
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Internal Network",
|
||||
"cidrs": ["10.0.1.0/24"],
|
||||
"ports": [443, 8443],
|
||||
"enabled": true,
|
||||
"scan_interval_hours": 6,
|
||||
"timeout_ms": 5000
|
||||
}' | jq .
|
||||
|
||||
# Trigger an immediate scan
|
||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-internal-network/scan | jq .
|
||||
|
||||
# List scan targets with results
|
||||
curl -s http://localhost:8443/api/v1/network-scan-targets | jq .
|
||||
```
|
||||
|
||||
Discovered network certificates appear in the same `GET /api/v1/discovered-certificates` list as filesystem-discovered certs, with `agent_id=server-scanner` and `source_format=network`.
|
||||
|
||||
## What's Next
|
||||
|
||||
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA and watch it appear in the dashboard
|
||||
- **[Demo Walkthrough](demo-guide.md)** — Guided 5-minute stakeholder presentation
|
||||
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
||||
- **[Connector Guide](connectors.md)** — Build custom connectors for your infrastructure
|
||||
- **[CLI Reference](cli.md)** — Manage certificates from your terminal
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,20 @@
|
||||
module github.com/shankar0123/certctl
|
||||
|
||||
go 1.22.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||
)
|
||||
|
||||
require golang.org/x/crypto v0.31.0
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// MockAgentGroupService is a mock implementation of AgentGroupService interface.
|
||||
type MockAgentGroupService struct {
|
||||
ListAgentGroupsFn func(page, perPage int) ([]domain.AgentGroup, int64, error)
|
||||
GetAgentGroupFn func(id string) (*domain.AgentGroup, error)
|
||||
CreateAgentGroupFn func(group domain.AgentGroup) (*domain.AgentGroup, error)
|
||||
UpdateAgentGroupFn func(id string, group domain.AgentGroup) (*domain.AgentGroup, error)
|
||||
DeleteAgentGroupFn func(id string) error
|
||||
ListMembersFn func(id string) ([]domain.Agent, int64, error)
|
||||
}
|
||||
|
||||
func (m *MockAgentGroupService) ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) {
|
||||
if m.ListAgentGroupsFn != nil {
|
||||
return m.ListAgentGroupsFn(page, perPage)
|
||||
}
|
||||
return []domain.AgentGroup{}, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockAgentGroupService) GetAgentGroup(id string) (*domain.AgentGroup, error) {
|
||||
if m.GetAgentGroupFn != nil {
|
||||
return m.GetAgentGroupFn(id)
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (m *MockAgentGroupService) CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||
if m.CreateAgentGroupFn != nil {
|
||||
return m.CreateAgentGroupFn(group)
|
||||
}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (m *MockAgentGroupService) UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||
if m.UpdateAgentGroupFn != nil {
|
||||
return m.UpdateAgentGroupFn(id, group)
|
||||
}
|
||||
group.ID = id
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (m *MockAgentGroupService) DeleteAgentGroup(id string) error {
|
||||
if m.DeleteAgentGroupFn != nil {
|
||||
return m.DeleteAgentGroupFn(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockAgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) {
|
||||
if m.ListMembersFn != nil {
|
||||
return m.ListMembersFn(id)
|
||||
}
|
||||
return []domain.Agent{}, 0, nil
|
||||
}
|
||||
|
||||
func TestListAgentGroups_Success(t *testing.T) {
|
||||
group := domain.AgentGroup{
|
||||
ID: "ag-linux",
|
||||
Name: "Linux Agents",
|
||||
Description: "All Linux-based agents",
|
||||
MatchOS: "linux",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
mock := &MockAgentGroupService{
|
||||
ListAgentGroupsFn: func(page, perPage int) ([]domain.AgentGroup, int64, error) {
|
||||
return []domain.AgentGroup{group}, 1, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListAgentGroups(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("expected total 1, got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgentGroups_ServiceError(t *testing.T) {
|
||||
mock := &MockAgentGroupService{
|
||||
ListAgentGroupsFn: func(page, perPage int) ([]domain.AgentGroup, int64, error) {
|
||||
return nil, 0, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListAgentGroups(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgentGroups_MethodNotAllowed(t *testing.T) {
|
||||
h := NewAgentGroupHandler(&MockAgentGroupService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListAgentGroups(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentGroup_Success(t *testing.T) {
|
||||
mock := &MockAgentGroupService{
|
||||
GetAgentGroupFn: func(id string) (*domain.AgentGroup, error) {
|
||||
return &domain.AgentGroup{
|
||||
ID: id,
|
||||
Name: "Linux Agents",
|
||||
MatchOS: "linux",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-linux", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.GetAgentGroup(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentGroup_NotFound(t *testing.T) {
|
||||
mock := &MockAgentGroupService{
|
||||
GetAgentGroupFn: func(id string) (*domain.AgentGroup, error) {
|
||||
return nil, ErrMockNotFound
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-ghost", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.GetAgentGroup(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentGroup_Success(t *testing.T) {
|
||||
mock := &MockAgentGroupService{
|
||||
CreateAgentGroupFn: func(group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||
group.ID = "ag-new"
|
||||
return &group, nil
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "Ubuntu Agents",
|
||||
"match_os": "linux",
|
||||
"enabled": true,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.CreateAgentGroup(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentGroup_MissingName(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"match_os": "linux",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
h := NewAgentGroupHandler(&MockAgentGroupService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.CreateAgentGroup(w, req)
|
||||
|
||||
// Handler may or may not validate name — service does. Either 400 or 500 acceptable.
|
||||
if w.Code == http.StatusCreated || w.Code == http.StatusOK {
|
||||
t.Fatalf("expected error for missing name, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentGroup_InvalidJSON(t *testing.T) {
|
||||
h := NewAgentGroupHandler(&MockAgentGroupService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", bytes.NewReader([]byte("not json")))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.CreateAgentGroup(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAgentGroup_Success(t *testing.T) {
|
||||
var deletedID string
|
||||
mock := &MockAgentGroupService{
|
||||
DeleteAgentGroupFn: func(id string) error {
|
||||
deletedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agent-groups/ag-linux", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.DeleteAgentGroup(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status 204, got %d", w.Code)
|
||||
}
|
||||
if deletedID != "ag-linux" {
|
||||
t.Errorf("expected deleted ID 'ag-linux', got '%s'", deletedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAgentGroup_ServiceError(t *testing.T) {
|
||||
mock := &MockAgentGroupService{
|
||||
DeleteAgentGroupFn: func(id string) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agent-groups/ag-linux", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.DeleteAgentGroup(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgentGroupMembers_Success(t *testing.T) {
|
||||
mock := &MockAgentGroupService{
|
||||
ListMembersFn: func(id string) ([]domain.Agent, int64, error) {
|
||||
return []domain.Agent{
|
||||
{ID: "agent-001", Name: "web-1", Hostname: "web-1.prod"},
|
||||
}, 1, nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-linux/members", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListAgentGroupMembers(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("expected total 1, got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgentGroupMembers_ServiceError(t *testing.T) {
|
||||
mock := &MockAgentGroupService{
|
||||
ListMembersFn: func(id string) ([]domain.Agent, int64, error) {
|
||||
return nil, 0, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
h := NewAgentGroupHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-linux/members", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListAgentGroupMembers(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// AgentGroupService defines the service interface for agent group operations.
|
||||
type AgentGroupService interface {
|
||||
ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error)
|
||||
GetAgentGroup(id string) (*domain.AgentGroup, error)
|
||||
CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error)
|
||||
UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error)
|
||||
DeleteAgentGroup(id string) error
|
||||
ListMembers(id string) ([]domain.Agent, int64, error)
|
||||
}
|
||||
|
||||
// AgentGroupHandler handles HTTP requests for agent group operations.
|
||||
type AgentGroupHandler struct {
|
||||
svc AgentGroupService
|
||||
}
|
||||
|
||||
// NewAgentGroupHandler creates a new AgentGroupHandler with a service dependency.
|
||||
func NewAgentGroupHandler(svc AgentGroupService) AgentGroupHandler {
|
||||
return AgentGroupHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ListAgentGroups lists all agent groups.
|
||||
// GET /api/v1/agent-groups?page=1&per_page=50
|
||||
func (h AgentGroupHandler) ListAgentGroups(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
page := 1
|
||||
perPage := 50
|
||||
query := r.URL.Query()
|
||||
if p := query.Get("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
if pp := query.Get("per_page"); pp != "" {
|
||||
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
|
||||
perPage = parsed
|
||||
}
|
||||
}
|
||||
|
||||
groups, total, err := h.svc.ListAgentGroups(page, perPage)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agent groups", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
response := PagedResponse{
|
||||
Data: groups,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAgentGroup retrieves a single agent group by ID.
|
||||
// GET /api/v1/agent-groups/{id}
|
||||
func (h AgentGroupHandler) GetAgentGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.svc.GetAgentGroup(id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, group)
|
||||
}
|
||||
|
||||
// CreateAgentGroup creates a new agent group.
|
||||
// POST /api/v1/agent-groups
|
||||
func (h AgentGroupHandler) CreateAgentGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
var group domain.AgentGroup
|
||||
if err := json.NewDecoder(r.Body).Decode(&group); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateRequired("name", group.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateStringLength("name", group.Name, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateAgentGroup(group)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create agent group", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// UpdateAgentGroup updates an existing agent group.
|
||||
// PUT /api/v1/agent-groups/{id}
|
||||
func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/")
|
||||
parts := strings.Split(id, "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID)
|
||||
return
|
||||
}
|
||||
id = parts[0]
|
||||
|
||||
var group domain.AgentGroup
|
||||
if err := json.NewDecoder(r.Body).Decode(&group); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateAgentGroup(id, group)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update agent group", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteAgentGroup deletes an agent group.
|
||||
// DELETE /api/v1/agent-groups/{id}
|
||||
func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAgentGroup(id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete agent group", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListAgentGroupMembers lists agents in a group.
|
||||
// GET /api/v1/agent-groups/{id}/members
|
||||
func (h AgentGroupHandler) ListAgentGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Parse ID from: /api/v1/agent-groups/{id}/members
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID)
|
||||
return
|
||||
}
|
||||
id := parts[0]
|
||||
|
||||
members, total, err := h.svc.ListMembers(id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list group members", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
response := PagedResponse{
|
||||
Data: members,
|
||||
Total: total,
|
||||
Page: 1,
|
||||
PerPage: int(total),
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ type MockAgentService struct {
|
||||
ListAgentsFn func(page, perPage int) ([]domain.Agent, int64, error)
|
||||
GetAgentFn func(id string) (*domain.Agent, error)
|
||||
RegisterAgentFn func(agent domain.Agent) (*domain.Agent, error)
|
||||
HeartbeatFn func(agentID string) error
|
||||
HeartbeatFn func(agentID string, metadata *domain.AgentMetadata) error
|
||||
CSRSubmitFn func(agentID string, csrPEM string) (string, error)
|
||||
CSRSubmitForCertFn func(agentID string, certID string, csrPEM string) (string, error)
|
||||
CertificatePickupFn func(agentID, certID string) (string, error)
|
||||
@@ -46,9 +46,9 @@ func (m *MockAgentService) RegisterAgent(agent domain.Agent) (*domain.Agent, err
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockAgentService) Heartbeat(agentID string) error {
|
||||
func (m *MockAgentService) Heartbeat(agentID string, metadata *domain.AgentMetadata) error {
|
||||
if m.HeartbeatFn != nil {
|
||||
return m.HeartbeatFn(agentID)
|
||||
return m.HeartbeatFn(agentID, metadata)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func TestRegisterAgent_InvalidBody(t *testing.T) {
|
||||
// Test Heartbeat - success case
|
||||
func TestHeartbeat_Success(t *testing.T) {
|
||||
mock := &MockAgentService{
|
||||
HeartbeatFn: func(agentID string) error {
|
||||
HeartbeatFn: func(agentID string, metadata *domain.AgentMetadata) error {
|
||||
if agentID == "a-prod-001" {
|
||||
return nil
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestHeartbeat_Success(t *testing.T) {
|
||||
// Test Heartbeat - service error
|
||||
func TestHeartbeat_ServiceError(t *testing.T) {
|
||||
mock := &MockAgentService{
|
||||
HeartbeatFn: func(agentID string) error {
|
||||
HeartbeatFn: func(agentID string, metadata *domain.AgentMetadata) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type AgentService interface {
|
||||
ListAgents(page, perPage int) ([]domain.Agent, int64, error)
|
||||
GetAgent(id string) (*domain.Agent, error)
|
||||
RegisterAgent(agent domain.Agent) (*domain.Agent, error)
|
||||
Heartbeat(agentID string) error
|
||||
Heartbeat(agentID string, metadata *domain.AgentMetadata) error
|
||||
CSRSubmit(agentID string, csrPEM string) (string, error)
|
||||
CSRSubmitForCert(agentID string, certID string, csrPEM string) (string, error)
|
||||
CertificatePickup(agentID, certID string) (string, error)
|
||||
@@ -159,7 +159,30 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
agentID := parts[0]
|
||||
|
||||
if err := h.svc.Heartbeat(agentID); err != nil {
|
||||
// Parse optional metadata from request body
|
||||
var metadata *domain.AgentMetadata
|
||||
if r.Body != nil {
|
||||
var body struct {
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err == nil {
|
||||
if body.Version != "" || body.Hostname != "" || body.OS != "" || body.Architecture != "" || body.IPAddress != "" {
|
||||
metadata = &domain.AgentMetadata{
|
||||
Version: body.Version,
|
||||
Hostname: body.Hostname,
|
||||
OS: body.OS,
|
||||
Architecture: body.Architecture,
|
||||
IPAddress: body.IPAddress,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.Heartbeat(agentID, metadata); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,17 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// CertificateService defines the service interface for certificate operations.
|
||||
type CertificateService interface {
|
||||
ListCertificates(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error)
|
||||
ListCertificatesWithFilter(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error)
|
||||
GetCertificate(id string) (*domain.ManagedCertificate, error)
|
||||
CreateCertificate(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
||||
UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
||||
@@ -20,6 +23,11 @@ type CertificateService interface {
|
||||
GetCertificateVersions(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
|
||||
TriggerRenewal(certID string) error
|
||||
TriggerDeployment(certID string, targetID string) error
|
||||
RevokeCertificate(certID string, reason string) error
|
||||
GetRevokedCertificates() ([]*domain.CertificateRevocation, error)
|
||||
GenerateDERCRL(issuerID string) ([]byte, error)
|
||||
GetOCSPResponse(issuerID string, serialHex string) ([]byte, error)
|
||||
GetCertificateDeployments(certID string) ([]domain.DeploymentTarget, error)
|
||||
}
|
||||
|
||||
// CertificateHandler handles HTTP requests for certificate operations.
|
||||
@@ -33,7 +41,7 @@ func NewCertificateHandler(svc CertificateService) CertificateHandler {
|
||||
}
|
||||
|
||||
// ListCertificates lists certificates with optional filtering.
|
||||
// GET /api/v1/certificates?status=Active&environment=prod&owner_id=...&team_id=...&issuer_id=...&page=1&per_page=50
|
||||
// GET /api/v1/certificates?status=Active&environment=prod&owner_id=...&team_id=...&issuer_id=...&agent_id=...&profile_id=...&expires_before=...&expires_after=...&created_after=...&updated_after=...&sort=notAfter&sort_desc=false&cursor=...&page=1&per_page=50&fields=id,commonName,status
|
||||
func (h CertificateHandler) ListCertificates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
@@ -44,12 +52,56 @@ func (h CertificateHandler) ListCertificates(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
// Parse query parameters
|
||||
query := r.URL.Query()
|
||||
status := query.Get("status")
|
||||
environment := query.Get("environment")
|
||||
ownerID := query.Get("owner_id")
|
||||
teamID := query.Get("team_id")
|
||||
issuerID := query.Get("issuer_id")
|
||||
|
||||
// Basic filters
|
||||
filter := &repository.CertificateFilter{
|
||||
Status: query.Get("status"),
|
||||
Environment: query.Get("environment"),
|
||||
OwnerID: query.Get("owner_id"),
|
||||
TeamID: query.Get("team_id"),
|
||||
IssuerID: query.Get("issuer_id"),
|
||||
AgentID: query.Get("agent_id"),
|
||||
ProfileID: query.Get("profile_id"),
|
||||
}
|
||||
|
||||
// Time-range filters
|
||||
if eb := query.Get("expires_before"); eb != "" {
|
||||
if t, err := time.Parse(time.RFC3339, eb); err == nil {
|
||||
filter.ExpiresBefore = &t
|
||||
}
|
||||
}
|
||||
if ea := query.Get("expires_after"); ea != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ea); err == nil {
|
||||
filter.ExpiresAfter = &t
|
||||
}
|
||||
}
|
||||
if ca := query.Get("created_after"); ca != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ca); err == nil {
|
||||
filter.CreatedAfter = &t
|
||||
}
|
||||
}
|
||||
if ua := query.Get("updated_after"); ua != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ua); err == nil {
|
||||
filter.UpdatedAfter = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if sort := query.Get("sort"); sort != "" {
|
||||
// Handle sort direction prefix
|
||||
if strings.HasPrefix(sort, "-") {
|
||||
filter.Sort = sort[1:]
|
||||
filter.SortDesc = true
|
||||
} else {
|
||||
filter.Sort = sort
|
||||
filter.SortDesc = query.Get("sort_desc") == "true"
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor-based pagination
|
||||
filter.Cursor = query.Get("cursor")
|
||||
|
||||
// Page-based pagination
|
||||
page := 1
|
||||
perPage := 50
|
||||
if p := query.Get("page"); p != "" {
|
||||
@@ -62,21 +114,59 @@ func (h CertificateHandler) ListCertificates(w http.ResponseWriter, r *http.Requ
|
||||
perPage = parsed
|
||||
}
|
||||
}
|
||||
if ps := query.Get("page_size"); ps != "" {
|
||||
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 500 {
|
||||
filter.PageSize = parsed
|
||||
}
|
||||
}
|
||||
filter.Page = page
|
||||
filter.PerPage = perPage
|
||||
|
||||
certs, total, err := h.svc.ListCertificates(status, environment, ownerID, teamID, issuerID, page, perPage)
|
||||
// Sparse fields
|
||||
if fieldsStr := query.Get("fields"); fieldsStr != "" {
|
||||
filter.Fields = strings.Split(fieldsStr, ",")
|
||||
}
|
||||
|
||||
certs, total, err := h.svc.ListCertificatesWithFilter(filter)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list certificates", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
response := PagedResponse{
|
||||
Data: certs,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
// Apply sparse field filtering if requested
|
||||
var responseData interface{} = certs
|
||||
if len(filter.Fields) > 0 {
|
||||
responseData = filterFields(certs, filter.Fields)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
// Return cursor-based or page-based response depending on which pagination is used
|
||||
if filter.Cursor != "" {
|
||||
// Compute next cursor from last result
|
||||
nextCursor := ""
|
||||
if len(certs) > 0 {
|
||||
lastCert := certs[len(certs)-1]
|
||||
nextCursor = encodeCursor(lastCert.CreatedAt, lastCert.ID)
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize == 0 {
|
||||
pageSize = filter.PerPage
|
||||
}
|
||||
response := CursorPagedResponse{
|
||||
Data: responseData,
|
||||
Total: int64(total),
|
||||
NextCursor: nextCursor,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
JSON(w, http.StatusOK, response)
|
||||
} else {
|
||||
response := PagedResponse{
|
||||
Data: responseData,
|
||||
Total: int64(total),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCertificate retrieves a single certificate by ID.
|
||||
@@ -350,3 +440,209 @@ func (h CertificateHandler) TriggerDeployment(w http.ResponseWriter, r *http.Req
|
||||
|
||||
JSON(w, http.StatusAccepted, response)
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate with an optional reason code.
|
||||
// POST /api/v1/certificates/{id}/revoke
|
||||
func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path /api/v1/certificates/{id}/revoke
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||
return
|
||||
}
|
||||
certID := parts[0]
|
||||
|
||||
// Parse optional reason from request body
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if r.Body != nil && r.Header.Get("Content-Type") == "application/json" {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.RevokeCertificate(certID, req.Reason); err != nil {
|
||||
// Distinguish between client errors and server errors
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "already revoked") ||
|
||||
strings.Contains(errMsg, "cannot revoke") ||
|
||||
strings.Contains(errMsg, "invalid revocation reason") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to revoke certificate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
// GetCRL returns the Certificate Revocation List as structured JSON.
|
||||
// GET /api/v1/crl
|
||||
// Note: DER-encoded X.509 CRL generation (requiring CA key access) is planned for M15b
|
||||
// alongside the embedded OCSP responder. This endpoint provides the same data in JSON format.
|
||||
func (h CertificateHandler) GetCRL(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
revocations, err := h.svc.GetRevokedCertificates()
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate CRL", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
type CRLEntry struct {
|
||||
SerialNumber string `json:"serial_number"`
|
||||
RevocationDate string `json:"revocation_date"`
|
||||
RevocationReason string `json:"revocation_reason"`
|
||||
}
|
||||
|
||||
entries := make([]CRLEntry, 0, len(revocations))
|
||||
for _, rev := range revocations {
|
||||
entries = append(entries, CRLEntry{
|
||||
SerialNumber: rev.SerialNumber,
|
||||
RevocationDate: rev.RevokedAt.Format("2006-01-02T15:04:05Z"),
|
||||
RevocationReason: rev.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"version": 1,
|
||||
"entries": entries,
|
||||
"total": len(entries),
|
||||
"generated_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
// GetDERCRL returns a DER-encoded X.509 CRL signed by the specified issuer.
|
||||
// GET /api/v1/crl/{issuer_id}
|
||||
func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) {
|
||||
requestID, _ := r.Context().Value("request_id").(string)
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
ErrorWithRequestID(w, http.StatusMethodNotAllowed, "Method not allowed", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
issuerID := strings.TrimPrefix(r.URL.Path, "/api/v1/crl/")
|
||||
if issuerID == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
derBytes, err := h.svc.GenerateDERCRL(issuerID)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
|
||||
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate CRL", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pkix-crl")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(derBytes)
|
||||
}
|
||||
|
||||
// HandleOCSP processes OCSP requests.
|
||||
// GET /api/v1/ocsp/{issuer_id}/{serial_hex}
|
||||
// For simplicity, use GET with path params instead of binary POST.
|
||||
func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
|
||||
requestID, _ := r.Context().Value("request_id").(string)
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
ErrorWithRequestID(w, http.StatusMethodNotAllowed, "Method not allowed", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract issuer_id and serial from path: /api/v1/ocsp/{issuer_id}/{serial_hex}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/ocsp/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID and serial number are required", requestID)
|
||||
return
|
||||
}
|
||||
issuerID := parts[0]
|
||||
serialHex := parts[1]
|
||||
|
||||
derBytes, err := h.svc.GetOCSPResponse(issuerID, serialHex)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
|
||||
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate OCSP response", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/ocsp-response")
|
||||
w.Header().Set("Cache-Control", "max-age=3600")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(derBytes)
|
||||
}
|
||||
|
||||
// GetCertificateDeployments retrieves all deployment targets for a certificate.
|
||||
// GET /api/v1/certificates/{id}/deployments
|
||||
func (h CertificateHandler) GetCertificateDeployments(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path /api/v1/certificates/{id}/deployments
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||
return
|
||||
}
|
||||
certID := parts[0]
|
||||
|
||||
deployments, err := h.svc.GetCertificateDeployments(certID)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get deployments", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"data": deployments,
|
||||
"total": len(deployments),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// DiscoveryService defines the interface used by the discovery handler.
|
||||
type DiscoveryService interface {
|
||||
ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error)
|
||||
ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error)
|
||||
GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
|
||||
ClaimDiscovered(ctx context.Context, id string, managedCertID string) error
|
||||
DismissDiscovered(ctx context.Context, id string) error
|
||||
ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
|
||||
GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error)
|
||||
GetDiscoverySummary(ctx context.Context) (map[string]int, error)
|
||||
}
|
||||
|
||||
// DiscoveryHandler handles HTTP requests for certificate discovery.
|
||||
type DiscoveryHandler struct {
|
||||
svc DiscoveryService
|
||||
}
|
||||
|
||||
// NewDiscoveryHandler creates a new discovery handler.
|
||||
func NewDiscoveryHandler(svc DiscoveryService) DiscoveryHandler {
|
||||
return DiscoveryHandler{svc: svc}
|
||||
}
|
||||
|
||||
// SubmitDiscoveryReport handles POST /api/v1/agents/{id}/discoveries
|
||||
// Agents submit their filesystem scan results here.
|
||||
func (h DiscoveryHandler) SubmitDiscoveryReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
agentID := r.PathValue("id")
|
||||
if agentID == "" {
|
||||
Error(w, http.StatusBadRequest, "agent ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
var report domain.DiscoveryReport
|
||||
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
|
||||
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Override agent ID from path (security: agents can only report for themselves)
|
||||
report.AgentID = agentID
|
||||
|
||||
scan, err := h.svc.ProcessDiscoveryReport(r.Context(), &report)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to process discovery report: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusAccepted, scan)
|
||||
}
|
||||
|
||||
// ListDiscovered handles GET /api/v1/discovered-certificates
|
||||
func (h DiscoveryHandler) ListDiscovered(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
agentID := query.Get("agent_id")
|
||||
status := query.Get("status")
|
||||
page := parseIntDefault(query.Get("page"), 1)
|
||||
perPage := parseIntDefault(query.Get("per_page"), 50)
|
||||
if perPage > 500 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
certs, total, err := h.svc.ListDiscovered(r.Context(), agentID, status, page, perPage)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list discovered certificates: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, PagedResponse{
|
||||
Data: certs,
|
||||
Total: int64(total),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDiscovered handles GET /api/v1/discovered-certificates/{id}
|
||||
func (h DiscoveryHandler) GetDiscovered(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "discovered certificate ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
cert, err := h.svc.GetDiscovered(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, fmt.Sprintf("discovered certificate not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, cert)
|
||||
}
|
||||
|
||||
// ClaimDiscovered handles POST /api/v1/discovered-certificates/{id}/claim
|
||||
func (h DiscoveryHandler) ClaimDiscovered(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "discovered certificate ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ManagedCertificateID string `json:"managed_certificate_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if body.ManagedCertificateID == "" {
|
||||
Error(w, http.StatusBadRequest, "managed_certificate_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.ClaimDiscovered(r.Context(), id, body.ManagedCertificateID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to claim certificate: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "claimed",
|
||||
"message": "Discovered certificate linked to managed certificate",
|
||||
})
|
||||
}
|
||||
|
||||
// DismissDiscovered handles POST /api/v1/discovered-certificates/{id}/dismiss
|
||||
func (h DiscoveryHandler) DismissDiscovered(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "discovered certificate ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DismissDiscovered(r.Context(), id); err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to dismiss certificate: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "dismissed",
|
||||
"message": "Discovered certificate dismissed",
|
||||
})
|
||||
}
|
||||
|
||||
// ListScans handles GET /api/v1/discovery-scans
|
||||
func (h DiscoveryHandler) ListScans(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
agentID := query.Get("agent_id")
|
||||
page := parseIntDefault(query.Get("page"), 1)
|
||||
perPage := parseIntDefault(query.Get("per_page"), 50)
|
||||
|
||||
scans, total, err := h.svc.ListScans(r.Context(), agentID, page, perPage)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list discovery scans: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, PagedResponse{
|
||||
Data: scans,
|
||||
Total: int64(total),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDiscoverySummary handles GET /api/v1/discovery-summary
|
||||
func (h DiscoveryHandler) GetDiscoverySummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.svc.GetDiscoverySummary(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to get discovery summary: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// parseIntDefault parses an integer from a string with a default fallback.
|
||||
func parseIntDefault(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
val, err := strconv.Atoi(s)
|
||||
if err != nil || val < 1 {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// MockDiscoveryService is a mock implementation of DiscoveryService interface.
|
||||
type MockDiscoveryService struct {
|
||||
ProcessDiscoveryReportFn func(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error)
|
||||
ListDiscoveredFn func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error)
|
||||
GetDiscoveredFn func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
|
||||
ClaimDiscoveredFn func(ctx context.Context, id string, managedCertID string) error
|
||||
DismissDiscoveredFn func(ctx context.Context, id string) error
|
||||
ListScansFn func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
|
||||
GetScanFn func(ctx context.Context, id string) (*domain.DiscoveryScan, error)
|
||||
GetDiscoverySummaryFn func(ctx context.Context) (map[string]int, error)
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
|
||||
if m.ProcessDiscoveryReportFn != nil {
|
||||
return m.ProcessDiscoveryReportFn(ctx, report)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
|
||||
if m.ListDiscoveredFn != nil {
|
||||
return m.ListDiscoveredFn(ctx, agentID, status, page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
||||
if m.GetDiscoveredFn != nil {
|
||||
return m.GetDiscoveredFn(ctx, id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error {
|
||||
if m.ClaimDiscoveredFn != nil {
|
||||
return m.ClaimDiscoveredFn(ctx, id, managedCertID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) DismissDiscovered(ctx context.Context, id string) error {
|
||||
if m.DismissDiscoveredFn != nil {
|
||||
return m.DismissDiscoveredFn(ctx, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
|
||||
if m.ListScansFn != nil {
|
||||
return m.ListScansFn(ctx, agentID, page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
|
||||
if m.GetScanFn != nil {
|
||||
return m.GetScanFn(ctx, id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockDiscoveryService) GetDiscoverySummary(ctx context.Context) (map[string]int, error) {
|
||||
if m.GetDiscoverySummaryFn != nil {
|
||||
return m.GetDiscoverySummaryFn(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Helper function to create context with request ID.
|
||||
func discoveryContextWithRequestID() context.Context {
|
||||
return context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-123")
|
||||
}
|
||||
|
||||
// Test SubmitDiscoveryReport - success case
|
||||
func TestSubmitDiscoveryReport_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
scan := &domain.DiscoveryScan{
|
||||
ID: "dscan-1",
|
||||
AgentID: "agent-1",
|
||||
CertificatesFound: 2,
|
||||
CertificatesNew: 1,
|
||||
ErrorsCount: 0,
|
||||
ScanDurationMs: 150,
|
||||
StartedAt: now,
|
||||
CompletedAt: &now,
|
||||
}
|
||||
|
||||
mock := &MockDiscoveryService{
|
||||
ProcessDiscoveryReportFn: func(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
|
||||
if report.AgentID == "agent-1" && len(report.Certificates) == 2 {
|
||||
return scan, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected report")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
reportBody := domain.DiscoveryReport{
|
||||
AgentID: "agent-1",
|
||||
Certificates: []domain.DiscoveredCertEntry{
|
||||
{
|
||||
FingerprintSHA256: "abc123",
|
||||
CommonName: "example.com",
|
||||
SerialNumber: "001",
|
||||
SourcePath: "/etc/certs/example.com.crt",
|
||||
},
|
||||
{
|
||||
FingerprintSHA256: "def456",
|
||||
CommonName: "api.example.com",
|
||||
SerialNumber: "002",
|
||||
SourcePath: "/etc/certs/api.example.com.crt",
|
||||
},
|
||||
},
|
||||
ScanDurationMs: 150,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reportBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/discoveries", bytes.NewReader(body))
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "agent-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.SubmitDiscoveryReport(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Errorf("expected status %d, got %d", http.StatusAccepted, w.Code)
|
||||
}
|
||||
|
||||
var response *domain.DiscoveryScan
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.ID != "dscan-1" {
|
||||
t.Errorf("expected scan ID dscan-1, got %s", response.ID)
|
||||
}
|
||||
if response.CertificatesFound != 2 {
|
||||
t.Errorf("expected 2 certificates found, got %d", response.CertificatesFound)
|
||||
}
|
||||
}
|
||||
|
||||
// Test SubmitDiscoveryReport - invalid body
|
||||
func TestSubmitDiscoveryReport_InvalidBody(t *testing.T) {
|
||||
mock := &MockDiscoveryService{}
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/discoveries", bytes.NewReader([]byte("invalid json")))
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "agent-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.SubmitDiscoveryReport(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test SubmitDiscoveryReport - method not allowed
|
||||
func TestSubmitDiscoveryReport_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockDiscoveryService{}
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/agent-1/discoveries", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "agent-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.SubmitDiscoveryReport(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test ListDiscovered - success case
|
||||
func TestListDiscovered_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
certs := []*domain.DiscoveredCertificate{
|
||||
{
|
||||
ID: "dcert-1",
|
||||
CommonName: "example.com",
|
||||
SerialNumber: "001",
|
||||
Status: domain.DiscoveryStatusUnmanaged,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "dcert-2",
|
||||
CommonName: "api.example.com",
|
||||
SerialNumber: "002",
|
||||
Status: domain.DiscoveryStatusManaged,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
mock := &MockDiscoveryService{
|
||||
ListDiscoveredFn: func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
|
||||
if page == 1 && perPage == 50 {
|
||||
return certs, 2, nil
|
||||
}
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates?page=1&per_page=50", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var response PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Total != 2 {
|
||||
t.Errorf("expected total 2, got %d", response.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// Test ListDiscovered - with filters
|
||||
func TestListDiscovered_WithFilters(t *testing.T) {
|
||||
mock := &MockDiscoveryService{
|
||||
ListDiscoveredFn: func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
|
||||
if agentID == "agent-1" && status == "Unmanaged" {
|
||||
return []*domain.DiscoveredCertificate{}, 0, nil
|
||||
}
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates?agent_id=agent-1&status=Unmanaged&page=1&per_page=25", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test ListDiscovered - method not allowed
|
||||
func TestListDiscovered_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockDiscoveryService{}
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetDiscovered - success case
|
||||
func TestGetDiscovered_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert := &domain.DiscoveredCertificate{
|
||||
ID: "dcert-1",
|
||||
CommonName: "example.com",
|
||||
SerialNumber: "001",
|
||||
Status: domain.DiscoveryStatusUnmanaged,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
mock := &MockDiscoveryService{
|
||||
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
||||
if id == "dcert-1" {
|
||||
return cert, nil
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/dcert-1", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "dcert-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var response *domain.DiscoveredCertificate
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.ID != "dcert-1" {
|
||||
t.Errorf("expected ID dcert-1, got %s", response.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetDiscovered - not found
|
||||
func TestGetDiscovered_NotFound(t *testing.T) {
|
||||
mock := &MockDiscoveryService{
|
||||
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/nonexistent", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "nonexistent")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test ClaimDiscovered - success case
|
||||
func TestClaimDiscovered_Success(t *testing.T) {
|
||||
mock := &MockDiscoveryService{
|
||||
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string) error {
|
||||
if id == "dcert-1" && managedCertID == "mc-prod-1" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unexpected parameters")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
claimBody := map[string]string{
|
||||
"managed_certificate_id": "mc-prod-1",
|
||||
}
|
||||
body, _ := json.Marshal(claimBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/claim", bytes.NewReader(body))
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "dcert-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ClaimDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var response map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response["status"] != "claimed" {
|
||||
t.Errorf("expected status 'claimed', got %s", response["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// Test ClaimDiscovered - missing managed_certificate_id
|
||||
func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
|
||||
mock := &MockDiscoveryService{}
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
claimBody := map[string]string{}
|
||||
body, _ := json.Marshal(claimBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/claim", bytes.NewReader(body))
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "dcert-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ClaimDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test ClaimDiscovered - discovered cert not found
|
||||
func TestClaimDiscovered_NotFound(t *testing.T) {
|
||||
mock := &MockDiscoveryService{
|
||||
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string) error {
|
||||
return fmt.Errorf("discovered certificate not found")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
claimBody := map[string]string{
|
||||
"managed_certificate_id": "mc-prod-1",
|
||||
}
|
||||
body, _ := json.Marshal(claimBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/nonexistent/claim", bytes.NewReader(body))
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "nonexistent")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ClaimDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test DismissDiscovered - success case
|
||||
func TestDismissDiscovered_Success(t *testing.T) {
|
||||
mock := &MockDiscoveryService{
|
||||
DismissDiscoveredFn: func(ctx context.Context, id string) error {
|
||||
if id == "dcert-1" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/dismiss", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "dcert-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DismissDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var response map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response["status"] != "dismissed" {
|
||||
t.Errorf("expected status 'dismissed', got %s", response["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// Test DismissDiscovered - method not allowed
|
||||
func TestDismissDiscovered_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockDiscoveryService{}
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/dcert-1/dismiss", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
req.SetPathValue("id", "dcert-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DismissDiscovered(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test ListScans - success case
|
||||
func TestListScans_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
scans := []*domain.DiscoveryScan{
|
||||
{
|
||||
ID: "dscan-1",
|
||||
AgentID: "agent-1",
|
||||
CertificatesFound: 5,
|
||||
CertificatesNew: 2,
|
||||
ScanDurationMs: 200,
|
||||
StartedAt: now,
|
||||
CompletedAt: &now,
|
||||
},
|
||||
}
|
||||
|
||||
mock := &MockDiscoveryService{
|
||||
ListScansFn: func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
|
||||
if page == 1 && perPage == 50 {
|
||||
return scans, 1, nil
|
||||
}
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-scans?page=1&per_page=50", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListScans(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var response PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Total != 1 {
|
||||
t.Errorf("expected total 1, got %d", response.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// Test ListScans - with agent filter
|
||||
func TestListScans_WithAgentFilter(t *testing.T) {
|
||||
mock := &MockDiscoveryService{
|
||||
ListScansFn: func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
|
||||
if agentID == "agent-1" {
|
||||
return []*domain.DiscoveryScan{}, 0, nil
|
||||
}
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-scans?agent_id=agent-1", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListScans(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetDiscoverySummary - success case
|
||||
func TestGetDiscoverySummary_Success(t *testing.T) {
|
||||
summary := map[string]int{
|
||||
"Unmanaged": 5,
|
||||
"Managed": 3,
|
||||
"Dismissed": 1,
|
||||
}
|
||||
|
||||
mock := &MockDiscoveryService{
|
||||
GetDiscoverySummaryFn: func(ctx context.Context) (map[string]int, error) {
|
||||
return summary, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-summary", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetDiscoverySummary(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var response map[string]int
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response["Unmanaged"] != 5 {
|
||||
t.Errorf("expected Unmanaged count 5, got %d", response["Unmanaged"])
|
||||
}
|
||||
if response["Managed"] != 3 {
|
||||
t.Errorf("expected Managed count 3, got %d", response["Managed"])
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetDiscoverySummary - method not allowed
|
||||
func TestGetDiscoverySummary_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockDiscoveryService{}
|
||||
handler := NewDiscoveryHandler(mock)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovery-summary", nil)
|
||||
req = req.WithContext(discoveryContextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetDiscoverySummary(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,9 +14,11 @@ import (
|
||||
|
||||
// MockJobService is a mock implementation of JobService interface.
|
||||
type MockJobService struct {
|
||||
ListJobsFn func(status, jobType string, page, perPage int) ([]domain.Job, int64, error)
|
||||
GetJobFn func(id string) (*domain.Job, error)
|
||||
CancelJobFn func(id string) error
|
||||
ListJobsFn func(status, jobType string, page, perPage int) ([]domain.Job, int64, error)
|
||||
GetJobFn func(id string) (*domain.Job, error)
|
||||
CancelJobFn func(id string) error
|
||||
ApproveJobFn func(id string) error
|
||||
RejectJobFn func(id string, reason string) error
|
||||
}
|
||||
|
||||
func (m *MockJobService) ListJobs(status, jobType string, page, perPage int) ([]domain.Job, int64, error) {
|
||||
@@ -38,6 +42,20 @@ func (m *MockJobService) CancelJob(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockJobService) ApproveJob(id string) error {
|
||||
if m.ApproveJobFn != nil {
|
||||
return m.ApproveJobFn(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockJobService) RejectJob(id string, reason string) error {
|
||||
if m.RejectJobFn != nil {
|
||||
return m.RejectJobFn(id, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestListJobs_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
job1 := domain.Job{
|
||||
@@ -325,3 +343,164 @@ func TestCancelJob_EmptyID(t *testing.T) {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveJob_Success(t *testing.T) {
|
||||
var approvedID string
|
||||
mock := &MockJobService{
|
||||
ApproveJobFn: func(id string) error {
|
||||
approvedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewJobHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-001/approve", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ApproveJob(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
if approvedID != "job-001" {
|
||||
t.Errorf("expected approved ID 'job-001', got '%s'", approvedID)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["status"] != "job_approved" {
|
||||
t.Errorf("expected status 'job_approved', got '%s'", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveJob_NotFound(t *testing.T) {
|
||||
mock := &MockJobService{
|
||||
ApproveJobFn: func(id string) error {
|
||||
return fmt.Errorf("job not found: no rows")
|
||||
},
|
||||
}
|
||||
|
||||
h := NewJobHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-ghost/approve", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ApproveJob(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveJob_BadStatus(t *testing.T) {
|
||||
mock := &MockJobService{
|
||||
ApproveJobFn: func(id string) error {
|
||||
return fmt.Errorf("cannot approve job with status Running")
|
||||
},
|
||||
}
|
||||
|
||||
h := NewJobHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-001/approve", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ApproveJob(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveJob_MethodNotAllowed(t *testing.T) {
|
||||
h := NewJobHandler(&MockJobService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-001/approve", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ApproveJob(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectJob_Success(t *testing.T) {
|
||||
var rejectedID, capturedReason string
|
||||
mock := &MockJobService{
|
||||
RejectJobFn: func(id string, reason string) error {
|
||||
rejectedID = id
|
||||
capturedReason = reason
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
body := `{"reason":"Certificate no longer needed"}`
|
||||
h := NewJobHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-002/reject", strings.NewReader(body))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.RejectJob(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
if rejectedID != "job-002" {
|
||||
t.Errorf("expected rejected ID 'job-002', got '%s'", rejectedID)
|
||||
}
|
||||
if capturedReason != "Certificate no longer needed" {
|
||||
t.Errorf("expected reason 'Certificate no longer needed', got '%s'", capturedReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectJob_NoReason(t *testing.T) {
|
||||
mock := &MockJobService{
|
||||
RejectJobFn: func(id string, reason string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewJobHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-002/reject", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.RejectJob(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectJob_NotFound(t *testing.T) {
|
||||
mock := &MockJobService{
|
||||
RejectJobFn: func(id string, reason string) error {
|
||||
return fmt.Errorf("job not found: no rows")
|
||||
},
|
||||
}
|
||||
|
||||
h := NewJobHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-ghost/reject", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.RejectJob(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectJob_MethodNotAllowed(t *testing.T) {
|
||||
h := NewJobHandler(&MockJobService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-001/reject", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.RejectJob(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,6 +16,8 @@ type JobService interface {
|
||||
ListJobs(status, jobType string, page, perPage int) ([]domain.Job, int64, error)
|
||||
GetJob(id string) (*domain.Job, error)
|
||||
CancelJob(id string) error
|
||||
ApproveJob(id string) error
|
||||
RejectJob(id string, reason string) error
|
||||
}
|
||||
|
||||
// JobHandler handles HTTP requests for job operations.
|
||||
@@ -126,3 +130,81 @@ func (h JobHandler) CancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ApproveJob approves a renewal job awaiting approval.
|
||||
// POST /api/v1/jobs/{id}/approve
|
||||
func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID)
|
||||
return
|
||||
}
|
||||
jobID := parts[0]
|
||||
|
||||
if err := h.svc.ApproveJob(jobID); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot approve") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to approve job", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "job_approved"})
|
||||
}
|
||||
|
||||
// RejectJob rejects a renewal job awaiting approval.
|
||||
// POST /api/v1/jobs/{id}/reject
|
||||
func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID)
|
||||
return
|
||||
}
|
||||
jobID := parts[0]
|
||||
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.RejectJob(jobID, body.Reason); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot reject") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to reject job", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "job_rejected"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
)
|
||||
|
||||
// MetricsService defines the service interface for metrics collection.
|
||||
type MetricsService interface {
|
||||
GetDashboardSummary(ctx context.Context) (interface{}, error)
|
||||
}
|
||||
|
||||
// MetricsHandler handles HTTP requests for metrics.
|
||||
// Supports both JSON format (GET /api/v1/metrics) and Prometheus exposition format
|
||||
// (GET /api/v1/metrics/prometheus) for integration with Prometheus, Grafana, Datadog, etc.
|
||||
type MetricsHandler struct {
|
||||
svc MetricsService
|
||||
serverStarted time.Time
|
||||
}
|
||||
|
||||
// NewMetricsHandler creates a new MetricsHandler with a service dependency.
|
||||
// serverStarted is used to calculate uptime_seconds.
|
||||
func NewMetricsHandler(svc MetricsService, serverStarted time.Time) MetricsHandler {
|
||||
return MetricsHandler{
|
||||
svc: svc,
|
||||
serverStarted: serverStarted,
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsResponse represents the JSON metrics response for V2.
|
||||
type MetricsResponse struct {
|
||||
Gauge MetricsGauge `json:"gauge"`
|
||||
Counter MetricsCounter `json:"counter"`
|
||||
Uptime UptimeMetric `json:"uptime"`
|
||||
}
|
||||
|
||||
// MetricsGauge represents gauge metrics (point-in-time values).
|
||||
type MetricsGauge struct {
|
||||
CertificateTotal int64 `json:"certificate_total"`
|
||||
CertificateActive int64 `json:"certificate_active"`
|
||||
CertificateExpiringSoon int64 `json:"certificate_expiring_soon"` // Within 30d
|
||||
CertificateExpired int64 `json:"certificate_expired"`
|
||||
CertificateRevoked int64 `json:"certificate_revoked"`
|
||||
AgentTotal int64 `json:"agent_total"`
|
||||
AgentOnline int64 `json:"agent_online"`
|
||||
JobPending int64 `json:"job_pending"`
|
||||
}
|
||||
|
||||
// MetricsCounter represents counter metrics (cumulative values).
|
||||
type MetricsCounter struct {
|
||||
JobCompletedTotal int64 `json:"job_completed_total"`
|
||||
JobFailedTotal int64 `json:"job_failed_total"`
|
||||
}
|
||||
|
||||
// UptimeMetric represents server uptime information.
|
||||
type UptimeMetric struct {
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
ServerStarted time.Time `json:"server_started"`
|
||||
MeasuredAt time.Time `json:"measured_at"`
|
||||
}
|
||||
|
||||
// GetMetrics returns JSON metrics (aggregated from dashboard summary).
|
||||
// GET /api/v1/metrics
|
||||
func (h MetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
summary, err := h.svc.GetDashboardSummary(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to collect metrics", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract fields from summary via JSON round-trip (avoids cross-package type assertion)
|
||||
jsonBytes, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to marshal metrics data", requestID)
|
||||
return
|
||||
}
|
||||
var dashboardSummary DashboardSummary
|
||||
if err := json.Unmarshal(jsonBytes, &dashboardSummary); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Invalid metrics data", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Build metrics response
|
||||
metricsResp := MetricsResponse{
|
||||
Gauge: MetricsGauge{
|
||||
CertificateTotal: dashboardSummary.TotalCertificates,
|
||||
CertificateActive: dashboardSummary.TotalCertificates - dashboardSummary.ExpiringCertificates - dashboardSummary.ExpiredCertificates - dashboardSummary.RevokedCertificates,
|
||||
CertificateExpiringSoon: dashboardSummary.ExpiringCertificates,
|
||||
CertificateExpired: dashboardSummary.ExpiredCertificates,
|
||||
CertificateRevoked: dashboardSummary.RevokedCertificates,
|
||||
AgentTotal: dashboardSummary.TotalAgents,
|
||||
AgentOnline: dashboardSummary.ActiveAgents,
|
||||
JobPending: dashboardSummary.PendingJobs,
|
||||
},
|
||||
Counter: MetricsCounter{
|
||||
JobCompletedTotal: dashboardSummary.CompleteJobs,
|
||||
JobFailedTotal: dashboardSummary.FailedJobs,
|
||||
},
|
||||
Uptime: UptimeMetric{
|
||||
UptimeSeconds: int64(time.Since(h.serverStarted).Seconds()),
|
||||
ServerStarted: h.serverStarted,
|
||||
MeasuredAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, metricsResp)
|
||||
}
|
||||
|
||||
// GetPrometheusMetrics returns metrics in Prometheus exposition format (text/plain).
|
||||
// GET /api/v1/metrics/prometheus
|
||||
// Compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics, and any
|
||||
// OpenMetrics-compatible scraper. Metric names follow Prometheus naming conventions
|
||||
// (lowercase, snake_case, prefixed with certctl_).
|
||||
func (h MetricsHandler) GetPrometheusMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
summary, err := h.svc.GetDashboardSummary(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to collect metrics", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract fields from summary via JSON round-trip (avoids cross-package type assertion)
|
||||
jsonBytes, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to marshal metrics data", requestID)
|
||||
return
|
||||
}
|
||||
var dashboardSummary DashboardSummary
|
||||
if err := json.Unmarshal(jsonBytes, &dashboardSummary); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Invalid metrics data", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute derived values
|
||||
active := dashboardSummary.TotalCertificates - dashboardSummary.ExpiringCertificates - dashboardSummary.ExpiredCertificates - dashboardSummary.RevokedCertificates
|
||||
uptimeSeconds := int64(time.Since(h.serverStarted).Seconds())
|
||||
|
||||
// Build Prometheus exposition format
|
||||
// See: https://prometheus.io/docs/instrumenting/exposition_formats/
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Gauges — point-in-time values
|
||||
fmt.Fprintf(w, "# HELP certctl_certificate_total Total number of managed certificates.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_certificate_total gauge\n")
|
||||
fmt.Fprintf(w, "certctl_certificate_total %d\n\n", dashboardSummary.TotalCertificates)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_certificate_active Number of active (non-expiring, non-expired, non-revoked) certificates.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_certificate_active gauge\n")
|
||||
fmt.Fprintf(w, "certctl_certificate_active %d\n\n", active)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_certificate_expiring_soon Number of certificates expiring within 30 days.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_certificate_expiring_soon gauge\n")
|
||||
fmt.Fprintf(w, "certctl_certificate_expiring_soon %d\n\n", dashboardSummary.ExpiringCertificates)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_certificate_expired Number of expired certificates.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_certificate_expired gauge\n")
|
||||
fmt.Fprintf(w, "certctl_certificate_expired %d\n\n", dashboardSummary.ExpiredCertificates)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_certificate_revoked Number of revoked certificates.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_certificate_revoked gauge\n")
|
||||
fmt.Fprintf(w, "certctl_certificate_revoked %d\n\n", dashboardSummary.RevokedCertificates)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_agent_total Total number of registered agents.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_agent_total gauge\n")
|
||||
fmt.Fprintf(w, "certctl_agent_total %d\n\n", dashboardSummary.TotalAgents)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_agent_online Number of agents currently online.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_agent_online gauge\n")
|
||||
fmt.Fprintf(w, "certctl_agent_online %d\n\n", dashboardSummary.ActiveAgents)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_job_pending Number of jobs currently pending.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_job_pending gauge\n")
|
||||
fmt.Fprintf(w, "certctl_job_pending %d\n\n", dashboardSummary.PendingJobs)
|
||||
|
||||
// Counters — cumulative values
|
||||
fmt.Fprintf(w, "# HELP certctl_job_completed_total Total number of completed jobs.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_job_completed_total counter\n")
|
||||
fmt.Fprintf(w, "certctl_job_completed_total %d\n\n", dashboardSummary.CompleteJobs)
|
||||
|
||||
fmt.Fprintf(w, "# HELP certctl_job_failed_total Total number of failed jobs.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_job_failed_total counter\n")
|
||||
fmt.Fprintf(w, "certctl_job_failed_total %d\n\n", dashboardSummary.FailedJobs)
|
||||
|
||||
// Info — server uptime
|
||||
fmt.Fprintf(w, "# HELP certctl_uptime_seconds Server uptime in seconds.\n")
|
||||
fmt.Fprintf(w, "# TYPE certctl_uptime_seconds gauge\n")
|
||||
fmt.Fprintf(w, "certctl_uptime_seconds %d\n", uptimeSeconds)
|
||||
}
|
||||
|
||||
// DashboardSummary mirrors the service.DashboardSummary for JSON unmarshaling.
|
||||
// JSON tags must match the service-layer struct exactly.
|
||||
type DashboardSummary struct {
|
||||
TotalCertificates int64 `json:"total_certificates"`
|
||||
ExpiringCertificates int64 `json:"expiring_certificates"`
|
||||
ExpiredCertificates int64 `json:"expired_certificates"`
|
||||
RevokedCertificates int64 `json:"revoked_certificates"`
|
||||
ActiveAgents int64 `json:"active_agents"`
|
||||
OfflineAgents int64 `json:"offline_agents"`
|
||||
TotalAgents int64 `json:"total_agents"`
|
||||
PendingJobs int64 `json:"pending_jobs"`
|
||||
FailedJobs int64 `json:"failed_jobs"`
|
||||
CompleteJobs int64 `json:"complete_jobs"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// NetworkScanService defines the interface used by the network scan handler.
|
||||
type NetworkScanService interface {
|
||||
ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error)
|
||||
GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error)
|
||||
CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
|
||||
UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
|
||||
DeleteTarget(ctx context.Context, id string) error
|
||||
TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error)
|
||||
}
|
||||
|
||||
// NetworkScanHandler handles HTTP requests for network scan targets.
|
||||
type NetworkScanHandler struct {
|
||||
svc NetworkScanService
|
||||
}
|
||||
|
||||
// NewNetworkScanHandler creates a new network scan handler.
|
||||
func NewNetworkScanHandler(svc NetworkScanService) NetworkScanHandler {
|
||||
return NetworkScanHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ListNetworkScanTargets handles GET /api/v1/network-scan-targets
|
||||
func (h NetworkScanHandler) ListNetworkScanTargets(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := h.svc.ListTargets(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list network scan targets: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if targets == nil {
|
||||
targets = []*domain.NetworkScanTarget{}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, PagedResponse{
|
||||
Data: targets,
|
||||
Total: int64(len(targets)),
|
||||
Page: 1,
|
||||
PerPage: len(targets),
|
||||
})
|
||||
}
|
||||
|
||||
// GetNetworkScanTarget handles GET /api/v1/network-scan-targets/{id}
|
||||
func (h NetworkScanHandler) GetNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := h.svc.GetTarget(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, fmt.Sprintf("network scan target not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, target)
|
||||
}
|
||||
|
||||
// CreateNetworkScanTarget handles POST /api/v1/network-scan-targets
|
||||
func (h NetworkScanHandler) CreateNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var target domain.NetworkScanTarget
|
||||
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
|
||||
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateTarget(r.Context(), &target)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, fmt.Sprintf("failed to create network scan target: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// UpdateNetworkScanTarget handles PUT /api/v1/network-scan-targets/{id}
|
||||
func (h NetworkScanHandler) UpdateNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
var target domain.NetworkScanTarget
|
||||
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
|
||||
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateTarget(r.Context(), id, &target)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to update network scan target: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteNetworkScanTarget handles DELETE /api/v1/network-scan-targets/{id}
|
||||
func (h NetworkScanHandler) DeleteNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteTarget(r.Context(), id); err != nil {
|
||||
Error(w, http.StatusNotFound, fmt.Sprintf("failed to delete network scan target: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// TriggerNetworkScan handles POST /api/v1/network-scan-targets/{id}/scan
|
||||
func (h NetworkScanHandler) TriggerNetworkScan(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
scan, err := h.svc.TriggerScan(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to trigger scan: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// scan may be nil if no certs found
|
||||
if scan == nil {
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "completed",
|
||||
"message": "Scan completed, no certificates found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusAccepted, scan)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// mockNetworkScanService implements NetworkScanService for testing.
|
||||
type mockNetworkScanService struct {
|
||||
targets []*domain.NetworkScanTarget
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
||||
return m.targets, nil
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error) {
|
||||
for _, t := range m.targets {
|
||||
if t.ID == id {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found: %s", id)
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||
if target.Name == "" {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
target.ID = "nst-test-123"
|
||||
m.targets = append(m.targets, target)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||
for _, t := range m.targets {
|
||||
if t.ID == id {
|
||||
if target.Name != "" {
|
||||
t.Name = target.Name
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found: %s", id)
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
|
||||
for i, t := range m.targets {
|
||||
if t.ID == id {
|
||||
m.targets = append(m.targets[:i], m.targets[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("not found: %s", id)
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
||||
for _, t := range m.targets {
|
||||
if t.ID == targetID {
|
||||
return &domain.DiscoveryScan{
|
||||
ID: "dscan-test",
|
||||
AgentID: "server-scanner",
|
||||
CertificatesFound: 3,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found: %s", targetID)
|
||||
}
|
||||
|
||||
func TestListNetworkScanTargets(t *testing.T) {
|
||||
svc := &mockNetworkScanService{
|
||||
targets: []*domain.NetworkScanTarget{
|
||||
{ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int{443}},
|
||||
{ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int{443, 8443}},
|
||||
},
|
||||
}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/network-scan-targets", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ListNetworkScanTargets(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp PagedResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Total != 2 {
|
||||
t.Errorf("expected total 2, got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNetworkScanTargets_Empty(t *testing.T) {
|
||||
svc := &mockNetworkScanService{}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/network-scan-targets", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ListNetworkScanTargets(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNetworkScanTarget(t *testing.T) {
|
||||
svc := &mockNetworkScanService{}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Production",
|
||||
"cidrs": []string{"10.0.0.0/24"},
|
||||
"ports": []int{443},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.CreateNetworkScanTarget(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNetworkScanTarget_InvalidJSON(t *testing.T) {
|
||||
svc := &mockNetworkScanService{}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader([]byte("not json")))
|
||||
w := httptest.NewRecorder()
|
||||
h.CreateNetworkScanTarget(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNetworkScanTarget_MissingName(t *testing.T) {
|
||||
svc := &mockNetworkScanService{}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"cidrs": []string{"10.0.0.0/24"},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.CreateNetworkScanTarget(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNetworkScanTarget_NotFound(t *testing.T) {
|
||||
svc := &mockNetworkScanService{}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/network-scan-targets/nst-nonexistent", nil)
|
||||
req.SetPathValue("id", "nst-nonexistent")
|
||||
w := httptest.NewRecorder()
|
||||
h.DeleteNetworkScanTarget(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerNetworkScan(t *testing.T) {
|
||||
svc := &mockNetworkScanService{
|
||||
targets: []*domain.NetworkScanTarget{
|
||||
{ID: "nst-1", Name: "target1"},
|
||||
},
|
||||
}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets/nst-1/scan", nil)
|
||||
req.SetPathValue("id", "nst-1")
|
||||
w := httptest.NewRecorder()
|
||||
h.TriggerNetworkScan(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Errorf("expected 202, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerNetworkScan_NotFound(t *testing.T) {
|
||||
svc := &mockNetworkScanService{}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets/nst-nonexistent/scan", nil)
|
||||
req.SetPathValue("id", "nst-nonexistent")
|
||||
w := httptest.NewRecorder()
|
||||
h.TriggerNetworkScan(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNetworkScanTargets_MethodNotAllowed(t *testing.T) {
|
||||
svc := &mockNetworkScanService{}
|
||||
h := NewNetworkScanHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ListNetworkScanTargets(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// MockOwnerService is a mock implementation of OwnerService interface.
|
||||
type MockOwnerService struct {
|
||||
ListOwnersFn func(page, perPage int) ([]domain.Owner, int64, error)
|
||||
GetOwnerFn func(id string) (*domain.Owner, error)
|
||||
CreateOwnerFn func(owner domain.Owner) (*domain.Owner, error)
|
||||
UpdateOwnerFn func(id string, owner domain.Owner) (*domain.Owner, error)
|
||||
DeleteOwnerFn func(id string) error
|
||||
}
|
||||
|
||||
func (m *MockOwnerService) ListOwners(page, perPage int) ([]domain.Owner, int64, error) {
|
||||
if m.ListOwnersFn != nil {
|
||||
return m.ListOwnersFn(page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockOwnerService) GetOwner(id string) (*domain.Owner, error) {
|
||||
if m.GetOwnerFn != nil {
|
||||
return m.GetOwnerFn(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockOwnerService) CreateOwner(owner domain.Owner) (*domain.Owner, error) {
|
||||
if m.CreateOwnerFn != nil {
|
||||
return m.CreateOwnerFn(owner)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockOwnerService) UpdateOwner(id string, owner domain.Owner) (*domain.Owner, error) {
|
||||
if m.UpdateOwnerFn != nil {
|
||||
return m.UpdateOwnerFn(id, owner)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockOwnerService) DeleteOwner(id string) error {
|
||||
if m.DeleteOwnerFn != nil {
|
||||
return m.DeleteOwnerFn(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestListOwners_Success lists owners with pagination, verify data fields.
|
||||
func TestListOwners_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
o1 := domain.Owner{
|
||||
ID: "o-alice",
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
TeamID: "t-platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
o2 := domain.Owner{
|
||||
ID: "o-bob",
|
||||
Name: "Bob",
|
||||
Email: "bob@example.com",
|
||||
TeamID: "t-ops",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
mock := &MockOwnerService{
|
||||
ListOwnersFn: func(page, perPage int) ([]domain.Owner, int64, error) {
|
||||
return []domain.Owner{o1, o2}, 2, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListOwners(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Total != 2 {
|
||||
t.Errorf("expected total 2, got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOwners_Pagination verifies pagination parameters are passed to service.
|
||||
func TestListOwners_Pagination(t *testing.T) {
|
||||
var capturedPage, capturedPerPage int
|
||||
mock := &MockOwnerService{
|
||||
ListOwnersFn: func(page, perPage int) ([]domain.Owner, int64, error) {
|
||||
capturedPage = page
|
||||
capturedPerPage = perPage
|
||||
return []domain.Owner{}, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners?page=3&per_page=25", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListOwners(w, req)
|
||||
|
||||
if capturedPage != 3 {
|
||||
t.Errorf("expected page 3, got %d", capturedPage)
|
||||
}
|
||||
if capturedPerPage != 25 {
|
||||
t.Errorf("expected per_page 25, got %d", capturedPerPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOwners_ServiceError returns 500 on service error.
|
||||
func TestListOwners_ServiceError(t *testing.T) {
|
||||
mock := &MockOwnerService{
|
||||
ListOwnersFn: func(page, perPage int) ([]domain.Owner, int64, error) {
|
||||
return nil, 0, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListOwners(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOwners_MethodNotAllowed returns 405 for non-GET methods.
|
||||
func TestListOwners_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListOwners(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOwner_Success returns owner with email and team_id.
|
||||
func TestGetOwner_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockOwnerService{
|
||||
GetOwnerFn: func(id string) (*domain.Owner, error) {
|
||||
return &domain.Owner{
|
||||
ID: id,
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
TeamID: "t-platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/o-alice", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var owner domain.Owner
|
||||
if err := json.NewDecoder(w.Body).Decode(&owner); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if owner.Email != "alice@example.com" {
|
||||
t.Errorf("expected email 'alice@example.com', got '%s'", owner.Email)
|
||||
}
|
||||
if owner.TeamID != "t-platform" {
|
||||
t.Errorf("expected team_id 't-platform', got '%s'", owner.TeamID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOwner_NotFound returns 404 when owner not found.
|
||||
func TestGetOwner_NotFound(t *testing.T) {
|
||||
mock := &MockOwnerService{
|
||||
GetOwnerFn: func(id string) (*domain.Owner, error) {
|
||||
return nil, ErrMockNotFound
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/nonexistent", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOwner_EmptyID returns 400 for empty ID.
|
||||
func TestGetOwner_EmptyID(t *testing.T) {
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOwner_MethodNotAllowed returns 405 for non-GET methods.
|
||||
func TestGetOwner_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/owners/o-alice", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOwner_Success returns 201 with email and team_id.
|
||||
func TestCreateOwner_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockOwnerService{
|
||||
CreateOwnerFn: func(owner domain.Owner) (*domain.Owner, error) {
|
||||
owner.ID = "o-new"
|
||||
owner.CreatedAt = now
|
||||
owner.UpdatedAt = now
|
||||
return &owner, nil
|
||||
},
|
||||
}
|
||||
|
||||
body := domain.Owner{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
TeamID: "t-platform",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
var owner domain.Owner
|
||||
if err := json.NewDecoder(w.Body).Decode(&owner); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if owner.Email != "alice@example.com" {
|
||||
t.Errorf("expected email 'alice@example.com', got '%s'", owner.Email)
|
||||
}
|
||||
if owner.TeamID != "t-platform" {
|
||||
t.Errorf("expected team_id 't-platform', got '%s'", owner.TeamID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOwner_InvalidJSON returns 400 for malformed JSON.
|
||||
func TestCreateOwner_InvalidJSON(t *testing.T) {
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader([]byte("not json")))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOwner_MissingName returns 400 when name is required.
|
||||
func TestCreateOwner_MissingName(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"email": "alice@example.com",
|
||||
"team_id": "t-platform",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOwner_NameTooLong returns 400 for name exceeding 255 chars.
|
||||
func TestCreateOwner_NameTooLong(t *testing.T) {
|
||||
longName := ""
|
||||
for i := 0; i < 256; i++ {
|
||||
longName += "x"
|
||||
}
|
||||
body := domain.Owner{
|
||||
Name: longName,
|
||||
Email: "alice@example.com",
|
||||
TeamID: "t-platform",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOwner_ServiceError returns 500 on service error.
|
||||
func TestCreateOwner_ServiceError(t *testing.T) {
|
||||
mock := &MockOwnerService{
|
||||
CreateOwnerFn: func(owner domain.Owner) (*domain.Owner, error) {
|
||||
return nil, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
body := domain.Owner{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
TeamID: "t-platform",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOwner_MethodNotAllowed returns 405 for non-POST methods.
|
||||
func TestCreateOwner_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateOwner_Success returns 200 with updated data.
|
||||
func TestUpdateOwner_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockOwnerService{
|
||||
UpdateOwnerFn: func(id string, owner domain.Owner) (*domain.Owner, error) {
|
||||
return &domain.Owner{
|
||||
ID: id,
|
||||
Name: owner.Name,
|
||||
Email: owner.Email,
|
||||
TeamID: owner.TeamID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
body := domain.Owner{
|
||||
Name: "Alice Updated",
|
||||
Email: "alice.updated@example.com",
|
||||
TeamID: "t-platform",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/owners/o-alice", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var owner domain.Owner
|
||||
if err := json.NewDecoder(w.Body).Decode(&owner); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if owner.Name != "Alice Updated" {
|
||||
t.Errorf("expected name 'Alice Updated', got '%s'", owner.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateOwner_ServiceError returns 500 on service error.
|
||||
func TestUpdateOwner_ServiceError(t *testing.T) {
|
||||
mock := &MockOwnerService{
|
||||
UpdateOwnerFn: func(id string, owner domain.Owner) (*domain.Owner, error) {
|
||||
return nil, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
body := domain.Owner{
|
||||
Name: "Alice Updated",
|
||||
Email: "alice.updated@example.com",
|
||||
TeamID: "t-platform",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/owners/o-alice", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateOwner_EmptyID returns 400 for empty ID.
|
||||
func TestUpdateOwner_EmptyID(t *testing.T) {
|
||||
body := domain.Owner{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
TeamID: "t-platform",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/owners/", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOwner_Success returns 204 No Content.
|
||||
func TestDeleteOwner_Success(t *testing.T) {
|
||||
var deletedID string
|
||||
mock := &MockOwnerService{
|
||||
DeleteOwnerFn: func(id string) error {
|
||||
deletedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/owners/o-alice", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status 204, got %d", w.Code)
|
||||
}
|
||||
if deletedID != "o-alice" {
|
||||
t.Errorf("expected deleted ID 'o-alice', got '%s'", deletedID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOwner_ServiceError returns 500 on service error.
|
||||
func TestDeleteOwner_ServiceError(t *testing.T) {
|
||||
mock := &MockOwnerService{
|
||||
DeleteOwnerFn: func(id string) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewOwnerHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/owners/o-alice", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOwner_EmptyID returns 400 for empty ID.
|
||||
func TestDeleteOwner_EmptyID(t *testing.T) {
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/owners/", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOwner_MethodNotAllowed returns 405 for non-DELETE methods.
|
||||
func TestDeleteOwner_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewOwnerHandler(&MockOwnerService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/o-alice", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteOwner(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// MockProfileService is a mock implementation of ProfileService interface.
|
||||
type MockProfileService struct {
|
||||
ListProfilesFn func(page, perPage int) ([]domain.CertificateProfile, int64, error)
|
||||
GetProfileFn func(id string) (*domain.CertificateProfile, error)
|
||||
CreateProfileFn func(profile domain.CertificateProfile) (*domain.CertificateProfile, error)
|
||||
UpdateProfileFn func(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error)
|
||||
DeleteProfileFn func(id string) error
|
||||
}
|
||||
|
||||
func (m *MockProfileService) ListProfiles(page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
||||
if m.ListProfilesFn != nil {
|
||||
return m.ListProfilesFn(page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockProfileService) GetProfile(id string) (*domain.CertificateProfile, error) {
|
||||
if m.GetProfileFn != nil {
|
||||
return m.GetProfileFn(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockProfileService) CreateProfile(profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
||||
if m.CreateProfileFn != nil {
|
||||
return m.CreateProfileFn(profile)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockProfileService) UpdateProfile(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
||||
if m.UpdateProfileFn != nil {
|
||||
return m.UpdateProfileFn(id, profile)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockProfileService) DeleteProfile(id string) error {
|
||||
if m.DeleteProfileFn != nil {
|
||||
return m.DeleteProfileFn(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestListProfiles_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
prof1 := domain.CertificateProfile{
|
||||
ID: "prof-standard-tls",
|
||||
Name: "Standard TLS",
|
||||
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
||||
{Algorithm: "ECDSA", MinSize: 256},
|
||||
{Algorithm: "RSA", MinSize: 2048},
|
||||
},
|
||||
MaxTTLSeconds: 7776000,
|
||||
AllowedEKUs: []string{"serverAuth"},
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
prof2 := domain.CertificateProfile{
|
||||
ID: "prof-internal-mtls",
|
||||
Name: "Internal mTLS",
|
||||
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
||||
{Algorithm: "ECDSA", MinSize: 256},
|
||||
},
|
||||
MaxTTLSeconds: 2592000,
|
||||
AllowedEKUs: []string{"serverAuth", "clientAuth"},
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
mock := &MockProfileService{
|
||||
ListProfilesFn: func(page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
||||
return []domain.CertificateProfile{prof1, prof2}, 2, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListProfiles(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Total != 2 {
|
||||
t.Errorf("expected total 2, got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProfiles_Pagination(t *testing.T) {
|
||||
var capturedPage, capturedPerPage int
|
||||
mock := &MockProfileService{
|
||||
ListProfilesFn: func(page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
||||
capturedPage = page
|
||||
capturedPerPage = perPage
|
||||
return []domain.CertificateProfile{}, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles?page=3&per_page=25", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListProfiles(w, req)
|
||||
|
||||
if capturedPage != 3 {
|
||||
t.Errorf("expected page 3, got %d", capturedPage)
|
||||
}
|
||||
if capturedPerPage != 25 {
|
||||
t.Errorf("expected per_page 25, got %d", capturedPerPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProfiles_ServiceError(t *testing.T) {
|
||||
mock := &MockProfileService{
|
||||
ListProfilesFn: func(page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
||||
return nil, 0, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListProfiles(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProfiles_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListProfiles(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfile_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockProfileService{
|
||||
GetProfileFn: func(id string) (*domain.CertificateProfile, error) {
|
||||
return &domain.CertificateProfile{
|
||||
ID: id,
|
||||
Name: "Standard TLS",
|
||||
MaxTTLSeconds: 7776000,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/prof-standard-tls", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfile_NotFound(t *testing.T) {
|
||||
mock := &MockProfileService{
|
||||
GetProfileFn: func(id string) (*domain.CertificateProfile, error) {
|
||||
return nil, ErrMockNotFound
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/nonexistent", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfile_EmptyID(t *testing.T) {
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfile_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockProfileService{
|
||||
CreateProfileFn: func(profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
||||
profile.ID = "prof-new"
|
||||
profile.CreatedAt = now
|
||||
profile.UpdatedAt = now
|
||||
return &profile, nil
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "New Profile",
|
||||
"max_ttl_seconds": 86400,
|
||||
"allowed_ekus": []string{"serverAuth"},
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfile_MissingName(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"max_ttl_seconds": 86400,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfile_NameTooLong(t *testing.T) {
|
||||
longName := ""
|
||||
for i := 0; i < 256; i++ {
|
||||
longName += "x"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": longName,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfile_InvalidJSON(t *testing.T) {
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader([]byte("{invalid")))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfile_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockProfileService{
|
||||
UpdateProfileFn: func(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
||||
profile.ID = id
|
||||
profile.UpdatedAt = now
|
||||
return &profile, nil
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "Updated Profile",
|
||||
"max_ttl_seconds": 172800,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/profiles/prof-standard-tls", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidJSON(t *testing.T) {
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/profiles/prof-x", bytes.NewReader([]byte("{bad")))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_Success(t *testing.T) {
|
||||
var deletedID string
|
||||
mock := &MockProfileService{
|
||||
DeleteProfileFn: func(id string) error {
|
||||
deletedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles/prof-standard-tls", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status 204, got %d", w.Code)
|
||||
}
|
||||
if deletedID != "prof-standard-tls" {
|
||||
t.Errorf("expected deleted ID 'prof-standard-tls', got '%s'", deletedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_ServiceError(t *testing.T) {
|
||||
mock := &MockProfileService{
|
||||
DeleteProfileFn: func(id string) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewProfileHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles/prof-x", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_EmptyID(t *testing.T) {
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles/", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewProfileHandler(&MockProfileService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/prof-x", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteProfile(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// ProfileService defines the service interface for certificate profile operations.
|
||||
type ProfileService interface {
|
||||
ListProfiles(page, perPage int) ([]domain.CertificateProfile, int64, error)
|
||||
GetProfile(id string) (*domain.CertificateProfile, error)
|
||||
CreateProfile(profile domain.CertificateProfile) (*domain.CertificateProfile, error)
|
||||
UpdateProfile(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error)
|
||||
DeleteProfile(id string) error
|
||||
}
|
||||
|
||||
// ProfileHandler handles HTTP requests for certificate profile operations.
|
||||
type ProfileHandler struct {
|
||||
svc ProfileService
|
||||
}
|
||||
|
||||
// NewProfileHandler creates a new ProfileHandler with a service dependency.
|
||||
func NewProfileHandler(svc ProfileService) ProfileHandler {
|
||||
return ProfileHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ListProfiles lists all certificate profiles.
|
||||
// GET /api/v1/profiles?page=1&per_page=50
|
||||
func (h ProfileHandler) ListProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
page := 1
|
||||
perPage := 50
|
||||
query := r.URL.Query()
|
||||
if p := query.Get("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
if pp := query.Get("per_page"); pp != "" {
|
||||
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
|
||||
perPage = parsed
|
||||
}
|
||||
}
|
||||
|
||||
profiles, total, err := h.svc.ListProfiles(page, perPage)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list profiles", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
response := PagedResponse{
|
||||
Data: profiles,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetProfile retrieves a single certificate profile by ID.
|
||||
// GET /api/v1/profiles/{id}
|
||||
func (h ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/profiles/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Profile ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := h.svc.GetProfile(id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// CreateProfile creates a new certificate profile.
|
||||
// POST /api/v1/profiles
|
||||
func (h ProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
var profile domain.CertificateProfile
|
||||
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("name", profile.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateStringLength("name", profile.Name, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateProfile(profile)
|
||||
if err != nil {
|
||||
// Check if it's a validation error from the service
|
||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
|
||||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create profile", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// UpdateProfile updates an existing certificate profile.
|
||||
// PUT /api/v1/profiles/{id}
|
||||
func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/profiles/")
|
||||
parts := strings.Split(id, "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Profile ID is required", requestID)
|
||||
return
|
||||
}
|
||||
id = parts[0]
|
||||
|
||||
var profile domain.CertificateProfile
|
||||
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateProfile(id, profile)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
|
||||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update profile", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteProfile deletes a certificate profile.
|
||||
// DELETE /api/v1/profiles/{id}
|
||||
func (h ProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/profiles/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Profile ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteProfile(id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete profile", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PagedResponse represents a paginated API response.
|
||||
@@ -13,6 +17,14 @@ type PagedResponse struct {
|
||||
PerPage int `json:"per_page"`
|
||||
}
|
||||
|
||||
// CursorPagedResponse represents a cursor-paginated API response.
|
||||
type CursorPagedResponse struct {
|
||||
Data interface{} `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents a standard error response.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
@@ -49,3 +61,72 @@ func ErrorWithRequestID(w http.ResponseWriter, status int, message, requestID st
|
||||
w.WriteHeader(status)
|
||||
return json.NewEncoder(w).Encode(errResp)
|
||||
}
|
||||
|
||||
// encodeCursor creates an opaque cursor token from a timestamp and ID.
|
||||
func encodeCursor(createdAt time.Time, id string) string {
|
||||
raw := createdAt.Format(time.RFC3339Nano) + ":" + id
|
||||
return base64.URLEncoding.EncodeToString([]byte(raw))
|
||||
}
|
||||
|
||||
// decodeCursor extracts a timestamp and ID from a cursor token.
|
||||
func decodeCursor(cursor string) (time.Time, string, error) {
|
||||
raw, err := base64.URLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return time.Time{}, "", fmt.Errorf("invalid cursor: %w", err)
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, "", fmt.Errorf("invalid cursor format")
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, parts[0])
|
||||
if err != nil {
|
||||
return time.Time{}, "", fmt.Errorf("invalid cursor timestamp: %w", err)
|
||||
}
|
||||
return t, parts[1], nil
|
||||
}
|
||||
|
||||
// filterFields removes fields not in the allowed list from the response data.
|
||||
// Works with both single objects and slices.
|
||||
func filterFields(data interface{}, fields []string) interface{} {
|
||||
if len(fields) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
// Create field set for O(1) lookup
|
||||
fieldSet := make(map[string]bool, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldSet[f] = true
|
||||
}
|
||||
|
||||
// Marshal to JSON, then unmarshal to generic structure
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
|
||||
// Try as array first
|
||||
var arr []map[string]interface{}
|
||||
if err := json.Unmarshal(bytes, &arr); err == nil {
|
||||
for i := range arr {
|
||||
for key := range arr[i] {
|
||||
if !fieldSet[key] {
|
||||
delete(arr[i], key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// Try as object
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal(bytes, &obj); err == nil {
|
||||
for key := range obj {
|
||||
if !fieldSet[key] {
|
||||
delete(obj, key)
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
)
|
||||
|
||||
// StatsService defines the service interface for statistics operations.
|
||||
type StatsService interface {
|
||||
GetDashboardSummary(ctx context.Context) (interface{}, error)
|
||||
GetCertificatesByStatus(ctx context.Context) (interface{}, error)
|
||||
GetExpirationTimeline(ctx context.Context, days int) (interface{}, error)
|
||||
GetJobStats(ctx context.Context, days int) (interface{}, error)
|
||||
GetIssuanceRate(ctx context.Context, days int) (interface{}, error)
|
||||
}
|
||||
|
||||
// StatsHandler handles HTTP requests for statistics and observability endpoints.
|
||||
type StatsHandler struct {
|
||||
svc StatsService
|
||||
}
|
||||
|
||||
// NewStatsHandler creates a new StatsHandler with a service dependency.
|
||||
func NewStatsHandler(svc StatsService) StatsHandler {
|
||||
return StatsHandler{svc: svc}
|
||||
}
|
||||
|
||||
// GetDashboardSummary returns a high-level summary of system state.
|
||||
// GET /api/v1/stats/summary
|
||||
func (h StatsHandler) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
summary, err := h.svc.GetDashboardSummary(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get dashboard summary", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// GetCertificatesByStatus returns certificate counts grouped by status.
|
||||
// GET /api/v1/stats/certificates-by-status
|
||||
func (h StatsHandler) GetCertificatesByStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
counts, err := h.svc.GetCertificatesByStatus(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate status counts", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, counts)
|
||||
}
|
||||
|
||||
// GetExpirationTimeline returns certificates expiring over the next N days.
|
||||
// GET /api/v1/stats/expiration-timeline?days=30
|
||||
func (h StatsHandler) GetExpirationTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Parse query parameter
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
timeline, err := h.svc.GetExpirationTimeline(r.Context(), days)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get expiration timeline", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, timeline)
|
||||
}
|
||||
|
||||
// GetJobTrends returns job success/failure trends over the past N days.
|
||||
// GET /api/v1/stats/job-trends?days=30
|
||||
func (h StatsHandler) GetJobTrends(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Parse query parameter
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
trends, err := h.svc.GetJobStats(r.Context(), days)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get job trends", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, trends)
|
||||
}
|
||||
|
||||
// GetIssuanceRate returns the rate of new certificate issuance over the past N days.
|
||||
// GET /api/v1/stats/issuance-rate?days=30
|
||||
func (h StatsHandler) GetIssuanceRate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Parse query parameter
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
issuanceRate, err := h.svc.GetIssuanceRate(r.Context(), days)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get issuance rate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, issuanceRate)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockStatsService implements both StatsService and MetricsService.
|
||||
type MockStatsService struct {
|
||||
GetDashboardSummaryFn func(ctx context.Context) (interface{}, error)
|
||||
GetCertificatesByStatusFn func(ctx context.Context) (interface{}, error)
|
||||
GetExpirationTimelineFn func(ctx context.Context, days int) (interface{}, error)
|
||||
GetJobStatsFn func(ctx context.Context, days int) (interface{}, error)
|
||||
GetIssuanceRateFn func(ctx context.Context, days int) (interface{}, error)
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetDashboardSummary(ctx context.Context) (interface{}, error) {
|
||||
if m.GetDashboardSummaryFn != nil {
|
||||
return m.GetDashboardSummaryFn(ctx)
|
||||
}
|
||||
return map[string]int64{"total_certificates": 0}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetCertificatesByStatus(ctx context.Context) (interface{}, error) {
|
||||
if m.GetCertificatesByStatusFn != nil {
|
||||
return m.GetCertificatesByStatusFn(ctx)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetExpirationTimeline(ctx context.Context, days int) (interface{}, error) {
|
||||
if m.GetExpirationTimelineFn != nil {
|
||||
return m.GetExpirationTimelineFn(ctx, days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetJobStats(ctx context.Context, days int) (interface{}, error) {
|
||||
if m.GetJobStatsFn != nil {
|
||||
return m.GetJobStatsFn(ctx, days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
|
||||
if m.GetIssuanceRateFn != nil {
|
||||
return m.GetIssuanceRateFn(ctx, days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetDashboardSummary(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetDashboardSummary(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_ServiceError(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetDashboardSummary(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCertificatesByStatus_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/certificates-by-status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetCertificatesByStatus(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpirationTimeline_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline?days=60", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetExpirationTimeline(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpirationTimeline_DefaultDays(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetExpirationTimelineFn: func(ctx context.Context, days int) (interface{}, error) {
|
||||
if days != 30 {
|
||||
t.Errorf("expected default 30 days, got %d", days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
},
|
||||
}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetExpirationTimeline(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobTrends_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/job-trends?days=14", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetJobTrends(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssuanceRate_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/issuance-rate?days=7", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetIssuanceRate(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_Success(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return &DashboardSummary{
|
||||
TotalCertificates: 10,
|
||||
ExpiringCertificates: 2,
|
||||
ExpiredCertificates: 1,
|
||||
RevokedCertificates: 0,
|
||||
ActiveAgents: 3,
|
||||
TotalAgents: 5,
|
||||
PendingJobs: 1,
|
||||
FailedJobs: 0,
|
||||
CompleteJobs: 8,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetMetrics(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetMetrics(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_ServiceError(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetMetrics(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prometheus metrics endpoint tests ---
|
||||
|
||||
func TestGetPrometheusMetrics_Success(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return &DashboardSummary{
|
||||
TotalCertificates: 25,
|
||||
ExpiringCertificates: 3,
|
||||
ExpiredCertificates: 2,
|
||||
RevokedCertificates: 1,
|
||||
ActiveAgents: 4,
|
||||
TotalAgents: 6,
|
||||
PendingJobs: 2,
|
||||
FailedJobs: 1,
|
||||
CompleteJobs: 15,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewMetricsHandler(mock, time.Now().Add(-1*time.Hour))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/prometheus", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetPrometheusMetrics(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/plain; version=0.0.4; charset=utf-8" {
|
||||
t.Errorf("expected Prometheus content type, got %q", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check metric lines are present
|
||||
expected := []string{
|
||||
"certctl_certificate_total 25",
|
||||
"certctl_certificate_active 19",
|
||||
"certctl_certificate_expiring_soon 3",
|
||||
"certctl_certificate_expired 2",
|
||||
"certctl_certificate_revoked 1",
|
||||
"certctl_agent_total 6",
|
||||
"certctl_agent_online 4",
|
||||
"certctl_job_pending 2",
|
||||
"certctl_job_completed_total 15",
|
||||
"certctl_job_failed_total 1",
|
||||
"# TYPE certctl_certificate_total gauge",
|
||||
"# TYPE certctl_job_completed_total counter",
|
||||
"# HELP certctl_uptime_seconds",
|
||||
"# TYPE certctl_uptime_seconds gauge",
|
||||
}
|
||||
for _, exp := range expected {
|
||||
if !containsLine(body, exp) {
|
||||
t.Errorf("expected body to contain %q", exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrometheusMetrics_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics/prometheus", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetPrometheusMetrics(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrometheusMetrics_ServiceError(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/prometheus", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetPrometheusMetrics(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrometheusMetrics_ZeroValues(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return &DashboardSummary{}, nil
|
||||
},
|
||||
}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/prometheus", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetPrometheusMetrics(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsLine(body, "certctl_certificate_total 0") {
|
||||
t.Error("expected zero value for certificate_total")
|
||||
}
|
||||
if !containsLine(body, "certctl_job_pending 0") {
|
||||
t.Error("expected zero value for job_pending")
|
||||
}
|
||||
}
|
||||
|
||||
// containsLine checks if the text contains the given substring.
|
||||
func containsLine(text, substr string) bool {
|
||||
return strings.Contains(text, substr)
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// MockTeamService is a mock implementation of TeamService interface.
|
||||
type MockTeamService struct {
|
||||
ListTeamsFn func(page, perPage int) ([]domain.Team, int64, error)
|
||||
GetTeamFn func(id string) (*domain.Team, error)
|
||||
CreateTeamFn func(team domain.Team) (*domain.Team, error)
|
||||
UpdateTeamFn func(id string, team domain.Team) (*domain.Team, error)
|
||||
DeleteTeamFn func(id string) error
|
||||
}
|
||||
|
||||
func (m *MockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) {
|
||||
if m.ListTeamsFn != nil {
|
||||
return m.ListTeamsFn(page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockTeamService) GetTeam(id string) (*domain.Team, error) {
|
||||
if m.GetTeamFn != nil {
|
||||
return m.GetTeamFn(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTeamService) CreateTeam(team domain.Team) (*domain.Team, error) {
|
||||
if m.CreateTeamFn != nil {
|
||||
return m.CreateTeamFn(team)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTeamService) UpdateTeam(id string, team domain.Team) (*domain.Team, error) {
|
||||
if m.UpdateTeamFn != nil {
|
||||
return m.UpdateTeamFn(id, team)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTeamService) DeleteTeam(id string) error {
|
||||
if m.DeleteTeamFn != nil {
|
||||
return m.DeleteTeamFn(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestListTeams_Success tests listing teams with default pagination.
|
||||
func TestListTeams_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
t1 := domain.Team{
|
||||
ID: "t-platform",
|
||||
Name: "Platform Team",
|
||||
Description: "Infrastructure team",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
t2 := domain.Team{
|
||||
ID: "t-security",
|
||||
Name: "Security Team",
|
||||
Description: "Security operations",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
mock := &MockTeamService{
|
||||
ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) {
|
||||
return []domain.Team{t1, t2}, 2, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListTeams(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp PagedResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Total != 2 {
|
||||
t.Errorf("expected total 2, got %d", resp.Total)
|
||||
}
|
||||
if resp.Page != 1 {
|
||||
t.Errorf("expected page 1, got %d", resp.Page)
|
||||
}
|
||||
if resp.PerPage != 50 {
|
||||
t.Errorf("expected per_page 50, got %d", resp.PerPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTeams_WithQueryParams tests listing with custom pagination parameters.
|
||||
func TestListTeams_WithQueryParams(t *testing.T) {
|
||||
var capturedPage, capturedPerPage int
|
||||
mock := &MockTeamService{
|
||||
ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) {
|
||||
capturedPage = page
|
||||
capturedPerPage = perPage
|
||||
return []domain.Team{}, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams?page=3&per_page=25", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListTeams(w, req)
|
||||
|
||||
if capturedPage != 3 {
|
||||
t.Errorf("expected page 3, got %d", capturedPage)
|
||||
}
|
||||
if capturedPerPage != 25 {
|
||||
t.Errorf("expected per_page 25, got %d", capturedPerPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTeams_PerPageMaxLimit tests that per_page values exceeding 500 are rejected
|
||||
// and fall back to the default of 50 (the handler ignores invalid per_page values).
|
||||
func TestListTeams_PerPageMaxLimit(t *testing.T) {
|
||||
var capturedPerPage int
|
||||
mock := &MockTeamService{
|
||||
ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) {
|
||||
capturedPerPage = perPage
|
||||
return []domain.Team{}, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams?per_page=1000", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListTeams(w, req)
|
||||
|
||||
// Handler rejects per_page > 500 and falls back to default (50)
|
||||
if capturedPerPage != 50 {
|
||||
t.Errorf("expected per_page to fall back to default 50 for values > 500, got %d", capturedPerPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTeams_ServiceError tests error handling when service fails.
|
||||
func TestListTeams_ServiceError(t *testing.T) {
|
||||
mock := &MockTeamService{
|
||||
ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) {
|
||||
return nil, 0, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListTeams(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTeams_MethodNotAllowed tests that non-GET requests are rejected.
|
||||
func TestListTeams_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListTeams(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTeam_Success tests retrieving a team by ID.
|
||||
func TestGetTeam_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockTeamService{
|
||||
GetTeamFn: func(id string) (*domain.Team, error) {
|
||||
return &domain.Team{
|
||||
ID: id,
|
||||
Name: "Platform Team",
|
||||
Description: "Infrastructure team",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/t-platform", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var team domain.Team
|
||||
if err := json.NewDecoder(w.Body).Decode(&team); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if team.ID != "t-platform" {
|
||||
t.Errorf("expected ID t-platform, got %s", team.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTeam_NotFound tests 404 response when team does not exist.
|
||||
func TestGetTeam_NotFound(t *testing.T) {
|
||||
mock := &MockTeamService{
|
||||
GetTeamFn: func(id string) (*domain.Team, error) {
|
||||
return nil, ErrMockNotFound
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/nonexistent", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTeam_EmptyID tests 400 response when team ID is empty.
|
||||
func TestGetTeam_EmptyID(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTeam_MethodNotAllowed tests that non-GET requests are rejected.
|
||||
func TestGetTeam_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/t-platform", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTeam_Success tests successful team creation.
|
||||
func TestCreateTeam_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockTeamService{
|
||||
CreateTeamFn: func(team domain.Team) (*domain.Team, error) {
|
||||
team.ID = "t-new"
|
||||
team.CreatedAt = now
|
||||
team.UpdatedAt = now
|
||||
return &team, nil
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "New Team",
|
||||
"description": "A new team",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
var team domain.Team
|
||||
if err := json.NewDecoder(w.Body).Decode(&team); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if team.ID != "t-new" {
|
||||
t.Errorf("expected ID t-new, got %s", team.ID)
|
||||
}
|
||||
if team.Name != "New Team" {
|
||||
t.Errorf("expected name 'New Team', got %s", team.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTeam_InvalidJSON tests 400 response for malformed JSON.
|
||||
func TestCreateTeam_InvalidJSON(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader([]byte("not json")))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTeam_MissingName tests 400 response when name is required but missing.
|
||||
func TestCreateTeam_MissingName(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"description": "Team without name",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTeam_NameTooLong tests 400 response when name exceeds max length.
|
||||
func TestCreateTeam_NameTooLong(t *testing.T) {
|
||||
longName := ""
|
||||
for i := 0; i < 256; i++ {
|
||||
longName += "x"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": longName,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTeam_ServiceError tests error handling when service fails.
|
||||
func TestCreateTeam_ServiceError(t *testing.T) {
|
||||
mock := &MockTeamService{
|
||||
CreateTeamFn: func(team domain.Team) (*domain.Team, error) {
|
||||
return nil, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "New Team",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTeam_MethodNotAllowed tests that non-POST requests are rejected.
|
||||
func TestCreateTeam_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTeam_Success tests successful team update.
|
||||
func TestUpdateTeam_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mock := &MockTeamService{
|
||||
UpdateTeamFn: func(id string, team domain.Team) (*domain.Team, error) {
|
||||
return &domain.Team{
|
||||
ID: id,
|
||||
Name: team.Name,
|
||||
Description: team.Description,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "Updated Team",
|
||||
"description": "Updated description",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/t-platform", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var team domain.Team
|
||||
if err := json.NewDecoder(w.Body).Decode(&team); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if team.Name != "Updated Team" {
|
||||
t.Errorf("expected name 'Updated Team', got %s", team.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTeam_InvalidJSON tests 400 response for malformed JSON.
|
||||
func TestUpdateTeam_InvalidJSON(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/t-platform", bytes.NewReader([]byte("bad json")))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTeam_EmptyID tests 400 response when team ID is empty.
|
||||
func TestUpdateTeam_EmptyID(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"name": "Updated Team",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTeam_ServiceError tests error handling when service fails.
|
||||
func TestUpdateTeam_ServiceError(t *testing.T) {
|
||||
mock := &MockTeamService{
|
||||
UpdateTeamFn: func(id string, team domain.Team) (*domain.Team, error) {
|
||||
return nil, ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "Updated Team",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/t-platform", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTeam_MethodNotAllowed tests that non-PUT requests are rejected.
|
||||
func TestUpdateTeam_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams/t-platform", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteTeam_Success tests successful team deletion.
|
||||
func TestDeleteTeam_Success(t *testing.T) {
|
||||
mock := &MockTeamService{
|
||||
DeleteTeamFn: func(id string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/t-platform", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status 204, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteTeam_EmptyID tests 400 response when team ID is empty.
|
||||
func TestDeleteTeam_EmptyID(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteTeam_ServiceError tests error handling when service fails.
|
||||
func TestDeleteTeam_ServiceError(t *testing.T) {
|
||||
mock := &MockTeamService{
|
||||
DeleteTeamFn: func(id string) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/t-platform", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteTeam_MethodNotAllowed tests that non-DELETE requests are rejected.
|
||||
func TestDeleteTeam_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/t-platform", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.DeleteTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTeam_EmptyNameString tests 400 response when name is empty string.
|
||||
func TestCreateTeam_EmptyNameString(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"name": "",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
handler := NewTeamHandler(&MockTeamService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateTeam(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTeams_InvalidPagination tests handling of invalid pagination parameters.
|
||||
func TestListTeams_InvalidPagination(t *testing.T) {
|
||||
var capturedPage, capturedPerPage int
|
||||
mock := &MockTeamService{
|
||||
ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) {
|
||||
capturedPage = page
|
||||
capturedPerPage = perPage
|
||||
return []domain.Team{}, 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTeamHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/teams?page=invalid&per_page=bad", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ListTeams(w, req)
|
||||
|
||||
// Should use defaults when parsing fails
|
||||
if capturedPage != 1 {
|
||||
t.Errorf("expected default page 1, got %d", capturedPage)
|
||||
}
|
||||
if capturedPerPage != 50 {
|
||||
t.Errorf("expected default per_page 50, got %d", capturedPerPage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditRecorder is the interface that the audit middleware uses to record API calls.
|
||||
// This avoids importing the service package directly, maintaining dependency inversion.
|
||||
type AuditRecorder interface {
|
||||
RecordAPICall(ctx context.Context, method, path, actor string, bodyHash string, status int, latencyMs int64) error
|
||||
}
|
||||
|
||||
// AuditConfig holds configuration for the API audit logging middleware.
|
||||
type AuditConfig struct {
|
||||
// ExcludePaths are path prefixes to skip audit logging (e.g., "/health", "/ready").
|
||||
ExcludePaths []string
|
||||
// Logger for audit middleware errors (audit recording failures shouldn't break requests).
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewAuditLog creates a middleware that records every API call to the audit trail.
|
||||
// It captures method, path, authenticated actor, request body hash, response status, and latency.
|
||||
// Audit recording is best-effort — failures are logged but don't affect the HTTP response.
|
||||
func NewAuditLog(recorder AuditRecorder, cfg AuditConfig) func(http.Handler) http.Handler {
|
||||
excludeSet := make(map[string]bool, len(cfg.ExcludePaths))
|
||||
for _, p := range cfg.ExcludePaths {
|
||||
excludeSet[p] = true
|
||||
}
|
||||
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip excluded paths (health, readiness probes)
|
||||
for prefix := range excludeSet {
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Hash request body for audit (don't store raw bodies — security + size concerns)
|
||||
bodyHash := ""
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
hasher := sha256.New()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err == nil && len(body) > 0 {
|
||||
hasher.Write(body)
|
||||
bodyHash = hex.EncodeToString(hasher.Sum(nil))[:16] // truncated hash
|
||||
// Restore the body for downstream handlers
|
||||
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
||||
}
|
||||
}
|
||||
|
||||
// Extract actor from auth context
|
||||
actor := "anonymous"
|
||||
if user, ok := GetUser(r.Context()); ok && user != "" {
|
||||
actor = user
|
||||
}
|
||||
|
||||
// Wrap response writer to capture status code
|
||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
// Record audit event asynchronously (best-effort, don't block response)
|
||||
go func() {
|
||||
if err := recorder.RecordAPICall(
|
||||
context.Background(),
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
actor,
|
||||
bodyHash,
|
||||
wrapped.statusCode,
|
||||
latency,
|
||||
); err != nil {
|
||||
logger.Error("failed to record API audit event",
|
||||
"error", err,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AuditServiceAdapter adapts the AuditService to the AuditRecorder interface.
|
||||
// This keeps the middleware decoupled from the service package.
|
||||
type AuditServiceAdapter struct {
|
||||
recordFn func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error
|
||||
}
|
||||
|
||||
// NewAuditServiceAdapter creates an adapter that bridges the middleware AuditRecorder
|
||||
// interface to the service layer's RecordEvent method.
|
||||
func NewAuditServiceAdapter(recordFn func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error) *AuditServiceAdapter {
|
||||
return &AuditServiceAdapter{recordFn: recordFn}
|
||||
}
|
||||
|
||||
// RecordAPICall implements AuditRecorder by translating API call data into an audit event.
|
||||
func (a *AuditServiceAdapter) RecordAPICall(ctx context.Context, method, path, actor string, bodyHash string, status int, latencyMs int64) error {
|
||||
details := map[string]interface{}{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"body_hash": bodyHash,
|
||||
"status": status,
|
||||
"latency_ms": latencyMs,
|
||||
}
|
||||
|
||||
action := fmt.Sprintf("api_%s", strings.ToLower(method))
|
||||
return a.recordFn(ctx, actor, "User", action, "api", path, details)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockAuditRecorder captures RecordAPICall invocations for testing.
|
||||
type mockAuditRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls []auditCall
|
||||
err error // if non-nil, RecordAPICall returns this
|
||||
}
|
||||
|
||||
type auditCall struct {
|
||||
Method string
|
||||
Path string
|
||||
Actor string
|
||||
BodyHash string
|
||||
Status int
|
||||
LatencyMs int64
|
||||
}
|
||||
|
||||
func (m *mockAuditRecorder) RecordAPICall(ctx context.Context, method, path, actor, bodyHash string, status int, latencyMs int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.calls = append(m.calls, auditCall{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Actor: actor,
|
||||
BodyHash: bodyHash,
|
||||
Status: status,
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
return m.err
|
||||
}
|
||||
|
||||
func (m *mockAuditRecorder) getCalls() []auditCall {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]auditCall, len(m.calls))
|
||||
copy(out, m.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAuditLog_RecordsAPICall(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{}
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
// Audit recording is async — give goroutine time to complete
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Method != "GET" {
|
||||
t.Errorf("expected method GET, got %s", calls[0].Method)
|
||||
}
|
||||
if calls[0].Path != "/api/v1/certificates" {
|
||||
t.Errorf("expected path /api/v1/certificates, got %s", calls[0].Path)
|
||||
}
|
||||
if calls[0].Actor != "anonymous" {
|
||||
t.Errorf("expected actor anonymous, got %s", calls[0].Actor)
|
||||
}
|
||||
if calls[0].Status != 200 {
|
||||
t.Errorf("expected status 200, got %d", calls[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_CapturesStatusCode(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{}
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certs/mc-nonexistent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Status != 404 {
|
||||
t.Errorf("expected status 404, got %d", calls[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_ExcludesHealth(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{}
|
||||
mw := NewAuditLog(recorder, AuditConfig{
|
||||
ExcludePaths: []string{"/health", "/ready"},
|
||||
})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Health endpoint — should be excluded
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Ready endpoint — should be excluded
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/ready", nil)
|
||||
rr2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr2, req2)
|
||||
|
||||
// API endpoint — should be recorded
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rr3 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr3, req3)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call (health/ready excluded), got %d", len(calls))
|
||||
}
|
||||
if calls[0].Path != "/api/v1/certificates" {
|
||||
t.Errorf("expected path /api/v1/certificates, got %s", calls[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_HashesRequestBody(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{}
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
// Handler verifies body was restored
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != `{"name":"test"}` {
|
||||
t.Errorf("body was not restored: got %q", string(body))
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
|
||||
body := strings.NewReader(`{"name":"test"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", body)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
// Body hash should be a 16-char hex string (truncated SHA-256)
|
||||
if len(calls[0].BodyHash) != 16 {
|
||||
t.Errorf("expected 16-char body hash, got %q (len=%d)", calls[0].BodyHash, len(calls[0].BodyHash))
|
||||
}
|
||||
if calls[0].Status != 201 {
|
||||
t.Errorf("expected status 201, got %d", calls[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_EmptyBodyNoHash(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{}
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].BodyHash != "" {
|
||||
t.Errorf("expected empty body hash for GET, got %q", calls[0].BodyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{}
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil)
|
||||
// Simulate auth middleware having set the user in context
|
||||
ctx := context.WithValue(req.Context(), UserKey{}, "api-key-user")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Actor != "api-key-user" {
|
||||
t.Errorf("expected actor api-key-user, got %s", calls[0].Actor)
|
||||
}
|
||||
if calls[0].Method != "DELETE" {
|
||||
t.Errorf("expected method DELETE, got %s", calls[0].Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_RecorderErrorDoesNotBreakResponse(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{err: fmt.Errorf("db connection lost")}
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/summary", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Response should still be 200 even though audit recording fails
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 despite recorder error, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_CapturesLatency(t *testing.T) {
|
||||
recorder := &mockAuditRecorder{}
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].LatencyMs < 10 {
|
||||
t.Errorf("expected latency >= 10ms, got %dms", calls[0].LatencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditServiceAdapter_TranslatesCallToEvent(t *testing.T) {
|
||||
var capturedActor, capturedActorType, capturedAction, capturedResourceType, capturedResourceID string
|
||||
var capturedDetails map[string]interface{}
|
||||
|
||||
adapter := NewAuditServiceAdapter(func(ctx context.Context, actor, actorType, action, resourceType, resourceID string, details map[string]interface{}) error {
|
||||
capturedActor = actor
|
||||
capturedActorType = actorType
|
||||
capturedAction = action
|
||||
capturedResourceType = resourceType
|
||||
capturedResourceID = resourceID
|
||||
capturedDetails = details
|
||||
return nil
|
||||
})
|
||||
|
||||
err := adapter.RecordAPICall(context.Background(), "POST", "/api/v1/certificates", "admin", "abc123", 201, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if capturedActor != "admin" {
|
||||
t.Errorf("expected actor admin, got %s", capturedActor)
|
||||
}
|
||||
if capturedActorType != "User" {
|
||||
t.Errorf("expected actorType User, got %s", capturedActorType)
|
||||
}
|
||||
if capturedAction != "api_post" {
|
||||
t.Errorf("expected action api_post, got %s", capturedAction)
|
||||
}
|
||||
if capturedResourceType != "api" {
|
||||
t.Errorf("expected resourceType api, got %s", capturedResourceType)
|
||||
}
|
||||
if capturedResourceID != "/api/v1/certificates" {
|
||||
t.Errorf("expected resourceID /api/v1/certificates, got %s", capturedResourceID)
|
||||
}
|
||||
if capturedDetails["method"] != "POST" {
|
||||
t.Errorf("expected details.method POST, got %v", capturedDetails["method"])
|
||||
}
|
||||
if capturedDetails["status"] != 201 {
|
||||
t.Errorf("expected details.status 201, got %v", capturedDetails["status"])
|
||||
}
|
||||
if capturedDetails["latency_ms"] != int64(42) {
|
||||
t.Errorf("expected details.latency_ms 42, got %v", capturedDetails["latency_ms"])
|
||||
}
|
||||
if capturedDetails["body_hash"] != "abc123" {
|
||||
t.Errorf("expected details.body_hash abc123, got %v", capturedDetails["body_hash"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditServiceAdapter_PropagatesError(t *testing.T) {
|
||||
adapter := NewAuditServiceAdapter(func(ctx context.Context, actor, actorType, action, resourceType, resourceID string, details map[string]interface{}) error {
|
||||
return fmt.Errorf("database error")
|
||||
})
|
||||
|
||||
err := adapter.RecordAPICall(context.Background(), "GET", "/api/v1/agents", "user", "", 200, 5)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "database error") {
|
||||
t.Errorf("expected database error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -30,6 +31,7 @@ func RequestID(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Logging middleware logs request details including method, path, status, and duration.
|
||||
// Deprecated: Use NewLogging for structured logging with slog.
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
@@ -45,6 +47,33 @@ func Logging(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// NewLogging creates a structured logging middleware using slog.
|
||||
// Logs request_id, method, path, status, duration_ms, and remote_addr.
|
||||
func NewLogging(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrap response writer to capture status code
|
||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
requestID := getRequestID(r.Context())
|
||||
|
||||
logger.InfoContext(r.Context(), "request completed",
|
||||
"request_id", requestID,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", wrapped.statusCode,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery middleware recovers from panics and returns a 500 error.
|
||||
func Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -51,11 +51,17 @@ func (r *Router) RegisterHandlers(
|
||||
agents handler.AgentHandler,
|
||||
jobs handler.JobHandler,
|
||||
policies handler.PolicyHandler,
|
||||
profiles handler.ProfileHandler,
|
||||
teams handler.TeamHandler,
|
||||
owners handler.OwnerHandler,
|
||||
agentGroups handler.AgentGroupHandler,
|
||||
audit handler.AuditHandler,
|
||||
notifications handler.NotificationHandler,
|
||||
stats handler.StatsHandler,
|
||||
metrics handler.MetricsHandler,
|
||||
health handler.HealthHandler,
|
||||
discovery handler.DiscoveryHandler,
|
||||
networkScan handler.NetworkScanHandler,
|
||||
) {
|
||||
// Health endpoints (no auth middleware — must always be accessible)
|
||||
r.mux.Handle("GET /health", middleware.Chain(
|
||||
@@ -84,8 +90,17 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("PUT /api/v1/certificates/{id}", http.HandlerFunc(certificates.UpdateCertificate))
|
||||
r.Register("DELETE /api/v1/certificates/{id}", http.HandlerFunc(certificates.ArchiveCertificate))
|
||||
r.Register("GET /api/v1/certificates/{id}/versions", http.HandlerFunc(certificates.GetCertificateVersions))
|
||||
r.Register("GET /api/v1/certificates/{id}/deployments", http.HandlerFunc(certificates.GetCertificateDeployments))
|
||||
r.Register("POST /api/v1/certificates/{id}/renew", http.HandlerFunc(certificates.TriggerRenewal))
|
||||
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(certificates.TriggerDeployment))
|
||||
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(certificates.RevokeCertificate))
|
||||
|
||||
// CRL endpoints: /api/v1/crl (JSON) and /api/v1/crl/{issuer_id} (DER)
|
||||
r.Register("GET /api/v1/crl", http.HandlerFunc(certificates.GetCRL))
|
||||
r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(certificates.GetDERCRL))
|
||||
|
||||
// OCSP responder: /api/v1/ocsp/{issuer_id}/{serial}
|
||||
r.Register("GET /api/v1/ocsp/{issuer_id}/{serial}", http.HandlerFunc(certificates.HandleOCSP))
|
||||
|
||||
// Issuers routes: /api/v1/issuers
|
||||
r.Register("GET /api/v1/issuers", http.HandlerFunc(issuers.ListIssuers))
|
||||
@@ -116,6 +131,8 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("GET /api/v1/jobs", http.HandlerFunc(jobs.ListJobs))
|
||||
r.Register("GET /api/v1/jobs/{id}", http.HandlerFunc(jobs.GetJob))
|
||||
r.Register("POST /api/v1/jobs/{id}/cancel", http.HandlerFunc(jobs.CancelJob))
|
||||
r.Register("POST /api/v1/jobs/{id}/approve", http.HandlerFunc(jobs.ApproveJob))
|
||||
r.Register("POST /api/v1/jobs/{id}/reject", http.HandlerFunc(jobs.RejectJob))
|
||||
|
||||
// Policies routes: /api/v1/policies
|
||||
r.Register("GET /api/v1/policies", http.HandlerFunc(policies.ListPolicies))
|
||||
@@ -125,6 +142,13 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("DELETE /api/v1/policies/{id}", http.HandlerFunc(policies.DeletePolicy))
|
||||
r.Register("GET /api/v1/policies/{id}/violations", http.HandlerFunc(policies.ListViolations))
|
||||
|
||||
// Profiles routes: /api/v1/profiles
|
||||
r.Register("GET /api/v1/profiles", http.HandlerFunc(profiles.ListProfiles))
|
||||
r.Register("POST /api/v1/profiles", http.HandlerFunc(profiles.CreateProfile))
|
||||
r.Register("GET /api/v1/profiles/{id}", http.HandlerFunc(profiles.GetProfile))
|
||||
r.Register("PUT /api/v1/profiles/{id}", http.HandlerFunc(profiles.UpdateProfile))
|
||||
r.Register("DELETE /api/v1/profiles/{id}", http.HandlerFunc(profiles.DeleteProfile))
|
||||
|
||||
// Teams routes: /api/v1/teams
|
||||
r.Register("GET /api/v1/teams", http.HandlerFunc(teams.ListTeams))
|
||||
r.Register("POST /api/v1/teams", http.HandlerFunc(teams.CreateTeam))
|
||||
@@ -139,6 +163,14 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("PUT /api/v1/owners/{id}", http.HandlerFunc(owners.UpdateOwner))
|
||||
r.Register("DELETE /api/v1/owners/{id}", http.HandlerFunc(owners.DeleteOwner))
|
||||
|
||||
// Agent Groups routes: /api/v1/agent-groups
|
||||
r.Register("GET /api/v1/agent-groups", http.HandlerFunc(agentGroups.ListAgentGroups))
|
||||
r.Register("POST /api/v1/agent-groups", http.HandlerFunc(agentGroups.CreateAgentGroup))
|
||||
r.Register("GET /api/v1/agent-groups/{id}", http.HandlerFunc(agentGroups.GetAgentGroup))
|
||||
r.Register("PUT /api/v1/agent-groups/{id}", http.HandlerFunc(agentGroups.UpdateAgentGroup))
|
||||
r.Register("DELETE /api/v1/agent-groups/{id}", http.HandlerFunc(agentGroups.DeleteAgentGroup))
|
||||
r.Register("GET /api/v1/agent-groups/{id}/members", http.HandlerFunc(agentGroups.ListAgentGroupMembers))
|
||||
|
||||
// Audit routes: /api/v1/audit
|
||||
r.Register("GET /api/v1/audit", http.HandlerFunc(audit.ListAuditEvents))
|
||||
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(audit.GetAuditEvent))
|
||||
@@ -147,6 +179,34 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("GET /api/v1/notifications", http.HandlerFunc(notifications.ListNotifications))
|
||||
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(notifications.GetNotification))
|
||||
r.Register("POST /api/v1/notifications/{id}/read", http.HandlerFunc(notifications.MarkAsRead))
|
||||
|
||||
// Stats routes: /api/v1/stats
|
||||
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(stats.GetDashboardSummary))
|
||||
r.Register("GET /api/v1/stats/certificates-by-status", http.HandlerFunc(stats.GetCertificatesByStatus))
|
||||
r.Register("GET /api/v1/stats/expiration-timeline", http.HandlerFunc(stats.GetExpirationTimeline))
|
||||
r.Register("GET /api/v1/stats/job-trends", http.HandlerFunc(stats.GetJobTrends))
|
||||
r.Register("GET /api/v1/stats/issuance-rate", http.HandlerFunc(stats.GetIssuanceRate))
|
||||
|
||||
// Metrics routes: /api/v1/metrics
|
||||
r.Register("GET /api/v1/metrics", http.HandlerFunc(metrics.GetMetrics))
|
||||
r.Register("GET /api/v1/metrics/prometheus", http.HandlerFunc(metrics.GetPrometheusMetrics))
|
||||
|
||||
// Discovery routes: /api/v1/discovered-certificates, /api/v1/discovery-scans
|
||||
r.Register("POST /api/v1/agents/{id}/discoveries", http.HandlerFunc(discovery.SubmitDiscoveryReport))
|
||||
r.Register("GET /api/v1/discovered-certificates", http.HandlerFunc(discovery.ListDiscovered))
|
||||
r.Register("GET /api/v1/discovered-certificates/{id}", http.HandlerFunc(discovery.GetDiscovered))
|
||||
r.Register("POST /api/v1/discovered-certificates/{id}/claim", http.HandlerFunc(discovery.ClaimDiscovered))
|
||||
r.Register("POST /api/v1/discovered-certificates/{id}/dismiss", http.HandlerFunc(discovery.DismissDiscovered))
|
||||
r.Register("GET /api/v1/discovery-scans", http.HandlerFunc(discovery.ListScans))
|
||||
r.Register("GET /api/v1/discovery-summary", http.HandlerFunc(discovery.GetDiscoverySummary))
|
||||
|
||||
// Network scan routes: /api/v1/network-scan-targets
|
||||
r.Register("GET /api/v1/network-scan-targets", http.HandlerFunc(networkScan.ListNetworkScanTargets))
|
||||
r.Register("POST /api/v1/network-scan-targets", http.HandlerFunc(networkScan.CreateNetworkScanTarget))
|
||||
r.Register("GET /api/v1/network-scan-targets/{id}", http.HandlerFunc(networkScan.GetNetworkScanTarget))
|
||||
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(networkScan.UpdateNetworkScanTarget))
|
||||
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(networkScan.DeleteNetworkScanTarget))
|
||||
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(networkScan.TriggerNetworkScan))
|
||||
}
|
||||
|
||||
// GetMux returns the underlying http.ServeMux for direct access if needed.
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is the CLI HTTP client that communicates with the certctl server.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
format string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new CLI client.
|
||||
func NewClient(baseURL, apiKey, format string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
format: format,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// do performs an HTTP request and returns the parsed JSON response.
|
||||
func (c *Client) do(method, path string, query url.Values, body interface{}) (json.RawMessage, error) {
|
||||
u, err := url.JoinPath(c.baseURL, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if query != nil && len(query) > 0 {
|
||||
u = u + "?" + query.Encode()
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
// 204 No Content — return empty JSON object
|
||||
if resp.StatusCode == 204 {
|
||||
return json.RawMessage(`{"status":"deleted"}`), nil
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return json.RawMessage(respBody), nil
|
||||
}
|
||||
|
||||
// ListCertificates lists all managed certificates with optional filters.
|
||||
func (c *Client) ListCertificates(args []string) error {
|
||||
fs := flag.NewFlagSet("certs list", flag.ContinueOnError)
|
||||
status := fs.String("status", "", "Filter by status")
|
||||
page := fs.Int("page", 1, "Page number")
|
||||
perPage := fs.Int("per-page", 50, "Items per page")
|
||||
fs.Parse(args)
|
||||
|
||||
query := url.Values{}
|
||||
if *status != "" {
|
||||
query.Set("status", *status)
|
||||
}
|
||||
query.Set("page", fmt.Sprintf("%d", *page))
|
||||
query.Set("per_page", fmt.Sprintf("%d", *perPage))
|
||||
|
||||
resp, err := c.do("GET", "/api/v1/certificates", query, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(result)
|
||||
}
|
||||
|
||||
return c.outputCertificatesTable(result.Data, result.Total)
|
||||
}
|
||||
|
||||
// GetCertificate retrieves a single certificate by ID.
|
||||
func (c *Client) GetCertificate(id string) error {
|
||||
resp, err := c.do("GET", fmt.Sprintf("/api/v1/certificates/%s", id), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cert map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &cert); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(cert)
|
||||
}
|
||||
|
||||
return c.outputCertificateDetail(cert)
|
||||
}
|
||||
|
||||
// RenewCertificate triggers renewal for a certificate.
|
||||
func (c *Client) RenewCertificate(id string) error {
|
||||
body := map[string]interface{}{
|
||||
"force": false,
|
||||
}
|
||||
|
||||
resp, err := c.do("POST", fmt.Sprintf("/api/v1/certificates/%s/renew", id), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(result)
|
||||
}
|
||||
|
||||
fmt.Printf("Renewal triggered for certificate %s\n", id)
|
||||
if jobID, ok := result["job_id"]; ok {
|
||||
fmt.Printf("Job ID: %v\n", jobID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate.
|
||||
func (c *Client) RevokeCertificate(id, reason string) error {
|
||||
body := map[string]interface{}{
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
resp, err := c.do("POST", fmt.Sprintf("/api/v1/certificates/%s/revoke", id), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(result)
|
||||
}
|
||||
|
||||
fmt.Printf("Certificate %s revoked with reason: %s\n", id, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAgents lists all agents.
|
||||
func (c *Client) ListAgents(args []string) error {
|
||||
fs := flag.NewFlagSet("agents list", flag.ContinueOnError)
|
||||
status := fs.String("status", "", "Filter by status")
|
||||
page := fs.Int("page", 1, "Page number")
|
||||
perPage := fs.Int("per-page", 50, "Items per page")
|
||||
fs.Parse(args)
|
||||
|
||||
query := url.Values{}
|
||||
if *status != "" {
|
||||
query.Set("status", *status)
|
||||
}
|
||||
query.Set("page", fmt.Sprintf("%d", *page))
|
||||
query.Set("per_page", fmt.Sprintf("%d", *perPage))
|
||||
|
||||
resp, err := c.do("GET", "/api/v1/agents", query, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(result)
|
||||
}
|
||||
|
||||
return c.outputAgentsTable(result.Data, result.Total)
|
||||
}
|
||||
|
||||
// GetAgent retrieves a single agent by ID.
|
||||
func (c *Client) GetAgent(id string) error {
|
||||
resp, err := c.do("GET", fmt.Sprintf("/api/v1/agents/%s", id), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var agent map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &agent); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(agent)
|
||||
}
|
||||
|
||||
return c.outputAgentDetail(agent)
|
||||
}
|
||||
|
||||
// ListJobs lists all jobs.
|
||||
func (c *Client) ListJobs(args []string) error {
|
||||
fs := flag.NewFlagSet("jobs list", flag.ContinueOnError)
|
||||
status := fs.String("status", "", "Filter by status")
|
||||
jobType := fs.String("type", "", "Filter by type")
|
||||
page := fs.Int("page", 1, "Page number")
|
||||
perPage := fs.Int("per-page", 50, "Items per page")
|
||||
fs.Parse(args)
|
||||
|
||||
query := url.Values{}
|
||||
if *status != "" {
|
||||
query.Set("status", *status)
|
||||
}
|
||||
if *jobType != "" {
|
||||
query.Set("type", *jobType)
|
||||
}
|
||||
query.Set("page", fmt.Sprintf("%d", *page))
|
||||
query.Set("per_page", fmt.Sprintf("%d", *perPage))
|
||||
|
||||
resp, err := c.do("GET", "/api/v1/jobs", query, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(result)
|
||||
}
|
||||
|
||||
return c.outputJobsTable(result.Data, result.Total)
|
||||
}
|
||||
|
||||
// GetJob retrieves a single job by ID.
|
||||
func (c *Client) GetJob(id string) error {
|
||||
resp, err := c.do("GET", fmt.Sprintf("/api/v1/jobs/%s", id), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var job map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &job); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(job)
|
||||
}
|
||||
|
||||
return c.outputJobDetail(job)
|
||||
}
|
||||
|
||||
// CancelJob cancels a pending job.
|
||||
func (c *Client) CancelJob(id string) error {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
resp, err := c.do("POST", fmt.Sprintf("/api/v1/jobs/%s/cancel", id), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(result)
|
||||
}
|
||||
|
||||
fmt.Printf("Job %s cancelled\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus retrieves server health and summary stats.
|
||||
func (c *Client) GetStatus() error {
|
||||
resp, err := c.do("GET", "/api/v1/health", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var health map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &health); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if c.format == "json" {
|
||||
return c.outputJSON(health)
|
||||
}
|
||||
|
||||
fmt.Printf("Server Status: %v\n", health["status"])
|
||||
if ts, ok := health["timestamp"]; ok {
|
||||
fmt.Printf("Timestamp: %v\n", ts)
|
||||
}
|
||||
|
||||
// Try to fetch summary stats
|
||||
statsResp, err := c.do("GET", "/api/v1/stats/summary", nil, nil)
|
||||
if err == nil {
|
||||
var stats map[string]interface{}
|
||||
if err := json.Unmarshal(statsResp, &stats); err == nil {
|
||||
fmt.Println("\nSummary Stats:")
|
||||
if data, ok := stats["data"].(map[string]interface{}); ok {
|
||||
for k, v := range data {
|
||||
fmt.Printf(" %s: %v\n", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportCertificates bulk imports certificates from PEM files.
|
||||
func (c *Client) ImportCertificates(files []string) error {
|
||||
var imported, failed int
|
||||
|
||||
for _, filePath := range files {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to read %s: %v\n", filePath, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
certs, err := parsePEMCertificates(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to parse %s: %v\n", filePath, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
for i, cert := range certs {
|
||||
total := len(certs)
|
||||
fmt.Printf("Importing %d/%d certificates from %s...\r", i+1, total, filepath.Base(filePath))
|
||||
|
||||
req := map[string]interface{}{
|
||||
"common_name": cert.Subject.CommonName,
|
||||
"sans": cert.DNSNames,
|
||||
"issuer_id": "iss-local",
|
||||
"environment": "imported",
|
||||
"status": "Active",
|
||||
}
|
||||
|
||||
if cert.SerialNumber != nil {
|
||||
req["serial_number"] = fmt.Sprintf("%x", cert.SerialNumber)
|
||||
}
|
||||
|
||||
_, err := c.do("POST", "/api/v1/certificates", nil, req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to import cert %s: %v\n", cert.Subject.CommonName, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
fmt.Printf("Importing %d/%d certificates from %s... done\n", len(certs), len(certs), filepath.Base(filePath))
|
||||
}
|
||||
|
||||
fmt.Printf("\nImport Summary:\n")
|
||||
fmt.Printf(" Successfully imported: %d\n", imported)
|
||||
fmt.Printf(" Failed: %d\n", failed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Output formatting functions
|
||||
|
||||
func (c *Client) outputJSON(data interface{}) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(data)
|
||||
}
|
||||
|
||||
func (c *Client) outputCertificatesTable(certs []map[string]interface{}, total int) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tCOMMON NAME\tSTATUS\tEXPIRES\tISSUER")
|
||||
|
||||
for _, cert := range certs {
|
||||
id := getString(cert, "id")
|
||||
cn := getString(cert, "common_name")
|
||||
status := getString(cert, "status")
|
||||
issuer := getString(cert, "issuer_id")
|
||||
|
||||
expiresStr := ""
|
||||
if expires, ok := cert["expires_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, expires); err == nil {
|
||||
expiresStr = t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", id, cn, status, expiresStr, issuer)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
fmt.Printf("\nTotal: %d\n", total)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) outputCertificateDetail(cert map[string]interface{}) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
|
||||
fmt.Fprintf(w, "ID:\t%v\n", getString(cert, "id"))
|
||||
fmt.Fprintf(w, "Name:\t%v\n", getString(cert, "name"))
|
||||
fmt.Fprintf(w, "Common Name:\t%v\n", getString(cert, "common_name"))
|
||||
fmt.Fprintf(w, "Status:\t%v\n", getString(cert, "status"))
|
||||
fmt.Fprintf(w, "Issuer ID:\t%v\n", getString(cert, "issuer_id"))
|
||||
fmt.Fprintf(w, "Owner ID:\t%v\n", getString(cert, "owner_id"))
|
||||
|
||||
if expires, ok := cert["expires_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, expires); err == nil {
|
||||
fmt.Fprintf(w, "Expires At:\t%s\n", t.Format("2006-01-02 15:04:05 MST"))
|
||||
}
|
||||
}
|
||||
|
||||
if sans, ok := cert["sans"].([]interface{}); ok && len(sans) > 0 {
|
||||
fmt.Fprintf(w, "SANs:\t%v\n", sans)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) outputAgentsTable(agents []map[string]interface{}, total int) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tHOSTNAME\tSTATUS\tOS\tARCHITECTURE\tIP ADDRESS")
|
||||
|
||||
for _, agent := range agents {
|
||||
id := getString(agent, "id")
|
||||
hostname := getString(agent, "hostname")
|
||||
status := getString(agent, "status")
|
||||
os := getString(agent, "os")
|
||||
arch := getString(agent, "architecture")
|
||||
ip := getString(agent, "ip_address")
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", id, hostname, status, os, arch, ip)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
fmt.Printf("\nTotal: %d\n", total)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) outputAgentDetail(agent map[string]interface{}) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
|
||||
fmt.Fprintf(w, "ID:\t%v\n", getString(agent, "id"))
|
||||
fmt.Fprintf(w, "Name:\t%v\n", getString(agent, "name"))
|
||||
fmt.Fprintf(w, "Hostname:\t%v\n", getString(agent, "hostname"))
|
||||
fmt.Fprintf(w, "Status:\t%v\n", getString(agent, "status"))
|
||||
fmt.Fprintf(w, "OS:\t%v\n", getString(agent, "os"))
|
||||
fmt.Fprintf(w, "Architecture:\t%v\n", getString(agent, "architecture"))
|
||||
fmt.Fprintf(w, "IP Address:\t%v\n", getString(agent, "ip_address"))
|
||||
fmt.Fprintf(w, "Version:\t%v\n", getString(agent, "version"))
|
||||
|
||||
if lastHB, ok := agent["last_heartbeat_at"].(string); ok && lastHB != "" {
|
||||
if t, err := time.Parse(time.RFC3339, lastHB); err == nil {
|
||||
fmt.Fprintf(w, "Last Heartbeat:\t%s\n", t.Format("2006-01-02 15:04:05 MST"))
|
||||
}
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) outputJobsTable(jobs []map[string]interface{}, total int) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tTYPE\tCERTIFICATE\tSTATUS\tATTEMPTS")
|
||||
|
||||
for _, job := range jobs {
|
||||
id := getString(job, "id")
|
||||
jobType := getString(job, "type")
|
||||
certID := getString(job, "certificate_id")
|
||||
status := getString(job, "status")
|
||||
attempts := getInt(job, "attempts")
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", id, jobType, certID, status, attempts)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
fmt.Printf("\nTotal: %d\n", total)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) outputJobDetail(job map[string]interface{}) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
|
||||
fmt.Fprintf(w, "ID:\t%v\n", getString(job, "id"))
|
||||
fmt.Fprintf(w, "Type:\t%v\n", getString(job, "type"))
|
||||
fmt.Fprintf(w, "Certificate ID:\t%v\n", getString(job, "certificate_id"))
|
||||
fmt.Fprintf(w, "Status:\t%v\n", getString(job, "status"))
|
||||
fmt.Fprintf(w, "Attempts:\t%d\n", getInt(job, "attempts"))
|
||||
fmt.Fprintf(w, "Max Attempts:\t%d\n", getInt(job, "max_attempts"))
|
||||
|
||||
if lastErr, ok := job["last_error"].(string); ok && lastErr != "" {
|
||||
fmt.Fprintf(w, "Last Error:\t%s\n", lastErr)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parsePEMCertificates parses PEM-encoded certificates from data.
|
||||
func parsePEMCertificates(data []byte) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
|
||||
for len(data) > 0 {
|
||||
block, rest := pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
data = rest
|
||||
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found in PEM data")
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_ListCertificates(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/api/v1/certificates" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{
|
||||
"id": "mc-1",
|
||||
"common_name": "example.com",
|
||||
"status": "Active",
|
||||
"expires_at": "2025-12-31T00:00:00Z",
|
||||
"issuer_id": "iss-local",
|
||||
},
|
||||
},
|
||||
"total": 1,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "table")
|
||||
err := client.ListCertificates([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListCertificates failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCertificate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/api/v1/certificates/mc-1" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "mc-1",
|
||||
"common_name": "example.com",
|
||||
"status": "Active",
|
||||
"expires_at": "2025-12-31T00:00:00Z",
|
||||
"issuer_id": "iss-local",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "json")
|
||||
err := client.GetCertificate("mc-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCertificate failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RenewCertificate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates/mc-1/renew" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"job_id": "job-123",
|
||||
"status": "Pending",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "table")
|
||||
err := client.RenewCertificate("mc-1")
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RevokeCertificate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates/mc-1/revoke" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "revoked",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "table")
|
||||
err := client.RevokeCertificate("mc-1", "cessationOfOperation")
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ListAgents(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/api/v1/agents" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{
|
||||
"id": "ag-1",
|
||||
"hostname": "agent1.example.com",
|
||||
"status": "Online",
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"ip_address": "192.168.1.1",
|
||||
},
|
||||
},
|
||||
"total": 1,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "table")
|
||||
err := client.ListAgents([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgents failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetAgent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/api/v1/agents/ag-1" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "ag-1",
|
||||
"hostname": "agent1.example.com",
|
||||
"status": "Online",
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"ip_address": "192.168.1.1",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "json")
|
||||
err := client.GetAgent("ag-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAgent failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ListJobs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/api/v1/jobs" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{
|
||||
"id": "job-1",
|
||||
"type": "Renewal",
|
||||
"certificate_id": "mc-1",
|
||||
"status": "Completed",
|
||||
"attempts": 1,
|
||||
"max_attempts": 3,
|
||||
},
|
||||
},
|
||||
"total": 1,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "table")
|
||||
err := client.ListJobs([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListJobs failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetJob(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/api/v1/jobs/job-1" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "job-1",
|
||||
"type": "Renewal",
|
||||
"certificate_id": "mc-1",
|
||||
"status": "Completed",
|
||||
"attempts": 1,
|
||||
"max_attempts": 3,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "json")
|
||||
err := client.GetJob("job-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetJob failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CancelJob(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/api/v1/jobs/job-1/cancel" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "table")
|
||||
err := client.CancelJob("job-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CancelJob failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.URL.Path == "/api/v1/health" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else if r.URL.Path == "/api/v1/stats/summary" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"total_certificates": 10,
|
||||
"total_agents": 5,
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "table")
|
||||
err := client.GetStatus()
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePEMCertificates(t *testing.T) {
|
||||
// Generate a self-signed test certificate
|
||||
cert := generateTestCert()
|
||||
|
||||
// Encode it to PEM
|
||||
pemBlock := &pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: cert.Raw,
|
||||
}
|
||||
pemData := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
// Parse it back
|
||||
certs, err := parsePEMCertificates(pemData)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePEMCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
if len(certs) != 1 {
|
||||
t.Fatalf("expected 1 certificate, got %d", len(certs))
|
||||
}
|
||||
|
||||
if certs[0].Subject.CommonName != "test.example.com" {
|
||||
t.Fatalf("expected CommonName 'test.example.com', got %s", certs[0].Subject.CommonName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePEMCertificates_Multiple(t *testing.T) {
|
||||
// Generate two test certificates
|
||||
cert1 := generateTestCert()
|
||||
cert2 := generateTestCert()
|
||||
|
||||
// Encode both to PEM
|
||||
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
||||
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
||||
|
||||
pemData := append(pem.EncodeToMemory(block1), pem.EncodeToMemory(block2)...)
|
||||
|
||||
// Parse them back
|
||||
certs, err := parsePEMCertificates(pemData)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePEMCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
if len(certs) != 2 {
|
||||
t.Fatalf("expected 2 certificates, got %d", len(certs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePEMCertificates_NoCertificates(t *testing.T) {
|
||||
pemData := []byte("no certificates here")
|
||||
|
||||
_, err := parsePEMCertificates(pemData)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty PEM data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AuthHeader(t *testing.T) {
|
||||
var authHeader string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"data": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testkey123", "json")
|
||||
client.do("GET", "/api/v1/certificates", nil, nil)
|
||||
|
||||
if authHeader != "Bearer testkey123" {
|
||||
t.Fatalf("expected 'Bearer testkey123', got '%s'", authHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to generate a test certificate
|
||||
func generateTestCert() *x509.Certificate {
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "test.example.com",
|
||||
},
|
||||
NotBefore: now,
|
||||
NotAfter: now.Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"test.example.com", "*.test.example.com"},
|
||||
}
|
||||
|
||||
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
certBytes, _ := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
cert, _ := x509.ParseCertificate(certBytes)
|
||||
|
||||
return cert
|
||||
}
|
||||
@@ -11,14 +11,30 @@ import (
|
||||
// Config represents the complete application configuration.
|
||||
// All configuration values are read from environment variables with CERTCTL_ prefix.
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Scheduler SchedulerConfig
|
||||
Log LogConfig
|
||||
Auth AuthConfig
|
||||
RateLimit RateLimitConfig
|
||||
CORS CORSConfig
|
||||
Keygen KeygenConfig
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Scheduler SchedulerConfig
|
||||
Log LogConfig
|
||||
Auth AuthConfig
|
||||
RateLimit RateLimitConfig
|
||||
CORS CORSConfig
|
||||
Keygen KeygenConfig
|
||||
CA CAConfig
|
||||
Notifiers NotifierConfig
|
||||
NetworkScan NetworkScanConfig
|
||||
}
|
||||
|
||||
// NotifierConfig contains configuration for notification connectors.
|
||||
// Each notifier is enabled by setting its required env var (webhook URL or API key).
|
||||
type NotifierConfig struct {
|
||||
SlackWebhookURL string
|
||||
SlackChannel string
|
||||
SlackUsername string
|
||||
TeamsWebhookURL string
|
||||
PagerDutyRoutingKey string
|
||||
PagerDutySeverity string
|
||||
OpsGenieAPIKey string
|
||||
OpsGeniePriority string
|
||||
}
|
||||
|
||||
// KeygenConfig controls where private keys are generated.
|
||||
@@ -29,6 +45,48 @@ type KeygenConfig struct {
|
||||
Mode string
|
||||
}
|
||||
|
||||
// CAConfig controls the Local CA's operating mode.
|
||||
type CAConfig struct {
|
||||
// CertPath is the path to a PEM-encoded CA certificate for sub-CA mode.
|
||||
// When set with KeyPath, the Local CA loads this cert instead of generating a self-signed root.
|
||||
CertPath string
|
||||
|
||||
// KeyPath is the path to a PEM-encoded CA private key for sub-CA mode.
|
||||
// Supports RSA, ECDSA, and PKCS#8 encoded keys.
|
||||
KeyPath string
|
||||
}
|
||||
|
||||
// StepCAConfig contains step-ca issuer connector configuration.
|
||||
type StepCAConfig struct {
|
||||
URL string
|
||||
ProvisionerName string
|
||||
ProvisionerKeyPath string
|
||||
ProvisionerPassword string
|
||||
}
|
||||
|
||||
// ACMEConfig contains ACME issuer connector configuration.
|
||||
type ACMEConfig struct {
|
||||
DirectoryURL string
|
||||
Email string
|
||||
ChallengeType string // "http-01" (default) or "dns-01"
|
||||
DNSPresentScript string
|
||||
DNSCleanUpScript string
|
||||
}
|
||||
|
||||
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
||||
type OpenSSLConfig struct {
|
||||
SignScript string
|
||||
RevokeScript string
|
||||
CRLScript string
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
// NetworkScanConfig controls the server-side active TLS scanner.
|
||||
type NetworkScanConfig struct {
|
||||
Enabled bool // Enable network scanning (default false)
|
||||
ScanInterval time.Duration // How often to run network scans (default 6h)
|
||||
}
|
||||
|
||||
// ServerConfig contains HTTP server configuration.
|
||||
type ServerConfig struct {
|
||||
Host string
|
||||
@@ -113,6 +171,24 @@ func Load() (*Config, error) {
|
||||
Keygen: KeygenConfig{
|
||||
Mode: getEnv("CERTCTL_KEYGEN_MODE", "agent"),
|
||||
},
|
||||
CA: CAConfig{
|
||||
CertPath: getEnv("CERTCTL_CA_CERT_PATH", ""),
|
||||
KeyPath: getEnv("CERTCTL_CA_KEY_PATH", ""),
|
||||
},
|
||||
Notifiers: NotifierConfig{
|
||||
SlackWebhookURL: getEnv("CERTCTL_SLACK_WEBHOOK_URL", ""),
|
||||
SlackChannel: getEnv("CERTCTL_SLACK_CHANNEL", ""),
|
||||
SlackUsername: getEnv("CERTCTL_SLACK_USERNAME", "certctl"),
|
||||
TeamsWebhookURL: getEnv("CERTCTL_TEAMS_WEBHOOK_URL", ""),
|
||||
PagerDutyRoutingKey: getEnv("CERTCTL_PAGERDUTY_ROUTING_KEY", ""),
|
||||
PagerDutySeverity: getEnv("CERTCTL_PAGERDUTY_SEVERITY", "warning"),
|
||||
OpsGenieAPIKey: getEnv("CERTCTL_OPSGENIE_API_KEY", ""),
|
||||
OpsGeniePriority: getEnv("CERTCTL_OPSGENIE_PRIORITY", "P3"),
|
||||
},
|
||||
NetworkScan: NetworkScanConfig{
|
||||
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
||||
ScanInterval: getEnvDuration("CERTCTL_NETWORK_SCAN_INTERVAL", 6*time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
|
||||
@@ -27,6 +27,22 @@ type Config struct {
|
||||
EABKid string `json:"eab_kid,omitempty"` // External Account Binding Key ID (for some CAs)
|
||||
EABHmac string `json:"eab_hmac,omitempty"` // External Account Binding HMAC Key
|
||||
HTTPPort int `json:"http_port,omitempty"` // Port for HTTP-01 challenge server (default: 80)
|
||||
|
||||
// ChallengeType selects the ACME challenge method: "http-01" (default) or "dns-01".
|
||||
// DNS-01 is required for wildcard certificates (*.example.com).
|
||||
ChallengeType string `json:"challenge_type,omitempty"`
|
||||
|
||||
// DNSPresentScript is the path to a script that creates DNS TXT records (dns-01 only).
|
||||
// The script receives CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE, CERTCTL_DNS_TOKEN.
|
||||
DNSPresentScript string `json:"dns_present_script,omitempty"`
|
||||
|
||||
// DNSCleanUpScript is the path to a script that removes DNS TXT records (dns-01 only).
|
||||
// Optional — if not set, records are not cleaned up automatically.
|
||||
DNSCleanUpScript string `json:"dns_cleanup_script,omitempty"`
|
||||
|
||||
// DNSPropagationWait is how long to wait (in seconds) after creating the TXT record
|
||||
// before telling the CA to validate. Defaults to 30 seconds.
|
||||
DNSPropagationWait int `json:"dns_propagation_wait,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||
@@ -46,18 +62,40 @@ type Connector struct {
|
||||
// HTTP-01 challenge solver state
|
||||
challengeMu sync.RWMutex
|
||||
challengeTokens map[string]string // token → key authorization
|
||||
|
||||
// DNS-01 challenge solver (nil if using HTTP-01)
|
||||
dnsSolver DNSSolver
|
||||
}
|
||||
|
||||
// New creates a new ACME connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil && config.HTTPPort == 0 {
|
||||
config.HTTPPort = 80
|
||||
if config != nil {
|
||||
if config.HTTPPort == 0 {
|
||||
config.HTTPPort = 80
|
||||
}
|
||||
if config.ChallengeType == "" {
|
||||
config.ChallengeType = "http-01"
|
||||
}
|
||||
if config.DNSPropagationWait == 0 {
|
||||
config.DNSPropagationWait = 30
|
||||
}
|
||||
}
|
||||
return &Connector{
|
||||
|
||||
c := &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
challengeTokens: make(map[string]string),
|
||||
}
|
||||
|
||||
// Initialize DNS solver if dns-01 challenge type is configured
|
||||
if config != nil && config.ChallengeType == "dns-01" && config.DNSPresentScript != "" {
|
||||
c.dnsSolver = NewScriptDNSSolver(config.DNSPresentScript, config.DNSCleanUpScript, logger)
|
||||
logger.Info("DNS-01 challenge solver configured",
|
||||
"present_script", config.DNSPresentScript,
|
||||
"cleanup_script", config.DNSCleanUpScript)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the ACME directory URL is reachable and valid.
|
||||
@@ -98,8 +136,33 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
cfg.HTTPPort = 80
|
||||
}
|
||||
|
||||
if cfg.ChallengeType == "" {
|
||||
cfg.ChallengeType = "http-01"
|
||||
}
|
||||
|
||||
// Validate challenge type
|
||||
if cfg.ChallengeType != "http-01" && cfg.ChallengeType != "dns-01" {
|
||||
return fmt.Errorf("invalid challenge_type: %s (must be http-01 or dns-01)", cfg.ChallengeType)
|
||||
}
|
||||
|
||||
// DNS-01 requires a present script
|
||||
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript == "" {
|
||||
return fmt.Errorf("dns_present_script is required for dns-01 challenge type")
|
||||
}
|
||||
|
||||
if cfg.DNSPropagationWait == 0 {
|
||||
cfg.DNSPropagationWait = 30
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("ACME configuration validated")
|
||||
|
||||
// Re-initialize DNS solver if switching to dns-01
|
||||
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript != "" {
|
||||
c.dnsSolver = NewScriptDNSSolver(cfg.DNSPresentScript, cfg.DNSCleanUpScript, c.logger)
|
||||
}
|
||||
|
||||
c.logger.Info("ACME configuration validated",
|
||||
"challenge_type", cfg.ChallengeType)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -271,8 +334,17 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// solveAuthorizations processes all authorization URLs and solves their HTTP-01 challenges.
|
||||
// solveAuthorizations processes all authorization URLs and solves their challenges.
|
||||
// Supports both HTTP-01 and DNS-01 challenge types based on configuration.
|
||||
func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string) error {
|
||||
if c.config.ChallengeType == "dns-01" {
|
||||
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
||||
}
|
||||
return c.solveAuthorizationsHTTP01(ctx, authzURLs)
|
||||
}
|
||||
|
||||
// solveAuthorizationsHTTP01 solves challenges using the HTTP-01 method.
|
||||
func (c *Connector) solveAuthorizationsHTTP01(ctx context.Context, authzURLs []string) error {
|
||||
// Start the challenge server
|
||||
srv, err := c.startChallengeServer()
|
||||
if err != nil {
|
||||
@@ -344,6 +416,87 @@ func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// solveAuthorizationsDNS01 solves challenges using the DNS-01 method.
|
||||
// DNS-01 is required for wildcard certificates (*.example.com) and works
|
||||
// when the server is not publicly reachable on port 80.
|
||||
func (c *Connector) solveAuthorizationsDNS01(ctx context.Context, authzURLs []string) error {
|
||||
if c.dnsSolver == nil {
|
||||
return fmt.Errorf("DNS-01 challenge type configured but no DNS solver available")
|
||||
}
|
||||
|
||||
for _, authzURL := range authzURLs {
|
||||
authz, err := c.client.GetAuthorization(ctx, authzURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
|
||||
}
|
||||
|
||||
if authz.Status == acme.StatusValid {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the DNS-01 challenge
|
||||
var dnsChallenge *acme.Challenge
|
||||
for _, ch := range authz.Challenges {
|
||||
if ch.Type == "dns-01" {
|
||||
dnsChallenge = ch
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dnsChallenge == nil {
|
||||
return fmt.Errorf("no DNS-01 challenge found for %s", authz.Identifier.Value)
|
||||
}
|
||||
|
||||
// Compute the DNS-01 key authorization (base64url-encoded SHA-256 digest)
|
||||
keyAuth, err := c.client.DNS01ChallengeRecord(dnsChallenge.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute DNS-01 key authorization: %w", err)
|
||||
}
|
||||
|
||||
domain := authz.Identifier.Value
|
||||
|
||||
c.logger.Info("presenting DNS-01 challenge",
|
||||
"domain", domain,
|
||||
"token", dnsChallenge.Token)
|
||||
|
||||
// Create the DNS TXT record
|
||||
if err := c.dnsSolver.Present(ctx, domain, dnsChallenge.Token, keyAuth); err != nil {
|
||||
return fmt.Errorf("failed to present DNS record for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
// Wait for DNS propagation
|
||||
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
|
||||
c.logger.Info("waiting for DNS propagation",
|
||||
"domain", domain,
|
||||
"wait_seconds", c.config.DNSPropagationWait)
|
||||
time.Sleep(propagationWait)
|
||||
|
||||
// Tell the CA we're ready
|
||||
if _, err := c.client.Accept(ctx, dnsChallenge); err != nil {
|
||||
// Clean up even on failure
|
||||
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
|
||||
return fmt.Errorf("failed to accept DNS-01 challenge: %w", err)
|
||||
}
|
||||
|
||||
// Wait for authorization to be valid
|
||||
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
|
||||
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
|
||||
return fmt.Errorf("DNS-01 authorization failed for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
c.logger.Info("DNS-01 authorization validated", "domain", domain)
|
||||
|
||||
// Clean up the DNS record
|
||||
if err := c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth); err != nil {
|
||||
c.logger.Warn("failed to clean up DNS record (non-fatal)",
|
||||
"domain", domain,
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startChallengeServer starts an HTTP server that responds to ACME HTTP-01 challenges.
|
||||
// It listens on the configured HTTP port and serves challenge tokens at
|
||||
// /.well-known/acme-challenge/{token}.
|
||||
@@ -456,3 +609,13 @@ func parseDERChain(derChain [][]byte) (certPEM string, chainPEM string, serial s
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported by ACME issuers.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("ACME issuers do not support CRL generation")
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported by ACME issuers.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("ACME issuers do not support OCSP response signing")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DNSSolver defines the interface for DNS-01 challenge provisioning.
|
||||
// Implementations create and clean up DNS TXT records for ACME validation.
|
||||
type DNSSolver interface {
|
||||
// Present creates a DNS TXT record for the given domain with the given value.
|
||||
// The FQDN will be _acme-challenge.<domain>.
|
||||
Present(ctx context.Context, domain, token, keyAuth string) error
|
||||
|
||||
// CleanUp removes the DNS TXT record created by Present.
|
||||
CleanUp(ctx context.Context, domain, token, keyAuth string) error
|
||||
}
|
||||
|
||||
// ScriptDNSSolver implements DNSSolver by executing external scripts.
|
||||
// This provides maximum flexibility: users supply their own scripts for
|
||||
// whatever DNS provider they use (Cloudflare, Route53, Azure DNS, etc.).
|
||||
//
|
||||
// The scripts receive these environment variables:
|
||||
//
|
||||
// CERTCTL_DNS_DOMAIN — the domain being validated (e.g., "example.com")
|
||||
// CERTCTL_DNS_FQDN — the full record name (e.g., "_acme-challenge.example.com")
|
||||
// CERTCTL_DNS_VALUE — the TXT record value (key authorization digest)
|
||||
// CERTCTL_DNS_TOKEN — the ACME challenge token
|
||||
//
|
||||
// The present script must create the TXT record and exit 0.
|
||||
// The cleanup script must remove the TXT record and exit 0.
|
||||
type ScriptDNSSolver struct {
|
||||
PresentScript string // Path to script that creates the TXT record
|
||||
CleanUpScript string // Path to script that removes the TXT record
|
||||
Timeout time.Duration
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewScriptDNSSolver creates a script-based DNS solver.
|
||||
func NewScriptDNSSolver(presentScript, cleanUpScript string, logger *slog.Logger) *ScriptDNSSolver {
|
||||
return &ScriptDNSSolver{
|
||||
PresentScript: presentScript,
|
||||
CleanUpScript: cleanUpScript,
|
||||
Timeout: 120 * time.Second,
|
||||
Logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Present executes the present script to create a DNS TXT record.
|
||||
func (s *ScriptDNSSolver) Present(ctx context.Context, domain, token, keyAuth string) error {
|
||||
if s.PresentScript == "" {
|
||||
return fmt.Errorf("DNS present script not configured")
|
||||
}
|
||||
|
||||
fqdn := "_acme-challenge." + domain
|
||||
|
||||
s.Logger.Info("creating DNS TXT record via script",
|
||||
"domain", domain,
|
||||
"fqdn", fqdn,
|
||||
"script", s.PresentScript)
|
||||
|
||||
return s.runScript(ctx, s.PresentScript, domain, fqdn, token, keyAuth)
|
||||
}
|
||||
|
||||
// CleanUp executes the cleanup script to remove a DNS TXT record.
|
||||
func (s *ScriptDNSSolver) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
|
||||
if s.CleanUpScript == "" {
|
||||
s.Logger.Warn("DNS cleanup script not configured, skipping cleanup", "domain", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
fqdn := "_acme-challenge." + domain
|
||||
|
||||
s.Logger.Info("removing DNS TXT record via script",
|
||||
"domain", domain,
|
||||
"fqdn", fqdn,
|
||||
"script", s.CleanUpScript)
|
||||
|
||||
return s.runScript(ctx, s.CleanUpScript, domain, fqdn, token, keyAuth)
|
||||
}
|
||||
|
||||
// runScript executes a DNS hook script with the appropriate environment variables.
|
||||
func (s *ScriptDNSSolver) runScript(ctx context.Context, script, domain, fqdn, token, keyAuth string) error {
|
||||
timeout := s.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 120 * time.Second
|
||||
}
|
||||
|
||||
execCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(execCtx, script)
|
||||
cmd.Env = append(cmd.Environ(),
|
||||
"CERTCTL_DNS_DOMAIN="+domain,
|
||||
"CERTCTL_DNS_FQDN="+fqdn,
|
||||
"CERTCTL_DNS_VALUE="+keyAuth,
|
||||
"CERTCTL_DNS_TOKEN="+token,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS script %s failed: %w (output: %s)", script, err, string(output))
|
||||
}
|
||||
|
||||
s.Logger.Debug("DNS script completed", "script", script, "output", string(output))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package acme_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||
)
|
||||
|
||||
func TestScriptDNSSolver(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Present_Success", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile := filepath.Join(tmpDir, "dns-record.txt")
|
||||
|
||||
// Create a script that writes the DNS record to a file
|
||||
scriptPath := filepath.Join(tmpDir, "present.sh")
|
||||
script := `#!/bin/sh
|
||||
echo "DOMAIN=$CERTCTL_DNS_DOMAIN FQDN=$CERTCTL_DNS_FQDN VALUE=$CERTCTL_DNS_VALUE TOKEN=$CERTCTL_DNS_TOKEN" > ` + outputFile + `
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("Failed to create script: %v", err)
|
||||
}
|
||||
|
||||
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||
err := solver.Present(ctx, "example.com", "test-token", "test-key-auth")
|
||||
if err != nil {
|
||||
t.Fatalf("Present failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the script was executed with correct env vars
|
||||
output, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
expected := "DOMAIN=example.com FQDN=_acme-challenge.example.com VALUE=test-key-auth TOKEN=test-token\n"
|
||||
if string(output) != expected {
|
||||
t.Errorf("Script output mismatch:\ngot: %q\nwant: %q", string(output), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Present_ScriptFailure", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "fail.sh")
|
||||
script := `#!/bin/sh
|
||||
echo "error: something went wrong" >&2
|
||||
exit 1
|
||||
`
|
||||
os.WriteFile(scriptPath, []byte(script), 0755)
|
||||
|
||||
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||
err := solver.Present(ctx, "example.com", "token", "keyauth")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error from failing script")
|
||||
}
|
||||
t.Logf("Correctly got error: %v", err)
|
||||
})
|
||||
|
||||
t.Run("Present_NoScript", func(t *testing.T) {
|
||||
solver := acmeissuer.NewScriptDNSSolver("", "", logger)
|
||||
err := solver.Present(ctx, "example.com", "token", "keyauth")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when no script is configured")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CleanUp_Success", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile := filepath.Join(tmpDir, "cleanup.txt")
|
||||
|
||||
scriptPath := filepath.Join(tmpDir, "cleanup.sh")
|
||||
script := `#!/bin/sh
|
||||
echo "cleaned $CERTCTL_DNS_FQDN" > ` + outputFile + `
|
||||
`
|
||||
os.WriteFile(scriptPath, []byte(script), 0755)
|
||||
|
||||
solver := acmeissuer.NewScriptDNSSolver("", scriptPath, logger)
|
||||
err := solver.CleanUp(ctx, "example.com", "token", "keyauth")
|
||||
if err != nil {
|
||||
t.Fatalf("CleanUp failed: %v", err)
|
||||
}
|
||||
|
||||
output, _ := os.ReadFile(outputFile)
|
||||
expected := "cleaned _acme-challenge.example.com\n"
|
||||
if string(output) != expected {
|
||||
t.Errorf("Cleanup output mismatch: got %q, want %q", string(output), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CleanUp_NoScript_Noop", func(t *testing.T) {
|
||||
solver := acmeissuer.NewScriptDNSSolver("", "", logger)
|
||||
// Should not error — cleanup without a script is a no-op
|
||||
err := solver.CleanUp(ctx, "example.com", "token", "keyauth")
|
||||
if err != nil {
|
||||
t.Fatalf("CleanUp without script should not error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Present_NonexistentScript", func(t *testing.T) {
|
||||
solver := acmeissuer.NewScriptDNSSolver("/nonexistent/script.sh", "", logger)
|
||||
err := solver.Present(ctx, "example.com", "token", "keyauth")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nonexistent script")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package issuer
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,14 @@ type Connector interface {
|
||||
|
||||
// GetOrderStatus retrieves the status of an issuance or renewal order.
|
||||
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
|
||||
|
||||
// GenerateCRL generates a DER-encoded X.509 CRL signed by this issuer.
|
||||
// Returns nil if the issuer does not support CRL generation (e.g., ACME).
|
||||
GenerateCRL(ctx context.Context, revokedCerts []RevokedCertEntry) ([]byte, error)
|
||||
|
||||
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
||||
// Returns nil if the issuer does not support OCSP (e.g., ACME).
|
||||
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||
}
|
||||
|
||||
// IssuanceRequest contains the parameters for issuing a new certificate.
|
||||
@@ -67,3 +76,20 @@ type OrderStatus struct {
|
||||
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RevokedCertEntry represents a revoked certificate for CRL generation.
|
||||
type RevokedCertEntry struct {
|
||||
SerialNumber *big.Int
|
||||
RevokedAt time.Time
|
||||
ReasonCode int
|
||||
}
|
||||
|
||||
// OCSPSignRequest contains the parameters for signing an OCSP response.
|
||||
type OCSPSignRequest struct {
|
||||
CertSerial *big.Int
|
||||
CertStatus int // 0=good, 1=revoked, 2=unknown
|
||||
RevokedAt time.Time
|
||||
RevocationReason int
|
||||
ThisUpdate time.Time
|
||||
NextUpdate time.Time
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
@@ -12,50 +15,70 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ocsp"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the local CA issuer connector configuration.
|
||||
type Config struct {
|
||||
// CACommonName is the CN for the self-signed CA certificate.
|
||||
// Defaults to "CertCtl Local CA".
|
||||
// Defaults to "CertCtl Local CA". Ignored in sub-CA mode.
|
||||
CACommonName string `json:"ca_common_name,omitempty"`
|
||||
|
||||
// ValidityDays is the number of days a certificate is valid.
|
||||
// Defaults to 90.
|
||||
ValidityDays int `json:"validity_days,omitempty"`
|
||||
|
||||
// CACertPath is the path to a PEM-encoded CA certificate file.
|
||||
// When set along with CAKeyPath, the connector operates in sub-CA mode:
|
||||
// it loads the CA cert+key from disk instead of generating a self-signed root.
|
||||
// The loaded CA cert should be signed by an upstream CA (e.g., ADCS).
|
||||
// All issued certificates will chain to the upstream root.
|
||||
CACertPath string `json:"ca_cert_path,omitempty"`
|
||||
|
||||
// CAKeyPath is the path to a PEM-encoded CA private key file (RSA or ECDSA).
|
||||
// Required when CACertPath is set.
|
||||
CAKeyPath string `json:"ca_key_path,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for local self-signed certificate generation.
|
||||
// Connector implements the issuer.Connector interface for local certificate generation.
|
||||
//
|
||||
// This connector generates self-signed certificates using an in-memory CA. It is designed for
|
||||
// development, testing, and demo purposes only and should NOT be used in production.
|
||||
// It supports two modes:
|
||||
//
|
||||
// On first use, it generates a self-signed CA root certificate and stores it in memory.
|
||||
// All issued certificates are signed by this local CA.
|
||||
// Self-signed mode (default):
|
||||
// - Generates an ephemeral self-signed CA root on first use
|
||||
// - Designed for development, testing, and demo purposes
|
||||
// - CA certificate is lost on service restart
|
||||
//
|
||||
// Sub-CA mode (when CACertPath + CAKeyPath are set):
|
||||
// - Loads a pre-signed CA cert+key from disk
|
||||
// - The CA cert should be signed by an upstream CA (e.g., ADCS, enterprise root)
|
||||
// - All issued certificates chain to the upstream root
|
||||
// - Suitable for production when the upstream CA is trusted
|
||||
//
|
||||
// Features:
|
||||
// - Instant certificate issuance (no external CA required)
|
||||
// - Full lifecycle demo support (issue, renew, revoke)
|
||||
// - In-memory certificate storage
|
||||
// - Full lifecycle support (issue, renew, revoke)
|
||||
// - Proper X.509 certificate generation with SANs, serial numbers, and validity periods
|
||||
//
|
||||
// Limitations:
|
||||
// - Not suitable for production use
|
||||
// - Certificates are not trusted by default browsers/systems
|
||||
// - No actual revocation checking (revocation is tracked in memory only)
|
||||
// - CA certificate is ephemeral and lost on service restart
|
||||
// - Revocation is tracked in memory only (not persistent)
|
||||
// - In self-signed mode, CA is ephemeral
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
mu sync.RWMutex
|
||||
caKey *rsa.PrivateKey
|
||||
caKey crypto.Signer // RSA or ECDSA private key
|
||||
caCert *x509.Certificate
|
||||
caCertPEM string
|
||||
revokedMap map[string]bool // serial -> revoked status
|
||||
subCA bool // true when loaded from disk (sub-CA mode)
|
||||
revokedMap map[string]bool // serial -> revoked status
|
||||
}
|
||||
|
||||
// New creates a new local CA connector with the given configuration and logger.
|
||||
@@ -80,7 +103,6 @@ func New(config *Config, logger *slog.Logger) *Connector {
|
||||
}
|
||||
|
||||
// ValidateConfig validates the local CA configuration.
|
||||
// This always succeeds as the local CA has minimal requirements.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
@@ -91,12 +113,32 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
return fmt.Errorf("validity_days must be at least 1")
|
||||
}
|
||||
|
||||
// Sub-CA mode: both paths must be set or neither
|
||||
if (cfg.CACertPath != "") != (cfg.CAKeyPath != "") {
|
||||
return fmt.Errorf("ca_cert_path and ca_key_path must both be set for sub-CA mode")
|
||||
}
|
||||
|
||||
// Validate paths exist if set
|
||||
if cfg.CACertPath != "" {
|
||||
if _, err := os.Stat(cfg.CACertPath); err != nil {
|
||||
return fmt.Errorf("ca_cert_path not accessible: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(cfg.CAKeyPath); err != nil {
|
||||
return fmt.Errorf("ca_key_path not accessible: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
if c.config.CACommonName == "" {
|
||||
c.config.CACommonName = "CertCtl Local CA"
|
||||
}
|
||||
|
||||
mode := "self-signed"
|
||||
if cfg.CACertPath != "" {
|
||||
mode = "sub-CA"
|
||||
}
|
||||
c.logger.Info("local CA configuration validated",
|
||||
"mode", mode,
|
||||
"ca_common_name", c.config.CACommonName,
|
||||
"validity_days", c.config.ValidityDays)
|
||||
|
||||
@@ -267,8 +309,8 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
|
||||
}
|
||||
|
||||
// ensureCA initializes the CA certificate and key if not already done.
|
||||
// This is called on first IssueCertificate or RenewCertificate call.
|
||||
// The CA is generated once and reused for all subsequent operations.
|
||||
// In sub-CA mode (CACertPath + CAKeyPath set), loads from disk.
|
||||
// Otherwise, generates an ephemeral self-signed CA.
|
||||
func (c *Connector) ensureCA(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -277,7 +319,81 @@ func (c *Connector) ensureCA(ctx context.Context) error {
|
||||
return nil // CA already initialized
|
||||
}
|
||||
|
||||
c.logger.Info("initializing local CA", "common_name", c.config.CACommonName)
|
||||
if c.config.CACertPath != "" && c.config.CAKeyPath != "" {
|
||||
return c.loadCAFromDisk()
|
||||
}
|
||||
|
||||
return c.generateSelfSignedCA()
|
||||
}
|
||||
|
||||
// loadCAFromDisk loads a CA certificate and private key from PEM files on disk.
|
||||
// This enables sub-CA mode where certctl operates as a subordinate CA under an
|
||||
// enterprise root (e.g., ADCS). The loaded cert should have IsCA=true and
|
||||
// KeyUsageCertSign set by the upstream CA.
|
||||
func (c *Connector) loadCAFromDisk() error {
|
||||
c.logger.Info("loading CA from disk (sub-CA mode)",
|
||||
"cert_path", c.config.CACertPath,
|
||||
"key_path", c.config.CAKeyPath)
|
||||
|
||||
// Load CA certificate
|
||||
certPEM, err := os.ReadFile(c.config.CACertPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CA certificate: %w", err)
|
||||
}
|
||||
|
||||
certBlock, _ := pem.Decode(certPEM)
|
||||
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
||||
return fmt.Errorf("invalid CA certificate PEM (expected CERTIFICATE block)")
|
||||
}
|
||||
|
||||
caCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||
}
|
||||
|
||||
// Validate CA certificate properties
|
||||
if !caCert.IsCA {
|
||||
return fmt.Errorf("loaded certificate is not a CA (BasicConstraints.IsCA=false)")
|
||||
}
|
||||
if caCert.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
return fmt.Errorf("loaded CA certificate does not have KeyUsageCertSign")
|
||||
}
|
||||
|
||||
// Load CA private key (supports RSA and ECDSA)
|
||||
keyPEM, err := os.ReadFile(c.config.CAKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CA private key: %w", err)
|
||||
}
|
||||
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return fmt.Errorf("invalid CA private key PEM")
|
||||
}
|
||||
|
||||
caKey, err := parsePrivateKey(keyBlock)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA private key: %w", err)
|
||||
}
|
||||
|
||||
// Encode CA cert PEM for chain responses
|
||||
c.caKey = caKey
|
||||
c.caCert = caCert
|
||||
c.caCertPEM = string(certPEM)
|
||||
c.subCA = true
|
||||
|
||||
c.logger.Info("sub-CA initialized from disk",
|
||||
"subject", caCert.Subject.CommonName,
|
||||
"issuer", caCert.Issuer.CommonName,
|
||||
"serial", caCert.SerialNumber,
|
||||
"not_after", caCert.NotAfter,
|
||||
"is_self_signed", caCert.Issuer.CommonName == caCert.Subject.CommonName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSelfSignedCA creates an ephemeral self-signed CA for development/demo.
|
||||
func (c *Connector) generateSelfSignedCA() error {
|
||||
c.logger.Info("generating self-signed CA (ephemeral mode)", "common_name", c.config.CACommonName)
|
||||
|
||||
// Generate CA private key
|
||||
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
@@ -319,13 +435,36 @@ func (c *Connector) ensureCA(ctx context.Context) error {
|
||||
c.caCert = caCert
|
||||
c.caCertPEM = string(caCertPEM)
|
||||
|
||||
c.logger.Info("local CA initialized successfully",
|
||||
c.logger.Info("self-signed CA initialized",
|
||||
"serial", caCert.SerialNumber,
|
||||
"not_after", caCert.NotAfter)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePrivateKey parses a PEM block into an RSA or ECDSA private key.
|
||||
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
|
||||
switch block.Type {
|
||||
case "RSA PRIVATE KEY":
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
case "EC PRIVATE KEY":
|
||||
return x509.ParseECPrivateKey(block.Bytes)
|
||||
case "PRIVATE KEY":
|
||||
// PKCS#8 — can contain RSA or ECDSA
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse PKCS#8 key: %w", err)
|
||||
}
|
||||
signer, ok := key.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("PKCS#8 key is not a signing key")
|
||||
}
|
||||
return signer, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported private key type: %s (expected RSA PRIVATE KEY, EC PRIVATE KEY, or PRIVATE KEY)", block.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// generateCertificate creates an X.509 certificate signed by the local CA.
|
||||
// It uses the CSR subject and adds any additional SANs from the request.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string) (*x509.Certificate, string, string, error) {
|
||||
@@ -420,9 +559,15 @@ func parseIP(s string) []byte {
|
||||
if s == "localhost" {
|
||||
return []byte{127, 0, 0, 1}
|
||||
}
|
||||
// In production, use net.ParseIP for proper parsing.
|
||||
// For now, return nil for non-localhost IPs.
|
||||
return nil
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
// Prefer 4-byte representation for IPv4
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// isEmail checks if a string looks like an email address.
|
||||
@@ -441,6 +586,81 @@ func hashPublicKey(pub interface{}) []byte {
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
h.Write(k.N.Bytes())
|
||||
case *ecdsa.PublicKey:
|
||||
h.Write(elliptic.Marshal(k.Curve, k.X, k.Y))
|
||||
}
|
||||
return h.Sum(nil)[:4] // Use first 4 bytes for brevity
|
||||
}
|
||||
|
||||
// GenerateCRL generates a DER-encoded X.509 CRL signed by this local CA.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
if err := c.ensureCA(ctx); err != nil {
|
||||
return nil, fmt.Errorf("CA initialization failed: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
revokedEntries := make([]x509.RevocationListEntry, 0, len(revokedCerts))
|
||||
for _, cert := range revokedCerts {
|
||||
revokedEntries = append(revokedEntries, x509.RevocationListEntry{
|
||||
SerialNumber: cert.SerialNumber,
|
||||
RevocationTime: cert.RevokedAt,
|
||||
ReasonCode: cert.ReasonCode,
|
||||
})
|
||||
}
|
||||
|
||||
template := &x509.RevocationList{
|
||||
RevokedCertificateEntries: revokedEntries,
|
||||
Number: big.NewInt(time.Now().Unix()),
|
||||
ThisUpdate: now,
|
||||
NextUpdate: now.Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
crlBytes, err := x509.CreateRevocationList(rand.Reader, template, c.caCert, c.caKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create CRL: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("CRL generated",
|
||||
"entries", len(revokedCerts),
|
||||
"next_update", template.NextUpdate)
|
||||
|
||||
return crlBytes, nil
|
||||
}
|
||||
|
||||
// SignOCSPResponse signs an OCSP response for the given certificate.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
if err := c.ensureCA(ctx); err != nil {
|
||||
return nil, fmt.Errorf("CA initialization failed: %w", err)
|
||||
}
|
||||
|
||||
// Import OCSP after we confirm golang.org/x/crypto is available
|
||||
// This will be added to imports below
|
||||
template := ocsp.Response{
|
||||
SerialNumber: req.CertSerial,
|
||||
ThisUpdate: req.ThisUpdate,
|
||||
NextUpdate: req.NextUpdate,
|
||||
Certificate: c.caCert,
|
||||
}
|
||||
|
||||
switch req.CertStatus {
|
||||
case 0: // good
|
||||
template.Status = ocsp.Good
|
||||
case 1: // revoked
|
||||
template.Status = ocsp.Revoked
|
||||
template.RevokedAt = req.RevokedAt
|
||||
template.RevocationReason = req.RevocationReason
|
||||
default: // unknown
|
||||
template.Status = ocsp.Unknown
|
||||
}
|
||||
|
||||
respBytes, err := ocsp.CreateResponse(c.caCert, c.caCert, template, c.caKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OCSP response: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("OCSP response signed",
|
||||
"serial", req.CertSerial,
|
||||
"status", req.CertStatus)
|
||||
|
||||
return respBytes, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package local_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -9,8 +11,11 @@ import (
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
@@ -171,6 +176,339 @@ func TestLocalConnector(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Sub-CA mode tests
|
||||
|
||||
func TestSubCAMode(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("SubCA_RSA_IssueCertificate", func(t *testing.T) {
|
||||
certPath, keyPath := generateTestSubCA(t, "rsa")
|
||||
defer os.Remove(certPath)
|
||||
defer os.Remove(keyPath)
|
||||
|
||||
config := &local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: certPath,
|
||||
CAKeyPath: keyPath,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
_, csrPEM, err := generateTestCSR("app.internal.corp")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.internal.corp",
|
||||
SANs: []string{"app.internal.corp"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("SubCA IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Error("CertPEM is empty")
|
||||
}
|
||||
if result.ChainPEM == "" {
|
||||
t.Error("ChainPEM is empty (should contain sub-CA cert)")
|
||||
}
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
|
||||
// Verify the issued cert is signed by the sub-CA (not self-signed)
|
||||
certBlock, _ := pem.Decode([]byte(result.CertPEM))
|
||||
if certBlock == nil {
|
||||
t.Fatal("Failed to decode issued cert PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse issued cert: %v", err)
|
||||
}
|
||||
|
||||
// The issuer should be the sub-CA, not the cert itself
|
||||
if cert.Issuer.CommonName == cert.Subject.CommonName {
|
||||
t.Error("Issued cert appears to be self-signed (issuer == subject)")
|
||||
}
|
||||
|
||||
t.Logf("Sub-CA issued cert: serial=%s, issuer=%s, subject=%s",
|
||||
result.Serial, cert.Issuer.CommonName, cert.Subject.CommonName)
|
||||
})
|
||||
|
||||
t.Run("SubCA_ECDSA_IssueCertificate", func(t *testing.T) {
|
||||
certPath, keyPath := generateTestSubCA(t, "ecdsa")
|
||||
defer os.Remove(certPath)
|
||||
defer os.Remove(keyPath)
|
||||
|
||||
config := &local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: certPath,
|
||||
CAKeyPath: keyPath,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
_, csrPEM, err := generateTestCSR("api.internal.corp")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "api.internal.corp",
|
||||
SANs: []string{"api.internal.corp"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("SubCA ECDSA IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Error("CertPEM is empty")
|
||||
}
|
||||
|
||||
t.Logf("Sub-CA (ECDSA) issued cert: serial=%s", result.Serial)
|
||||
})
|
||||
|
||||
t.Run("SubCA_ValidateConfig_MissingKeyPath", func(t *testing.T) {
|
||||
cfg := local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: "/some/cert.pem",
|
||||
// CAKeyPath intentionally omitted
|
||||
}
|
||||
connector := local.New(nil, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when only CACertPath is set")
|
||||
}
|
||||
t.Logf("Correctly rejected partial sub-CA config: %v", err)
|
||||
})
|
||||
|
||||
t.Run("SubCA_ValidateConfig_NonexistentPaths", func(t *testing.T) {
|
||||
cfg := local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: "/nonexistent/ca.pem",
|
||||
CAKeyPath: "/nonexistent/ca-key.pem",
|
||||
}
|
||||
connector := local.New(nil, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nonexistent file paths")
|
||||
}
|
||||
t.Logf("Correctly rejected nonexistent paths: %v", err)
|
||||
})
|
||||
|
||||
t.Run("SubCA_InvalidCertFile", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
certPath := filepath.Join(tmpDir, "bad-cert.pem")
|
||||
keyPath := filepath.Join(tmpDir, "bad-key.pem")
|
||||
|
||||
// Write garbage data
|
||||
os.WriteFile(certPath, []byte("not a certificate"), 0600)
|
||||
os.WriteFile(keyPath, []byte("not a key"), 0600)
|
||||
|
||||
config := &local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: certPath,
|
||||
CAKeyPath: keyPath,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
_, csrPEM, _ := generateTestCSR("test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid cert file")
|
||||
}
|
||||
t.Logf("Correctly rejected invalid cert file: %v", err)
|
||||
})
|
||||
|
||||
t.Run("SubCA_NonCACert", func(t *testing.T) {
|
||||
// Create a cert that is NOT a CA (no BasicConstraints.IsCA)
|
||||
tmpDir := t.TempDir()
|
||||
certPath, keyPath := generateTestNonCACert(t, tmpDir)
|
||||
|
||||
config := &local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: certPath,
|
||||
CAKeyPath: keyPath,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
_, csrPEM, _ := generateTestCSR("test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-CA cert")
|
||||
}
|
||||
t.Logf("Correctly rejected non-CA cert: %v", err)
|
||||
})
|
||||
|
||||
t.Run("SubCA_RenewCertificate", func(t *testing.T) {
|
||||
certPath, keyPath := generateTestSubCA(t, "rsa")
|
||||
defer os.Remove(certPath)
|
||||
defer os.Remove(keyPath)
|
||||
|
||||
config := &local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: certPath,
|
||||
CAKeyPath: keyPath,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
_, csrPEM, err := generateTestCSR("renew.internal.corp")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
renewReq := issuer.RenewalRequest{
|
||||
CommonName: "renew.internal.corp",
|
||||
SANs: []string{"renew.internal.corp"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||
if err != nil {
|
||||
t.Fatalf("SubCA RenewCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
t.Logf("Sub-CA renewed cert: serial=%s", result.Serial)
|
||||
})
|
||||
}
|
||||
|
||||
// generateTestSubCA creates a self-signed CA cert+key pair and writes them to temp files.
|
||||
// keyType can be "rsa" or "ecdsa".
|
||||
func generateTestSubCA(t *testing.T, keyType string) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
certPath = filepath.Join(tmpDir, "ca.pem")
|
||||
keyPath = filepath.Join(tmpDir, "ca-key.pem")
|
||||
|
||||
var privKey interface{}
|
||||
var pubKey interface{}
|
||||
var keyPEM []byte
|
||||
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||
}
|
||||
privKey = rsaKey
|
||||
pubKey = &rsaKey.PublicKey
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
|
||||
})
|
||||
case "ecdsa":
|
||||
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate ECDSA key: %v", err)
|
||||
}
|
||||
privKey = ecKey
|
||||
pubKey = &ecKey.PublicKey
|
||||
ecKeyBytes, err := x509.MarshalECPrivateKey(ecKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal ECDSA key: %v", err)
|
||||
}
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{
|
||||
Type: "EC PRIVATE KEY",
|
||||
Bytes: ecKeyBytes,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("Unsupported key type: %s", keyType)
|
||||
}
|
||||
|
||||
// Create a CA certificate
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Test Sub-CA",
|
||||
Organization: []string{"CertCtl Test"},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(5, 0, 0),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, pubKey, privKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create CA cert: %v", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certBytes,
|
||||
})
|
||||
|
||||
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
|
||||
t.Fatalf("Failed to write CA cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
||||
t.Fatalf("Failed to write CA key: %v", err)
|
||||
}
|
||||
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
// generateTestNonCACert creates a cert+key pair where IsCA=false (not a CA cert).
|
||||
func generateTestNonCACert(t *testing.T, tmpDir string) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
certPath = filepath.Join(tmpDir, "not-ca.pem")
|
||||
keyPath = filepath.Join(tmpDir, "not-ca-key.pem")
|
||||
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Not A CA",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: false, // NOT a CA
|
||||
}
|
||||
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &rsaKey.PublicKey, rsaKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create non-CA cert: %v", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)})
|
||||
|
||||
os.WriteFile(certPath, certPEM, 0600)
|
||||
os.WriteFile(keyPath, keyPEM, 0600)
|
||||
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
func generateTestCSR(commonName string) (*x509.CertificateRequest, string, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
@@ -204,3 +542,364 @@ func generateTestCSR(commonName string) (*x509.CertificateRequest, string, error
|
||||
|
||||
return csr, string(csrPEM), nil
|
||||
}
|
||||
|
||||
// M15b: CRL and OCSP Tests
|
||||
|
||||
func TestGenerateCRL_Empty(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
config := &local.Config{
|
||||
CACommonName: "Test CA",
|
||||
ValidityDays: 30,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
// Generate CRL with no revoked certs — should succeed with 0 entries
|
||||
crl, err := connector.GenerateCRL(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCRL failed: %v", err)
|
||||
}
|
||||
|
||||
if crl == nil {
|
||||
t.Fatal("CRL is nil")
|
||||
}
|
||||
|
||||
// Verify it's valid DER by parsing
|
||||
parsedCRL, err := x509.ParseRevocationList(crl)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse CRL: %v", err)
|
||||
}
|
||||
|
||||
if len(parsedCRL.RevokedCertificateEntries) != 0 {
|
||||
t.Errorf("expected 0 revoked entries, got %d", len(parsedCRL.RevokedCertificateEntries))
|
||||
}
|
||||
|
||||
t.Logf("Empty CRL generated successfully with %d entries", len(parsedCRL.RevokedCertificateEntries))
|
||||
}
|
||||
|
||||
func TestGenerateCRL_WithEntries(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
config := &local.Config{
|
||||
CACommonName: "Test CA",
|
||||
ValidityDays: 30,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
// Generate CRL with 2 revoked certs
|
||||
entries := []issuer.RevokedCertEntry{
|
||||
{SerialNumber: big.NewInt(12345), RevokedAt: time.Now().Add(-24 * time.Hour), ReasonCode: 1},
|
||||
{SerialNumber: big.NewInt(67890), RevokedAt: time.Now().Add(-1 * time.Hour), ReasonCode: 4},
|
||||
}
|
||||
|
||||
crl, err := connector.GenerateCRL(ctx, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCRL failed: %v", err)
|
||||
}
|
||||
|
||||
if crl == nil {
|
||||
t.Fatal("CRL is nil")
|
||||
}
|
||||
|
||||
parsedCRL, err := x509.ParseRevocationList(crl)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse CRL: %v", err)
|
||||
}
|
||||
|
||||
if len(parsedCRL.RevokedCertificateEntries) != 2 {
|
||||
t.Errorf("expected 2 revoked entries, got %d", len(parsedCRL.RevokedCertificateEntries))
|
||||
}
|
||||
|
||||
// Verify entries contain expected serials
|
||||
serials := make(map[string]bool)
|
||||
for _, entry := range parsedCRL.RevokedCertificateEntries {
|
||||
serials[entry.SerialNumber.String()] = true
|
||||
}
|
||||
|
||||
if !serials["12345"] {
|
||||
t.Error("expected serial 12345 in CRL")
|
||||
}
|
||||
if !serials["67890"] {
|
||||
t.Error("expected serial 67890 in CRL")
|
||||
}
|
||||
|
||||
t.Logf("CRL with entries generated successfully: %d entries", len(parsedCRL.RevokedCertificateEntries))
|
||||
}
|
||||
|
||||
func TestGenerateCRL_BeforeCAInit(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// CRL generation should init the CA automatically
|
||||
cfg := &local.Config{ValidityDays: 90}
|
||||
connector := local.New(cfg, logger)
|
||||
|
||||
crl, err := connector.GenerateCRL(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCRL failed: %v", err)
|
||||
}
|
||||
|
||||
if crl == nil {
|
||||
t.Fatal("CRL is nil")
|
||||
}
|
||||
|
||||
// Verify it's valid
|
||||
_, err = x509.ParseRevocationList(crl)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse CRL: %v", err)
|
||||
}
|
||||
|
||||
t.Log("CRL generated with auto-initialized CA")
|
||||
}
|
||||
|
||||
func TestGenerateCRL_WithReasonCodes(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
config := &local.Config{
|
||||
CACommonName: "Test CA",
|
||||
ValidityDays: 30,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
// Test all RFC 5280 reason codes
|
||||
entries := []issuer.RevokedCertEntry{
|
||||
{SerialNumber: big.NewInt(100), RevokedAt: time.Now(), ReasonCode: 0}, // unspecified
|
||||
{SerialNumber: big.NewInt(101), RevokedAt: time.Now(), ReasonCode: 1}, // keyCompromise
|
||||
{SerialNumber: big.NewInt(102), RevokedAt: time.Now(), ReasonCode: 2}, // caCompromise
|
||||
{SerialNumber: big.NewInt(103), RevokedAt: time.Now(), ReasonCode: 3}, // affiliationChanged
|
||||
{SerialNumber: big.NewInt(104), RevokedAt: time.Now(), ReasonCode: 4}, // superseded
|
||||
}
|
||||
|
||||
crl, err := connector.GenerateCRL(ctx, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCRL failed: %v", err)
|
||||
}
|
||||
|
||||
parsedCRL, err := x509.ParseRevocationList(crl)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse CRL: %v", err)
|
||||
}
|
||||
|
||||
if len(parsedCRL.RevokedCertificateEntries) != 5 {
|
||||
t.Errorf("expected 5 revoked entries, got %d", len(parsedCRL.RevokedCertificateEntries))
|
||||
}
|
||||
|
||||
// Verify reason codes are preserved
|
||||
reasonCount := 0
|
||||
for _, entry := range parsedCRL.RevokedCertificateEntries {
|
||||
if entry.ReasonCode >= 0 {
|
||||
reasonCount++
|
||||
}
|
||||
}
|
||||
if reasonCount != 5 {
|
||||
t.Errorf("expected all 5 entries to have reason codes, got %d", reasonCount)
|
||||
}
|
||||
|
||||
t.Logf("CRL with %d reason codes generated successfully", reasonCount)
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_Good(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
config := &local.Config{
|
||||
CACommonName: "Test CA",
|
||||
ValidityDays: 30,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
now := time.Now()
|
||||
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
|
||||
CertSerial: big.NewInt(12345),
|
||||
CertStatus: 0, // good
|
||||
ThisUpdate: now,
|
||||
NextUpdate: now.Add(1 * time.Hour),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SignOCSPResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
t.Fatal("OCSP response is nil")
|
||||
}
|
||||
|
||||
if len(resp) == 0 {
|
||||
t.Fatal("OCSP response is empty")
|
||||
}
|
||||
|
||||
t.Logf("OCSP response for good cert generated: %d bytes", len(resp))
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_Revoked(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
config := &local.Config{
|
||||
CACommonName: "Test CA",
|
||||
ValidityDays: 30,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
now := time.Now()
|
||||
revokedAt := now.Add(-24 * time.Hour)
|
||||
|
||||
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
|
||||
CertSerial: big.NewInt(12345),
|
||||
CertStatus: 1, // revoked
|
||||
RevokedAt: revokedAt,
|
||||
RevocationReason: 1, // keyCompromise
|
||||
ThisUpdate: now,
|
||||
NextUpdate: now.Add(1 * time.Hour),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SignOCSPResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
t.Fatal("OCSP response is nil")
|
||||
}
|
||||
|
||||
if len(resp) == 0 {
|
||||
t.Fatal("OCSP response is empty")
|
||||
}
|
||||
|
||||
t.Logf("OCSP response for revoked cert generated: %d bytes", len(resp))
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_Unknown(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
config := &local.Config{
|
||||
CACommonName: "Test CA",
|
||||
ValidityDays: 30,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
now := time.Now()
|
||||
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
|
||||
CertSerial: big.NewInt(12345),
|
||||
CertStatus: 2, // unknown
|
||||
ThisUpdate: now,
|
||||
NextUpdate: now.Add(1 * time.Hour),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SignOCSPResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
t.Fatal("OCSP response is nil")
|
||||
}
|
||||
|
||||
if len(resp) == 0 {
|
||||
t.Fatal("OCSP response is empty")
|
||||
}
|
||||
|
||||
t.Logf("OCSP response for unknown cert generated: %d bytes", len(resp))
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_BeforeCAInit(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &local.Config{ValidityDays: 90}
|
||||
connector := local.New(cfg, logger)
|
||||
|
||||
now := time.Now()
|
||||
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
|
||||
CertSerial: big.NewInt(999),
|
||||
CertStatus: 0,
|
||||
ThisUpdate: now,
|
||||
NextUpdate: now.Add(1 * time.Hour),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SignOCSPResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if resp == nil || len(resp) == 0 {
|
||||
t.Fatal("OCSP response is nil or empty")
|
||||
}
|
||||
|
||||
t.Log("OCSP response generated with auto-initialized CA")
|
||||
}
|
||||
|
||||
func TestGenerateCRL_SubCA(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
certPath, keyPath := generateTestSubCA(t, "rsa")
|
||||
defer os.Remove(certPath)
|
||||
defer os.Remove(keyPath)
|
||||
|
||||
config := &local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: certPath,
|
||||
CAKeyPath: keyPath,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
entries := []issuer.RevokedCertEntry{
|
||||
{SerialNumber: big.NewInt(555), RevokedAt: time.Now().Add(-12 * time.Hour), ReasonCode: 2},
|
||||
}
|
||||
|
||||
crl, err := connector.GenerateCRL(ctx, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("SubCA GenerateCRL failed: %v", err)
|
||||
}
|
||||
|
||||
if crl == nil {
|
||||
t.Fatal("CRL is nil")
|
||||
}
|
||||
|
||||
parsedCRL, err := x509.ParseRevocationList(crl)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse SubCA CRL: %v", err)
|
||||
}
|
||||
|
||||
if len(parsedCRL.RevokedCertificateEntries) != 1 {
|
||||
t.Errorf("expected 1 entry in SubCA CRL, got %d", len(parsedCRL.RevokedCertificateEntries))
|
||||
}
|
||||
|
||||
t.Log("SubCA CRL generated successfully")
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_SubCA(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
certPath, keyPath := generateTestSubCA(t, "ecdsa")
|
||||
defer os.Remove(certPath)
|
||||
defer os.Remove(keyPath)
|
||||
|
||||
config := &local.Config{
|
||||
ValidityDays: 30,
|
||||
CACertPath: certPath,
|
||||
CAKeyPath: keyPath,
|
||||
}
|
||||
connector := local.New(config, logger)
|
||||
|
||||
now := time.Now()
|
||||
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
|
||||
CertSerial: big.NewInt(777),
|
||||
CertStatus: 0,
|
||||
ThisUpdate: now,
|
||||
NextUpdate: now.Add(1 * time.Hour),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SubCA SignOCSPResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if resp == nil || len(resp) == 0 {
|
||||
t.Fatal("SubCA OCSP response is nil or empty")
|
||||
}
|
||||
|
||||
t.Log("SubCA OCSP response generated successfully")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
// Package openssl implements the issuer.Connector interface for custom CA integrations.
|
||||
//
|
||||
// This connector delegates certificate signing to user-provided scripts/commands.
|
||||
// It allows operators to use their existing CA tooling (OpenSSL, cfssl, custom scripts, etc.)
|
||||
// as the signing backend for certctl.
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// SignScript: path to a script/command that signs CSRs.
|
||||
// Called as: <sign_script> <csr_file> <cert_output_file>
|
||||
// The script receives the CSR PEM as a temp file, and must write the signed cert PEM to the output file.
|
||||
// Exit 0 = success, non-zero = failure (stderr captured as error message).
|
||||
//
|
||||
// RevokeScript: path to a script/command that revokes certificates (optional).
|
||||
// Called as: <revoke_script> <serial> <reason>
|
||||
// Optional — if empty, revocation returns "not supported".
|
||||
//
|
||||
// CRLScript: path to a script/command that generates a CRL (optional).
|
||||
// Called as: <crl_script> <revoked_serials_json_file> <crl_output_file>
|
||||
// Optional — if empty, CRL generation returns nil.
|
||||
//
|
||||
// TimeoutSeconds: max time to wait for script execution (default 30).
|
||||
package openssl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the OpenSSL/Custom CA issuer connector configuration.
|
||||
type Config struct {
|
||||
// SignScript is the path to a script/command that signs CSRs.
|
||||
// Called as: <sign_script> <csr_file> <cert_output_file>
|
||||
// The script receives the CSR PEM as a temp file, and must write the signed cert PEM to the output file.
|
||||
// Exit 0 = success, non-zero = failure (stderr captured as error message).
|
||||
SignScript string `json:"sign_script"`
|
||||
|
||||
// RevokeScript is the path to a script/command that revokes certificates.
|
||||
// Called as: <revoke_script> <serial> <reason>
|
||||
// Optional — if empty, revocation returns "not supported".
|
||||
RevokeScript string `json:"revoke_script,omitempty"`
|
||||
|
||||
// CRLScript is the path to a script/command that generates a CRL.
|
||||
// Called as: <crl_script> <revoked_serials_json_file> <crl_output_file>
|
||||
// Optional — if empty, CRL generation returns nil.
|
||||
CRLScript string `json:"crl_script,omitempty"`
|
||||
|
||||
// TimeoutSeconds is the max time to wait for script execution.
|
||||
// Defaults to 30.
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for custom CA signing via scripts.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// New creates a new OpenSSL/Custom CA connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
|
||||
timeout := time.Duration(config.TimeoutSeconds) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig validates the OpenSSL/Custom CA configuration.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid OpenSSL/Custom CA config: %w", err)
|
||||
}
|
||||
|
||||
// SignScript is required
|
||||
if cfg.SignScript == "" {
|
||||
return fmt.Errorf("sign_script is required")
|
||||
}
|
||||
|
||||
// Verify sign_script exists and is executable
|
||||
if _, err := os.Stat(cfg.SignScript); err != nil {
|
||||
return fmt.Errorf("sign_script not accessible: %w", err)
|
||||
}
|
||||
|
||||
// Verify revoke_script exists if specified
|
||||
if cfg.RevokeScript != "" {
|
||||
if _, err := os.Stat(cfg.RevokeScript); err != nil {
|
||||
return fmt.Errorf("revoke_script not accessible: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify crl_script exists if specified
|
||||
if cfg.CRLScript != "" {
|
||||
if _, err := os.Stat(cfg.CRLScript); err != nil {
|
||||
return fmt.Errorf("crl_script not accessible: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update connector config
|
||||
c.config = &cfg
|
||||
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
c.timeout = timeout
|
||||
|
||||
c.logger.Info("OpenSSL/Custom CA configuration validated",
|
||||
"sign_script", cfg.SignScript,
|
||||
"has_revoke_script", cfg.RevokeScript != "",
|
||||
"has_crl_script", cfg.CRLScript != "",
|
||||
"timeout_seconds", c.timeout.Seconds())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate issues a new certificate by calling the sign script.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing custom CA issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// Write CSR to a temporary file
|
||||
csrFile, err := c.writeTempFile([]byte(request.CSRPEM), "csr-")
|
||||
if err != nil {
|
||||
c.logger.Error("failed to write CSR temp file", "error", err)
|
||||
return nil, fmt.Errorf("failed to write CSR temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(csrFile)
|
||||
|
||||
// Create temp file for cert output
|
||||
certFile := filepath.Join(filepath.Dir(csrFile), "cert-"+filepath.Base(csrFile))
|
||||
defer os.Remove(certFile)
|
||||
|
||||
// Call sign script
|
||||
if err := c.callSignScript(ctx, csrFile, certFile); err != nil {
|
||||
c.logger.Error("sign script failed", "error", err)
|
||||
return nil, fmt.Errorf("sign script failed: %w", err)
|
||||
}
|
||||
|
||||
// Read the signed certificate
|
||||
certPEM, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to read signed certificate", "error", err)
|
||||
return nil, fmt.Errorf("failed to read signed certificate: %w", err)
|
||||
}
|
||||
|
||||
// Parse the certificate to extract metadata
|
||||
cert, serial, err := c.parseCertificate(certPEM)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to parse signed certificate", "error", err)
|
||||
return nil, fmt.Errorf("failed to parse signed certificate: %w", err)
|
||||
}
|
||||
|
||||
orderID := fmt.Sprintf("openssl-%s", serial)
|
||||
|
||||
result := &issuer.IssuanceResult{
|
||||
CertPEM: string(certPEM),
|
||||
ChainPEM: "", // Custom CA connectors typically don't provide chain; operators must configure separately
|
||||
Serial: serial,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
OrderID: orderID,
|
||||
}
|
||||
|
||||
c.logger.Info("certificate issued successfully",
|
||||
"serial", serial,
|
||||
"common_name", request.CommonName,
|
||||
"not_after", cert.NotAfter)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews a certificate by issuing a new one with the same identifiers.
|
||||
// For custom CA connectors, this is functionally identical to IssueCertificate.
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing custom CA renewal request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// Write CSR to a temporary file
|
||||
csrFile, err := c.writeTempFile([]byte(request.CSRPEM), "csr-")
|
||||
if err != nil {
|
||||
c.logger.Error("failed to write CSR temp file", "error", err)
|
||||
return nil, fmt.Errorf("failed to write CSR temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(csrFile)
|
||||
|
||||
// Create temp file for cert output
|
||||
certFile := filepath.Join(filepath.Dir(csrFile), "cert-"+filepath.Base(csrFile))
|
||||
defer os.Remove(certFile)
|
||||
|
||||
// Call sign script
|
||||
if err := c.callSignScript(ctx, csrFile, certFile); err != nil {
|
||||
c.logger.Error("sign script failed", "error", err)
|
||||
return nil, fmt.Errorf("sign script failed: %w", err)
|
||||
}
|
||||
|
||||
// Read the signed certificate
|
||||
certPEM, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to read signed certificate", "error", err)
|
||||
return nil, fmt.Errorf("failed to read signed certificate: %w", err)
|
||||
}
|
||||
|
||||
// Parse the certificate to extract metadata
|
||||
cert, serial, err := c.parseCertificate(certPEM)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to parse signed certificate", "error", err)
|
||||
return nil, fmt.Errorf("failed to parse signed certificate: %w", err)
|
||||
}
|
||||
|
||||
// Preserve order ID if provided
|
||||
orderID := fmt.Sprintf("openssl-%s", serial)
|
||||
if request.OrderID != nil {
|
||||
orderID = *request.OrderID
|
||||
}
|
||||
|
||||
result := &issuer.IssuanceResult{
|
||||
CertPEM: string(certPEM),
|
||||
ChainPEM: "",
|
||||
Serial: serial,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
OrderID: orderID,
|
||||
}
|
||||
|
||||
c.logger.Info("certificate renewed successfully",
|
||||
"serial", serial,
|
||||
"common_name", request.CommonName,
|
||||
"not_after", cert.NotAfter)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate by calling the revoke script if configured.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
if c.config.RevokeScript == "" {
|
||||
c.logger.Warn("revocation not supported (revoke_script not configured)", "serial", request.Serial)
|
||||
return nil // No-op if revoke script not configured
|
||||
}
|
||||
|
||||
reason := "unspecified"
|
||||
if request.Reason != nil {
|
||||
reason = *request.Reason
|
||||
}
|
||||
|
||||
c.logger.Info("revoking certificate via revoke script",
|
||||
"serial", request.Serial,
|
||||
"reason", reason)
|
||||
|
||||
// Call revoke script: <revoke_script> <serial> <reason>
|
||||
cmd := exec.CommandContext(ctx, c.config.RevokeScript, request.Serial, reason)
|
||||
cmd.Env = os.Environ() // Inherit environment
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Log but don't fail — revocation is best-effort
|
||||
c.logger.Warn("revoke script completed with error",
|
||||
"serial", request.Serial,
|
||||
"error", err)
|
||||
// Return nil to indicate best-effort success
|
||||
}
|
||||
|
||||
c.logger.Info("certificate revoked",
|
||||
"serial", request.Serial,
|
||||
"reason", reason)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus returns the status of an issuance or renewal order.
|
||||
// For custom CA connectors, orders complete immediately, so this always returns "completed" status.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
c.logger.Info("fetching custom CA order status", "order_id", orderID)
|
||||
|
||||
// Custom CA orders complete immediately
|
||||
status := &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "completed",
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// GenerateCRL generates a DER-encoded X.509 CRL by calling the CRL script if configured.
|
||||
// Returns nil if the CRL script is not configured.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
if c.config.CRLScript == "" {
|
||||
c.logger.Debug("CRL generation not supported (crl_script not configured)")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
c.logger.Info("generating CRL via crl script", "revoked_count", len(revokedCerts))
|
||||
|
||||
// Write revoked serials to a temporary JSON file
|
||||
serialsJSON, err := c.marshalRevokedSerials(revokedCerts)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to marshal revoked serials", "error", err)
|
||||
return nil, fmt.Errorf("failed to marshal revoked serials: %w", err)
|
||||
}
|
||||
|
||||
serialsFile, err := c.writeTempFile(serialsJSON, "serials-")
|
||||
if err != nil {
|
||||
c.logger.Error("failed to write revoked serials temp file", "error", err)
|
||||
return nil, fmt.Errorf("failed to write revoked serials temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(serialsFile)
|
||||
|
||||
// Create temp file for CRL output
|
||||
crlFile := filepath.Join(filepath.Dir(serialsFile), "crl-"+filepath.Base(serialsFile))
|
||||
defer os.Remove(crlFile)
|
||||
|
||||
// Call CRL script: <crl_script> <revoked_serials_json_file> <crl_output_file>
|
||||
cmd := exec.CommandContext(ctx, c.config.CRLScript, serialsFile, crlFile)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.logger.Error("crl script failed", "error", err)
|
||||
return nil, fmt.Errorf("crl script failed: %w", err)
|
||||
}
|
||||
|
||||
// Read the generated CRL
|
||||
crlDER, err := os.ReadFile(crlFile)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to read generated CRL", "error", err)
|
||||
return nil, fmt.Errorf("failed to read generated CRL: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("CRL generated successfully", "crl_size", len(crlDER))
|
||||
|
||||
return crlDER, nil
|
||||
}
|
||||
|
||||
// SignOCSPResponse signs an OCSP response.
|
||||
// Custom CA connectors don't support OCSP, so this returns nil.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
c.logger.Debug("OCSP signing not supported by custom CA connector")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// --- Helper Methods ---
|
||||
|
||||
// writeTempFile writes data to a temporary file and returns its path.
|
||||
func (c *Connector) writeTempFile(data []byte, prefix string) (string, error) {
|
||||
f, err := os.CreateTemp("", prefix+"*.pem")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(data); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// callSignScript calls the sign script with CSR and cert output file paths.
|
||||
// Returns the script's error message if execution fails.
|
||||
func (c *Connector) callSignScript(ctx context.Context, csrFile, certFile string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Call sign script: <sign_script> <csr_file> <cert_output_file>
|
||||
cmd := exec.CommandContext(ctx, c.config.SignScript, csrFile, certFile)
|
||||
cmd.Env = os.Environ() // Inherit environment
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("script exited with error: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCertificate parses a PEM-encoded certificate and extracts serial and X.509 cert.
|
||||
func (c *Connector) parseCertificate(certPEM []byte) (*x509.Certificate, string, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, "", fmt.Errorf("invalid certificate PEM format")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
serial := cert.SerialNumber.String()
|
||||
return cert, serial, nil
|
||||
}
|
||||
|
||||
// marshalRevokedSerials converts revoked certs to JSON format for the CRL script.
|
||||
// Format: [{"serial": "...", "revoked_at": "...", "reason_code": ...}, ...]
|
||||
func (c *Connector) marshalRevokedSerials(revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
type RevokedEntry struct {
|
||||
Serial string `json:"serial"`
|
||||
RevokedAt string `json:"revoked_at"`
|
||||
ReasonCode int `json:"reason_code"`
|
||||
}
|
||||
|
||||
entries := make([]RevokedEntry, len(revokedCerts))
|
||||
for i, rc := range revokedCerts {
|
||||
entries[i] = RevokedEntry{
|
||||
Serial: rc.SerialNumber.String(),
|
||||
RevokedAt: rc.RevokedAt.Format(time.RFC3339),
|
||||
ReasonCode: rc.ReasonCode,
|
||||
}
|
||||
}
|
||||
|
||||
return json.MarshalIndent(entries, "", " ")
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package openssl_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||
)
|
||||
|
||||
func TestOpenSSLConnector(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// Test 1: ValidateConfig with valid config
|
||||
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||
// Create a temporary directory for script files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a minimal sign script
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
TimeoutSeconds: 30,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: ValidateConfig with missing sign_script
|
||||
t.Run("ValidateConfig_MissingSignScript", func(t *testing.T) {
|
||||
config := &openssl.Config{
|
||||
SignScript: "",
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing sign_script, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: ValidateConfig with nonexistent script path
|
||||
t.Run("ValidateConfig_NonexistentScript", func(t *testing.T) {
|
||||
config := &openssl.Config{
|
||||
SignScript: "/nonexistent/path/to/sign.sh",
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nonexistent script, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 4: IssueCertificate with a real test CSR and mock sign script
|
||||
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a mock sign script that creates a self-signed cert from CSR
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
mockCertPEM := generateMockCertPEM()
|
||||
scriptContent := "#!/bin/sh\n" +
|
||||
"CSR_FILE=\"$1\"\n" +
|
||||
"CERT_FILE=\"$2\"\n" +
|
||||
"cat > \"$CERT_FILE\" << 'EOF'\n" + mockCertPEM + "\nEOF\n" +
|
||||
"exit 0\n"
|
||||
if err := os.WriteFile(signScript, []byte(scriptContent), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
TimeoutSeconds: 30,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
// Validate config first
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate test CSR
|
||||
csr, csrPEM, err := generateTestCSR("test.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: csr.Subject.CommonName,
|
||||
SANs: []string{"www.test.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
if result.CertPEM == "" {
|
||||
t.Error("CertPEM is empty")
|
||||
}
|
||||
if result.OrderID == "" {
|
||||
t.Error("OrderID is empty")
|
||||
}
|
||||
if result.NotAfter.IsZero() {
|
||||
t.Error("NotAfter is zero")
|
||||
}
|
||||
|
||||
t.Logf("Certificate issued: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||
})
|
||||
|
||||
// Test 5: IssueCertificate with sign script failure
|
||||
t.Run("IssueCertificate_SignScriptFailure", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a sign script that fails
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 1"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
TimeoutSeconds: 30,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
csr, csrPEM, err := generateTestCSR("test.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: csr.Subject.CommonName,
|
||||
SANs: []string{"www.test.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error from failing sign script, got nil")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected result to be nil on error")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 6: IssueCertificate with timeout
|
||||
t.Run("IssueCertificate_SignScriptTimeout", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a sign script that takes too long
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nsleep 10\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
TimeoutSeconds: 1, // 1 second timeout
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
csr, csrPEM, err := generateTestCSR("test.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: csr.Subject.CommonName,
|
||||
SANs: []string{"www.test.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected timeout error, got nil")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected result to be nil on timeout")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 7: RenewCertificate delegates to IssueCertificate
|
||||
t.Run("RenewCertificate_Success", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a mock sign script
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
mockCertPEM := generateMockCertPEM()
|
||||
scriptContent := "#!/bin/sh\n" +
|
||||
"CSR_FILE=\"$1\"\n" +
|
||||
"CERT_FILE=\"$2\"\n" +
|
||||
"cat > \"$CERT_FILE\" << 'EOF'\n" + mockCertPEM + "\nEOF\n" +
|
||||
"exit 0\n"
|
||||
if err := os.WriteFile(signScript, []byte(scriptContent), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
TimeoutSeconds: 30,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
csr, csrPEM, err := generateTestCSR("test.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
renewReq := issuer.RenewalRequest{
|
||||
CommonName: csr.Subject.CommonName,
|
||||
SANs: []string{"www.test.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
|
||||
t.Logf("Certificate renewed: serial=%s", result.Serial)
|
||||
})
|
||||
|
||||
// Test 8: RevokeCertificate without revoke script configured
|
||||
t.Run("RevokeCertificate_NoScript", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
// RevokeScript not set
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "test-serial-12345",
|
||||
}
|
||||
|
||||
// Should return nil (no-op) when revoke script not configured
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 9: RevokeCertificate with revoke script
|
||||
t.Run("RevokeCertificate_WithScript", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
revokeScript := filepath.Join(tmpDir, "revoke.sh")
|
||||
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create revoke script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
RevokeScript: revokeScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "test-serial-12345",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 10: GetOrderStatus always returns "completed"
|
||||
t.Run("GetOrderStatus", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "openssl-12345")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
|
||||
t.Logf("Order status: %s", status.Status)
|
||||
})
|
||||
|
||||
// Test 11: GenerateCRL without CRL script configured
|
||||
t.Run("GenerateCRL_NoScript", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
// CRLScript not set
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
crl, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCRL failed: %v", err)
|
||||
}
|
||||
|
||||
// Should return nil when CRL script not configured
|
||||
if crl != nil {
|
||||
t.Error("Expected nil CRL when CRL script not configured")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 12: GenerateCRL with CRL script
|
||||
t.Run("GenerateCRL_WithScript", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
crlScript := filepath.Join(tmpDir, "crl.sh")
|
||||
scriptContent := "#!/bin/sh\n" +
|
||||
"SERIALS_FILE=\"$1\"\n" +
|
||||
"CRL_FILE=\"$2\"\n" +
|
||||
"echo 'test-crl-content' > \"$CRL_FILE\"\n" +
|
||||
"exit 0\n"
|
||||
if err := os.WriteFile(crlScript, []byte(scriptContent), 0755); err != nil {
|
||||
t.Fatalf("Failed to create CRL script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
CRLScript: crlScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
crl, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCRL failed: %v", err)
|
||||
}
|
||||
|
||||
if crl == nil {
|
||||
t.Error("Expected CRL, got nil")
|
||||
}
|
||||
if len(crl) == 0 {
|
||||
t.Error("Expected non-empty CRL")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 13: SignOCSPResponse returns nil (not supported)
|
||||
t.Run("SignOCSPResponse_NotSupported", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("SignOCSPResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if resp != nil {
|
||||
t.Error("Expected nil OCSP response (not supported)")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 14: Default timeout
|
||||
t.Run("DefaultTimeout", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
TimeoutSeconds: 0, // Should default to 30
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// If timeout is 30 seconds, the config should validate without errors
|
||||
// (we can't easily test the actual timeout value without accessing private fields)
|
||||
t.Log("Default timeout configured (should be 30 seconds)")
|
||||
})
|
||||
}
|
||||
|
||||
// --- Test Helpers ---
|
||||
|
||||
// generateTestCSR creates a test Certificate Signing Request.
|
||||
func generateTestCSR(cn string) (*x509.CertificateRequest, string, error) {
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
subject := pkix.Name{
|
||||
CommonName: cn,
|
||||
}
|
||||
|
||||
csrTemplate := x509.CertificateRequest{
|
||||
Subject: subject,
|
||||
DNSNames: []string{cn, "www." + cn},
|
||||
}
|
||||
|
||||
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
})
|
||||
|
||||
return csr, string(csrPEM), nil
|
||||
}
|
||||
|
||||
// generateMockCertPEM creates a self-signed certificate for testing.
|
||||
func generateMockCertPEM() string {
|
||||
privKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
|
||||
serialNumber := big.NewInt(1234567890)
|
||||
subject := pkix.Name{
|
||||
CommonName: "test.example.com",
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: subject,
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(0, 0, 90),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"test.example.com", "www.test.example.com"},
|
||||
}
|
||||
|
||||
certBytes, _ := x509.CreateCertificate(rand.Reader, template, template, privKey.Public(), privKey)
|
||||
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certBytes,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// Package stepca implements the issuer.Connector interface for Smallstep step-ca
|
||||
// private certificate authority.
|
||||
//
|
||||
// step-ca is a popular open-source private CA that provides both ACME and native
|
||||
// provisioner-based certificate issuance. This connector uses the native /sign API
|
||||
// with JWK provisioner authentication, which is simpler than ACME for internal PKI:
|
||||
// no challenge solving, no domain validation — just CSR + auth token → signed cert.
|
||||
//
|
||||
// For teams already using step-ca, this connector integrates certctl's lifecycle
|
||||
// management (renewal policies, deployment, audit) with step-ca's certificate signing.
|
||||
//
|
||||
// Authentication: JWK provisioner with a shared provisioner password.
|
||||
// The connector generates a short-lived token for each signing request using the
|
||||
// provisioner key (loaded from disk or provided inline).
|
||||
//
|
||||
// step-ca API used:
|
||||
//
|
||||
// POST /sign — submit CSR with provisioner token, receive signed certificate
|
||||
// POST /revoke — revoke a certificate by serial
|
||||
// GET /health — check CA availability
|
||||
package stepca
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the step-ca issuer connector configuration.
|
||||
type Config struct {
|
||||
// CAURL is the base URL of the step-ca server (e.g., "https://ca.internal:9000").
|
||||
CAURL string `json:"ca_url"`
|
||||
|
||||
// RootCertPath is the path to the step-ca root certificate PEM (for TLS verification).
|
||||
// If empty, the system trust store is used.
|
||||
RootCertPath string `json:"root_cert_path,omitempty"`
|
||||
|
||||
// ProvisionerName is the name of the JWK provisioner to use for signing.
|
||||
ProvisionerName string `json:"provisioner_name"`
|
||||
|
||||
// ProvisionerKeyPath is the path to the provisioner's encrypted private key (JWK JSON).
|
||||
// This is the key file generated by `step ca provisioner add`.
|
||||
ProvisionerKeyPath string `json:"provisioner_key_path,omitempty"`
|
||||
|
||||
// ProvisionerPassword is the password to decrypt the provisioner key.
|
||||
// Can also be set via CERTCTL_STEPCA_PROVISIONER_PASSWORD env var.
|
||||
ProvisionerPassword string `json:"provisioner_password,omitempty"`
|
||||
|
||||
// ValidityDays is the requested certificate validity (step-ca may enforce a maximum).
|
||||
// Defaults to 90.
|
||||
ValidityDays int `json:"validity_days,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for step-ca.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new step-ca connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil && config.ValidityDays == 0 {
|
||||
config.ValidityDays = 90
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the step-ca configuration is valid and the CA is reachable.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid step-ca config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CAURL == "" {
|
||||
return fmt.Errorf("step-ca ca_url is required")
|
||||
}
|
||||
|
||||
if cfg.ProvisionerName == "" {
|
||||
return fmt.Errorf("step-ca provisioner_name is required")
|
||||
}
|
||||
|
||||
if cfg.ValidityDays == 0 {
|
||||
cfg.ValidityDays = 90
|
||||
}
|
||||
|
||||
// Check CA health
|
||||
healthURL := cfg.CAURL + "/health"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create health check request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step-ca not reachable at %s: %w", cfg.CAURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("step-ca health check returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Validate provisioner key path exists if provided
|
||||
if cfg.ProvisionerKeyPath != "" {
|
||||
if _, err := os.Stat(cfg.ProvisionerKeyPath); err != nil {
|
||||
return fmt.Errorf("provisioner key not accessible: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("step-ca configuration validated",
|
||||
"ca_url", cfg.CAURL,
|
||||
"provisioner", cfg.ProvisionerName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signRequest is the JSON body for the step-ca /sign endpoint.
|
||||
type signRequest struct {
|
||||
CsrPEM string `json:"csr"`
|
||||
OTT string `json:"ott"` // One-Time Token (provisioner JWT)
|
||||
NotBefore time.Time `json:"notBefore,omitempty"`
|
||||
NotAfter time.Time `json:"notAfter,omitempty"`
|
||||
}
|
||||
|
||||
// signResponse is the JSON response from the step-ca /sign endpoint.
|
||||
type signResponse struct {
|
||||
ServerPEM certificateChain `json:"serverPEM,omitempty"`
|
||||
CaPEM certificateChain `json:"caPEM,omitempty"`
|
||||
CertChainPEM []certBlock `json:"certChainPEM,omitempty"`
|
||||
}
|
||||
|
||||
type certificateChain struct {
|
||||
Certificate string `json:"certificate"`
|
||||
}
|
||||
|
||||
type certBlock struct {
|
||||
Certificate string `json:"certificate"`
|
||||
}
|
||||
|
||||
// IssueCertificate submits a CSR to step-ca for signing.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing step-ca issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// Generate a provisioner token (OTT) for this request
|
||||
ott, err := c.generateProvisionerToken(request.CommonName, request.SANs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate provisioner token: %w", err)
|
||||
}
|
||||
|
||||
// Build the sign request
|
||||
now := time.Now()
|
||||
notAfter := now.AddDate(0, 0, c.config.ValidityDays)
|
||||
|
||||
signReq := signRequest{
|
||||
CsrPEM: request.CSRPEM,
|
||||
OTT: ott,
|
||||
NotBefore: now,
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(signReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal sign request: %w", err)
|
||||
}
|
||||
|
||||
// POST /sign
|
||||
signURL := c.config.CAURL + "/sign"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sign request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("step-ca sign request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read sign response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("step-ca sign returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response — step-ca returns the cert chain
|
||||
certPEM, chainPEM, serial, certNotBefore, certNotAfter, err := parseSignResponse(respBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse sign response: %w", err)
|
||||
}
|
||||
|
||||
orderID := fmt.Sprintf("stepca-%s", serial)
|
||||
|
||||
c.logger.Info("step-ca certificate issued",
|
||||
"common_name", request.CommonName,
|
||||
"serial", serial,
|
||||
"not_after", certNotAfter)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: certNotBefore,
|
||||
NotAfter: certNotAfter,
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews a certificate by creating a new signing request.
|
||||
// For step-ca, renewal is functionally identical to issuance.
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing step-ca renewal request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: request.CommonName,
|
||||
SANs: request.SANs,
|
||||
CSRPEM: request.CSRPEM,
|
||||
})
|
||||
}
|
||||
|
||||
// revokeRequest is the JSON body for the step-ca /revoke endpoint.
|
||||
type revokeRequest struct {
|
||||
Serial string `json:"serial"`
|
||||
ReasonCode int `json:"reasonCode,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OTT string `json:"ott"`
|
||||
Passive bool `json:"passive"` // true = don't propagate to OCSP (just mark revoked)
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at step-ca.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing step-ca revocation request", "serial", request.Serial)
|
||||
|
||||
ott, err := c.generateProvisionerToken(request.Serial, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate revocation token: %w", err)
|
||||
}
|
||||
|
||||
reason := "unspecified"
|
||||
if request.Reason != nil {
|
||||
reason = *request.Reason
|
||||
}
|
||||
|
||||
revokeReq := revokeRequest{
|
||||
Serial: request.Serial,
|
||||
Reason: reason,
|
||||
OTT: ott,
|
||||
Passive: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(revokeReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||
}
|
||||
|
||||
revokeURL := c.config.CAURL + "/revoke"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create revoke request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step-ca revoke request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("step-ca revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
c.logger.Info("step-ca certificate revoked", "serial", request.Serial, "reason", reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus returns the status of a step-ca order.
|
||||
// step-ca signs synchronously, so orders are always "completed" immediately.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "completed",
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateProvisionerToken creates a short-lived JWT (One-Time Token) for step-ca API calls.
|
||||
// This is a minimal JWT signed with the provisioner's key.
|
||||
func (c *Connector) generateProvisionerToken(subject string, sans []string) (string, error) {
|
||||
// For the initial implementation, we generate a simple self-signed JWT.
|
||||
// In production, the provisioner key would be loaded from the configured path.
|
||||
// step-ca expects a JWT with: sub=<CN>, iss=<provisioner>, aud=<ca-url>/sign
|
||||
|
||||
now := time.Now()
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": subject,
|
||||
"iss": c.config.ProvisionerName,
|
||||
"aud": c.config.CAURL + "/sign",
|
||||
"nbf": now.Unix(),
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(5 * time.Minute).Unix(),
|
||||
"jti": generateJTI(),
|
||||
"sha": c.config.ProvisionerName, // step-ca uses this for key lookup
|
||||
}
|
||||
|
||||
if len(sans) > 0 {
|
||||
claims["sans"] = sans
|
||||
}
|
||||
|
||||
// Generate an ephemeral signing key for the token.
|
||||
// In a full implementation, this would use the provisioner key from disk.
|
||||
// For now, we use an ephemeral key — step-ca administrators should configure
|
||||
// the provisioner to accept tokens from this key.
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate token signing key: %w", err)
|
||||
}
|
||||
|
||||
return signJWT(claims, key)
|
||||
}
|
||||
|
||||
// generateJTI creates a unique JWT ID.
|
||||
func generateJTI() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// signJWT creates a minimal ES256 JWT from the given claims.
|
||||
func signJWT(claims map[string]interface{}, key *ecdsa.PrivateKey) (string, error) {
|
||||
// Header
|
||||
header := map[string]string{
|
||||
"alg": "ES256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
signingInput := headerB64 + "." + claimsB64
|
||||
|
||||
// Sign with ES256
|
||||
hash := sha256.Sum256([]byte(signingInput))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, key, hash[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
// Encode signature as fixed-size concatenation (r || s, 32 bytes each for P-256)
|
||||
sig := make([]byte, 64)
|
||||
rBytes := r.Bytes()
|
||||
sBytes := s.Bytes()
|
||||
copy(sig[32-len(rBytes):32], rBytes)
|
||||
copy(sig[64-len(sBytes):64], sBytes)
|
||||
|
||||
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
|
||||
return signingInput + "." + sigB64, nil
|
||||
}
|
||||
|
||||
// parseSignResponse extracts the certificate and chain from step-ca's /sign response.
|
||||
func parseSignResponse(respBody []byte) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
// step-ca /sign response format:
|
||||
// { "crt": "-----BEGIN CERTIFICATE-----\n...", "ca": "-----BEGIN CERTIFICATE-----\n..." }
|
||||
// or
|
||||
// { "serverPEM": { "certificate": "..." }, "caPEM": { "certificate": "..." } }
|
||||
// or
|
||||
// { "certChainPEM": [ { "certificate": "..." }, ... ] }
|
||||
|
||||
// Try the simple format first (crt/ca)
|
||||
var simpleResp struct {
|
||||
Crt string `json:"crt"`
|
||||
Ca string `json:"ca"`
|
||||
}
|
||||
if err = json.Unmarshal(respBody, &simpleResp); err == nil && simpleResp.Crt != "" {
|
||||
certPEM = simpleResp.Crt
|
||||
chainPEM = simpleResp.Ca
|
||||
} else {
|
||||
// Try the structured format
|
||||
var structResp signResponse
|
||||
if err = json.Unmarshal(respBody, &structResp); err != nil {
|
||||
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("failed to parse sign response: %w", err)
|
||||
}
|
||||
|
||||
if structResp.ServerPEM.Certificate != "" {
|
||||
certPEM = structResp.ServerPEM.Certificate
|
||||
chainPEM = structResp.CaPEM.Certificate
|
||||
} else if len(structResp.CertChainPEM) > 0 {
|
||||
certPEM = structResp.CertChainPEM[0].Certificate
|
||||
for i := 1; i < len(structResp.CertChainPEM); i++ {
|
||||
chainPEM += structResp.CertChainPEM[i].Certificate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if certPEM == "" {
|
||||
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("no certificate in sign response")
|
||||
}
|
||||
|
||||
// Parse the leaf cert to extract metadata
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("failed to decode certificate PEM")
|
||||
}
|
||||
|
||||
cert, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil {
|
||||
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("failed to parse certificate: %w", parseErr)
|
||||
}
|
||||
|
||||
serial = cert.SerialNumber.String()
|
||||
notBefore = cert.NotBefore
|
||||
notAfter = cert.NotAfter
|
||||
|
||||
return certPEM, chainPEM, serial, notBefore, notAfter, nil
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported by step-ca as step-ca provides its own CRL endpoint.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("step-ca provides its own CRL endpoint; use step-ca's /crl directly")
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported by step-ca as step-ca provides its own OCSP responder.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("step-ca provides its own OCSP responder; use step-ca's /ocsp directly")
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,367 @@
|
||||
package stepca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||
)
|
||||
|
||||
func TestStepCAConnector(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||
// Start a mock step-ca health endpoint
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/health" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := stepca.Config{
|
||||
CAURL: srv.URL,
|
||||
ProvisionerName: "test-provisioner",
|
||||
ValidityDays: 90,
|
||||
}
|
||||
|
||||
connector := stepca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingCAURL", func(t *testing.T) {
|
||||
config := stepca.Config{
|
||||
ProvisionerName: "test",
|
||||
}
|
||||
|
||||
connector := stepca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing ca_url")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingProvisioner", func(t *testing.T) {
|
||||
config := stepca.Config{
|
||||
CAURL: "https://ca.example.com",
|
||||
}
|
||||
|
||||
connector := stepca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing provisioner_name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_UnreachableCA", func(t *testing.T) {
|
||||
config := stepca.Config{
|
||||
CAURL: "http://localhost:19999",
|
||||
ProvisionerName: "test",
|
||||
}
|
||||
|
||||
connector := stepca.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unreachable CA")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||
// Generate a test certificate to return in the mock
|
||||
testCertPEM, testKeyPEM := generateTestCert(t)
|
||||
_ = testKeyPEM
|
||||
|
||||
// Start a mock step-ca server
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
case "/sign":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
resp := fmt.Sprintf(`{"crt": %q, "ca": %q}`, testCertPEM, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &stepca.Config{
|
||||
CAURL: srv.URL,
|
||||
ProvisionerName: "test-provisioner",
|
||||
ValidityDays: 30,
|
||||
}
|
||||
connector := stepca.New(config, logger)
|
||||
|
||||
_, csrPEM, err := generateStepCATestCSR("app.internal.corp")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSR: %v", err)
|
||||
}
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.internal.corp",
|
||||
SANs: []string{"app.internal.corp"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Error("CertPEM is empty")
|
||||
}
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
if result.OrderID == "" {
|
||||
t.Error("OrderID is empty")
|
||||
}
|
||||
|
||||
t.Logf("step-ca issued cert: serial=%s", result.Serial)
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/sign":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":"invalid token"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &stepca.Config{
|
||||
CAURL: srv.URL,
|
||||
ProvisionerName: "test-provisioner",
|
||||
}
|
||||
connector := stepca.New(config, logger)
|
||||
|
||||
_, csrPEM, _ := generateStepCATestCSR("test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
t.Logf("Correctly got error: %v", err)
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/sign":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
resp := fmt.Sprintf(`{"crt": %q, "ca": %q}`, testCertPEM, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &stepca.Config{
|
||||
CAURL: srv.URL,
|
||||
ProvisionerName: "test-provisioner",
|
||||
}
|
||||
connector := stepca.New(config, logger)
|
||||
|
||||
_, csrPEM, _ := generateStepCATestCSR("renew.example.com")
|
||||
renewReq := issuer.RenewalRequest{
|
||||
CommonName: "renew.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/revoke":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &stepca.Config{
|
||||
CAURL: srv.URL,
|
||||
ProvisionerName: "test-provisioner",
|
||||
}
|
||||
connector := stepca.New(config, logger)
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "1234567890",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/revoke":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &stepca.Config{
|
||||
CAURL: srv.URL,
|
||||
ProvisionerName: "test-provisioner",
|
||||
}
|
||||
connector := stepca.New(config, logger)
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "1234567890",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus", func(t *testing.T) {
|
||||
config := &stepca.Config{
|
||||
CAURL: "https://ca.example.com",
|
||||
ProvisionerName: "test-provisioner",
|
||||
}
|
||||
connector := stepca.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "stepca-12345")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
|
||||
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Test Certificate",
|
||||
},
|
||||
DNSNames: []string{"test.example.com"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func generateStepCATestCSR(commonName string) (*x509.CertificateRequest, string, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
csrTemplate := x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
},
|
||||
DNSNames: []string{commonName},
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
}
|
||||
|
||||
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
})
|
||||
|
||||
return csr, string(csrPEM), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const alertAPIURL = "https://api.opsgenie.com/v2/alerts"
|
||||
|
||||
// Config holds configuration for the OpsGenie notifier.
|
||||
type Config struct {
|
||||
// APIKey is the OpsGenie API integration key.
|
||||
APIKey string `json:"api_key"`
|
||||
// Priority is the default alert priority (P1-P5). Defaults to "P3".
|
||||
Priority string `json:"priority,omitempty"`
|
||||
// Tags are default tags applied to all alerts.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// Notifier sends notifications to OpsGenie via the Alert API.
|
||||
type Notifier struct {
|
||||
config Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new OpsGenie notifier.
|
||||
func New(config Config) *Notifier {
|
||||
if config.Priority == "" {
|
||||
config.Priority = "P3"
|
||||
}
|
||||
return &Notifier{
|
||||
config: config,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Channel returns the channel identifier.
|
||||
func (n *Notifier) Channel() string {
|
||||
return "OpsGenie"
|
||||
}
|
||||
|
||||
// Send delivers a notification to OpsGenie as an alert.
|
||||
func (n *Notifier) Send(ctx context.Context, recipient string, subject string, body string) error {
|
||||
alert := ogAlert{
|
||||
Message: subject,
|
||||
Description: body,
|
||||
Priority: n.config.Priority,
|
||||
Source: "certctl",
|
||||
Tags: n.config.Tags,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(alert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opsgenie: failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, alertAPIURL, bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("opsgenie: failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "GenieKey "+n.config.APIKey)
|
||||
|
||||
resp, err := n.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opsgenie: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("opsgenie: API returned HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ogAlert struct {
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpsGenie_Channel(t *testing.T) {
|
||||
n := New(Config{APIKey: "test-key"})
|
||||
if n.Channel() != "OpsGenie" {
|
||||
t.Errorf("expected channel OpsGenie, got %s", n.Channel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenie_DefaultPriority(t *testing.T) {
|
||||
n := New(Config{APIKey: "test-key"})
|
||||
if n.config.Priority != "P3" {
|
||||
t.Errorf("expected default priority P3, got %s", n.config.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenie_CustomPriority(t *testing.T) {
|
||||
n := New(Config{APIKey: "test-key", Priority: "P1"})
|
||||
if n.config.Priority != "P1" {
|
||||
t.Errorf("expected priority P1, got %s", n.config.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenie_SendSuccess(t *testing.T) {
|
||||
var receivedAlert ogAlert
|
||||
var receivedAuthHeader string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", ct)
|
||||
}
|
||||
receivedAuthHeader = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&receivedAlert); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{
|
||||
APIKey: "test-api-key-123",
|
||||
Priority: "P2",
|
||||
Tags: []string{"certctl", "production"},
|
||||
})
|
||||
// Override HTTP client to hit test server
|
||||
n.httpClient = &http.Client{Transport: &urlRewriteTransport{target: server.URL, transport: http.DefaultTransport}}
|
||||
|
||||
err := n.Send(context.Background(), "ops-team", "Key Compromise", "Certificate mc-api-prod may have compromised private key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if receivedAuthHeader != "GenieKey test-api-key-123" {
|
||||
t.Errorf("expected GenieKey auth header, got %s", receivedAuthHeader)
|
||||
}
|
||||
if receivedAlert.Message != "Key Compromise" {
|
||||
t.Errorf("expected message 'Key Compromise', got %s", receivedAlert.Message)
|
||||
}
|
||||
if receivedAlert.Description != "Certificate mc-api-prod may have compromised private key" {
|
||||
t.Errorf("expected description with cert details, got %s", receivedAlert.Description)
|
||||
}
|
||||
if receivedAlert.Priority != "P2" {
|
||||
t.Errorf("expected priority P2, got %s", receivedAlert.Priority)
|
||||
}
|
||||
if receivedAlert.Source != "certctl" {
|
||||
t.Errorf("expected source certctl, got %s", receivedAlert.Source)
|
||||
}
|
||||
if len(receivedAlert.Tags) != 2 || receivedAlert.Tags[0] != "certctl" {
|
||||
t.Errorf("expected tags [certctl, production], got %v", receivedAlert.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenie_SendHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"message":"API key is invalid"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{APIKey: "bad-key"})
|
||||
n.httpClient = &http.Client{Transport: &urlRewriteTransport{target: server.URL, transport: http.DefaultTransport}}
|
||||
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 401") {
|
||||
t.Errorf("expected HTTP 401 in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenie_SendConnectionError(t *testing.T) {
|
||||
n := New(Config{APIKey: "test-key"})
|
||||
n.httpClient = &http.Client{Transport: &urlRewriteTransport{target: "http://127.0.0.1:1", transport: http.DefaultTransport}}
|
||||
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected connection error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request failed") {
|
||||
t.Errorf("expected 'request failed' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// urlRewriteTransport redirects all requests to a test server URL.
|
||||
type urlRewriteTransport struct {
|
||||
target string
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *urlRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = strings.TrimPrefix(t.target, "http://")
|
||||
return t.transport.RoundTrip(req)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const eventsAPIURL = "https://events.pagerduty.com/v2/enqueue"
|
||||
|
||||
// Config holds configuration for the PagerDuty notifier.
|
||||
type Config struct {
|
||||
// RoutingKey is the PagerDuty Events API v2 integration/routing key.
|
||||
RoutingKey string `json:"routing_key"`
|
||||
// Severity is the default event severity (critical, error, warning, info).
|
||||
// Defaults to "warning" if not set.
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
// Notifier sends notifications to PagerDuty via the Events API v2.
|
||||
type Notifier struct {
|
||||
config Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new PagerDuty notifier.
|
||||
func New(config Config) *Notifier {
|
||||
if config.Severity == "" {
|
||||
config.Severity = "warning"
|
||||
}
|
||||
return &Notifier{
|
||||
config: config,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Channel returns the channel identifier.
|
||||
func (n *Notifier) Channel() string {
|
||||
return "PagerDuty"
|
||||
}
|
||||
|
||||
// Send delivers a notification to PagerDuty as a trigger event.
|
||||
func (n *Notifier) Send(ctx context.Context, recipient string, subject string, body string) error {
|
||||
event := pdEvent{
|
||||
RoutingKey: n.config.RoutingKey,
|
||||
EventAction: "trigger",
|
||||
Payload: pdPayload{
|
||||
Summary: subject,
|
||||
Severity: n.config.Severity,
|
||||
Source: "certctl",
|
||||
CustomDetails: map[string]string{
|
||||
"body": body,
|
||||
"recipient": recipient,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pagerduty: failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, eventsAPIURL, bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("pagerduty: failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := n.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pagerduty: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pagerduty: API returned HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type pdEvent struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
EventAction string `json:"event_action"`
|
||||
Payload pdPayload `json:"payload"`
|
||||
}
|
||||
|
||||
type pdPayload struct {
|
||||
Summary string `json:"summary"`
|
||||
Severity string `json:"severity"`
|
||||
Source string `json:"source"`
|
||||
CustomDetails map[string]string `json:"custom_details,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPagerDuty_Channel(t *testing.T) {
|
||||
n := New(Config{RoutingKey: "test-key"})
|
||||
if n.Channel() != "PagerDuty" {
|
||||
t.Errorf("expected channel PagerDuty, got %s", n.Channel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDuty_DefaultSeverity(t *testing.T) {
|
||||
n := New(Config{RoutingKey: "test-key"})
|
||||
if n.config.Severity != "warning" {
|
||||
t.Errorf("expected default severity 'warning', got %s", n.config.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDuty_CustomSeverity(t *testing.T) {
|
||||
n := New(Config{RoutingKey: "test-key", Severity: "critical"})
|
||||
if n.config.Severity != "critical" {
|
||||
t.Errorf("expected severity 'critical', got %s", n.config.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDuty_SendSuccess(t *testing.T) {
|
||||
var receivedEvent pdEvent
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", ct)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&receivedEvent); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Override the events URL for testing — use a custom HTTP client that redirects
|
||||
n := New(Config{RoutingKey: "test-routing-key", Severity: "error"})
|
||||
// We can't easily override the const URL, so test with a direct HTTP call approach.
|
||||
// Instead, test the payload structure by calling Send with a mock server.
|
||||
// We need to make the notifier use our test server URL.
|
||||
// The simplest way: create the notifier, then manually set the URL by using the test server.
|
||||
// Since eventsAPIURL is a const, we'll test by replacing the http client's transport.
|
||||
|
||||
// Alternative approach: just test that the method constructs the right payload
|
||||
// by using a custom transport that intercepts the request.
|
||||
n.httpClient = server.Client()
|
||||
|
||||
// For this test, we need to override the target URL. Since it's a package-level const,
|
||||
// we'll create a custom RoundTripper that redirects to our test server.
|
||||
originalURL := eventsAPIURL
|
||||
_ = originalURL // just to avoid unused var in case we reference it
|
||||
|
||||
transport := &urlRewriteTransport{
|
||||
target: server.URL,
|
||||
transport: http.DefaultTransport,
|
||||
}
|
||||
n.httpClient = &http.Client{Transport: transport}
|
||||
|
||||
err := n.Send(context.Background(), "oncall@example.com", "Cert Expired", "mc-api-prod has expired")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if receivedEvent.RoutingKey != "test-routing-key" {
|
||||
t.Errorf("expected routing key test-routing-key, got %s", receivedEvent.RoutingKey)
|
||||
}
|
||||
if receivedEvent.EventAction != "trigger" {
|
||||
t.Errorf("expected event action trigger, got %s", receivedEvent.EventAction)
|
||||
}
|
||||
if receivedEvent.Payload.Summary != "Cert Expired" {
|
||||
t.Errorf("expected summary 'Cert Expired', got %s", receivedEvent.Payload.Summary)
|
||||
}
|
||||
if receivedEvent.Payload.Severity != "error" {
|
||||
t.Errorf("expected severity error, got %s", receivedEvent.Payload.Severity)
|
||||
}
|
||||
if receivedEvent.Payload.Source != "certctl" {
|
||||
t.Errorf("expected source certctl, got %s", receivedEvent.Payload.Source)
|
||||
}
|
||||
if receivedEvent.Payload.CustomDetails["body"] != "mc-api-prod has expired" {
|
||||
t.Errorf("expected body in custom_details, got %v", receivedEvent.Payload.CustomDetails)
|
||||
}
|
||||
if receivedEvent.Payload.CustomDetails["recipient"] != "oncall@example.com" {
|
||||
t.Errorf("expected recipient in custom_details, got %v", receivedEvent.Payload.CustomDetails)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDuty_SendHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"status":"invalid","message":"bad routing key"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{RoutingKey: "bad-key"})
|
||||
n.httpClient = &http.Client{Transport: &urlRewriteTransport{target: server.URL, transport: http.DefaultTransport}}
|
||||
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("expected HTTP 400 in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDuty_SendConnectionError(t *testing.T) {
|
||||
n := New(Config{RoutingKey: "test-key"})
|
||||
n.httpClient = &http.Client{Transport: &urlRewriteTransport{target: "http://127.0.0.1:1", transport: http.DefaultTransport}}
|
||||
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected connection error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request failed") {
|
||||
t.Errorf("expected 'request failed' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// urlRewriteTransport redirects all requests to a test server URL.
|
||||
type urlRewriteTransport struct {
|
||||
target string
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *urlRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = strings.TrimPrefix(t.target, "http://")
|
||||
return t.transport.RoundTrip(req)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds configuration for the Slack notifier.
|
||||
type Config struct {
|
||||
// WebhookURL is the Slack incoming webhook URL.
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
// ChannelOverride optionally overrides the webhook's default channel.
|
||||
ChannelOverride string `json:"channel,omitempty"`
|
||||
// Username optionally sets the bot display name.
|
||||
Username string `json:"username,omitempty"`
|
||||
// IconEmoji optionally sets the bot icon (e.g., ":lock:").
|
||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
||||
}
|
||||
|
||||
// Notifier sends notifications to Slack via incoming webhooks.
|
||||
type Notifier struct {
|
||||
config Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new Slack notifier.
|
||||
func New(config Config) *Notifier {
|
||||
return &Notifier{
|
||||
config: config,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Channel returns the channel identifier.
|
||||
func (n *Notifier) Channel() string {
|
||||
return "Slack"
|
||||
}
|
||||
|
||||
// Send delivers a notification to Slack via webhook.
|
||||
func (n *Notifier) Send(ctx context.Context, recipient string, subject string, body string) error {
|
||||
payload := slackMessage{
|
||||
Text: fmt.Sprintf("*%s*\n%s", subject, body),
|
||||
}
|
||||
|
||||
if n.config.ChannelOverride != "" {
|
||||
payload.Channel = n.config.ChannelOverride
|
||||
}
|
||||
if n.config.Username != "" {
|
||||
payload.Username = n.config.Username
|
||||
}
|
||||
if n.config.IconEmoji != "" {
|
||||
payload.IconEmoji = n.config.IconEmoji
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.config.WebhookURL, bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := n.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("slack: webhook returned HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type slackMessage struct {
|
||||
Text string `json:"text"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSlack_Channel(t *testing.T) {
|
||||
n := New(Config{WebhookURL: "https://hooks.slack.com/test"})
|
||||
if n.Channel() != "Slack" {
|
||||
t.Errorf("expected channel Slack, got %s", n.Channel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlack_SendSuccess(t *testing.T) {
|
||||
var receivedPayload slackMessage
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", ct)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&receivedPayload); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{WebhookURL: server.URL})
|
||||
err := n.Send(context.Background(), "ops@example.com", "Cert Expiring", "mc-api-prod expires in 7 days")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(receivedPayload.Text, "*Cert Expiring*") {
|
||||
t.Errorf("expected bold subject in text, got %q", receivedPayload.Text)
|
||||
}
|
||||
if !strings.Contains(receivedPayload.Text, "mc-api-prod expires in 7 days") {
|
||||
t.Errorf("expected body in text, got %q", receivedPayload.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlack_SendWithOverrides(t *testing.T) {
|
||||
var receivedPayload slackMessage
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedPayload)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{
|
||||
WebhookURL: server.URL,
|
||||
ChannelOverride: "#alerts",
|
||||
Username: "certctl-bot",
|
||||
IconEmoji: ":lock:",
|
||||
})
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if receivedPayload.Channel != "#alerts" {
|
||||
t.Errorf("expected channel #alerts, got %s", receivedPayload.Channel)
|
||||
}
|
||||
if receivedPayload.Username != "certctl-bot" {
|
||||
t.Errorf("expected username certctl-bot, got %s", receivedPayload.Username)
|
||||
}
|
||||
if receivedPayload.IconEmoji != ":lock:" {
|
||||
t.Errorf("expected icon_emoji :lock:, got %s", receivedPayload.IconEmoji)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlack_SendHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("invalid_token"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{WebhookURL: server.URL})
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 403") {
|
||||
t.Errorf("expected HTTP 403 in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlack_SendConnectionError(t *testing.T) {
|
||||
n := New(Config{WebhookURL: "http://127.0.0.1:1"})
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected connection error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request failed") {
|
||||
t.Errorf("expected 'request failed' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package teams
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds configuration for the Microsoft Teams notifier.
|
||||
type Config struct {
|
||||
// WebhookURL is the Teams incoming webhook URL.
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
}
|
||||
|
||||
// Notifier sends notifications to Microsoft Teams via incoming webhooks.
|
||||
type Notifier struct {
|
||||
config Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new Teams notifier.
|
||||
func New(config Config) *Notifier {
|
||||
return &Notifier{
|
||||
config: config,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Channel returns the channel identifier.
|
||||
func (n *Notifier) Channel() string {
|
||||
return "Teams"
|
||||
}
|
||||
|
||||
// Send delivers a notification to Teams via webhook using MessageCard format.
|
||||
func (n *Notifier) Send(ctx context.Context, recipient string, subject string, body string) error {
|
||||
card := teamsMessageCard{
|
||||
Type: "MessageCard",
|
||||
Context: "https://schema.org/extensions",
|
||||
ThemeColor: "0076D7",
|
||||
Summary: subject,
|
||||
Sections: []teamsSection{
|
||||
{
|
||||
ActivityTitle: subject,
|
||||
Text: body,
|
||||
Markdown: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
return fmt.Errorf("teams: failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.config.WebhookURL, bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("teams: failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := n.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("teams: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("teams: webhook returned HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type teamsMessageCard struct {
|
||||
Type string `json:"@type"`
|
||||
Context string `json:"@context"`
|
||||
ThemeColor string `json:"themeColor"`
|
||||
Summary string `json:"summary"`
|
||||
Sections []teamsSection `json:"sections"`
|
||||
}
|
||||
|
||||
type teamsSection struct {
|
||||
ActivityTitle string `json:"activityTitle"`
|
||||
Text string `json:"text"`
|
||||
Markdown bool `json:"markdown"`
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package teams
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTeams_Channel(t *testing.T) {
|
||||
n := New(Config{WebhookURL: "https://outlook.office.com/webhook/test"})
|
||||
if n.Channel() != "Teams" {
|
||||
t.Errorf("expected channel Teams, got %s", n.Channel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeams_SendSuccess(t *testing.T) {
|
||||
var receivedCard teamsMessageCard
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", ct)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&receivedCard); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{WebhookURL: server.URL})
|
||||
err := n.Send(context.Background(), "team@example.com", "Renewal Failed", "Certificate mc-api-prod renewal failed after 3 attempts")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if receivedCard.Type != "MessageCard" {
|
||||
t.Errorf("expected @type MessageCard, got %s", receivedCard.Type)
|
||||
}
|
||||
if receivedCard.Summary != "Renewal Failed" {
|
||||
t.Errorf("expected summary 'Renewal Failed', got %s", receivedCard.Summary)
|
||||
}
|
||||
if receivedCard.ThemeColor != "0076D7" {
|
||||
t.Errorf("expected theme color 0076D7, got %s", receivedCard.ThemeColor)
|
||||
}
|
||||
if len(receivedCard.Sections) != 1 {
|
||||
t.Fatalf("expected 1 section, got %d", len(receivedCard.Sections))
|
||||
}
|
||||
if receivedCard.Sections[0].ActivityTitle != "Renewal Failed" {
|
||||
t.Errorf("expected section title 'Renewal Failed', got %s", receivedCard.Sections[0].ActivityTitle)
|
||||
}
|
||||
if !strings.Contains(receivedCard.Sections[0].Text, "mc-api-prod") {
|
||||
t.Errorf("expected body to contain cert ID, got %s", receivedCard.Sections[0].Text)
|
||||
}
|
||||
if !receivedCard.Sections[0].Markdown {
|
||||
t.Error("expected markdown=true in section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeams_SendHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("bad request"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(Config{WebhookURL: server.URL})
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("expected HTTP 400 in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeams_SendConnectionError(t *testing.T) {
|
||||
n := New(Config{WebhookURL: "http://127.0.0.1:1"})
|
||||
err := n.Send(context.Background(), "", "Test", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected connection error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request failed") {
|
||||
t.Errorf("expected 'request failed' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package apache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the Apache httpd deployment target configuration.
|
||||
// This configuration is used on the agent side to deploy certificates to Apache.
|
||||
type Config struct {
|
||||
CertPath string `json:"cert_path"` // Path where cert will be written (e.g., /etc/apache2/ssl/cert.pem)
|
||||
KeyPath string `json:"key_path"` // Path where private key will be written
|
||||
ChainPath string `json:"chain_path"` // Path where CA chain will be written
|
||||
ReloadCommand string `json:"reload_command"` // Command to reload Apache (e.g., "apachectl graceful" or "systemctl reload apache2")
|
||||
ValidateCommand string `json:"validate_command"` // Command to validate Apache config (e.g., "apachectl configtest")
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Apache httpd servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Apache target connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that all required configuration paths and commands are valid.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid Apache config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CertPath == "" || cfg.ChainPath == "" {
|
||||
return fmt.Errorf("Apache cert_path and chain_path are required")
|
||||
}
|
||||
|
||||
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
|
||||
return fmt.Errorf("Apache reload_command and validate_command are required")
|
||||
}
|
||||
|
||||
c.logger.Info("validating Apache configuration",
|
||||
"cert_path", cfg.CertPath,
|
||||
"chain_path", cfg.ChainPath)
|
||||
|
||||
// Verify parent directory exists
|
||||
certDir := filepath.Dir(cfg.CertPath)
|
||||
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("Apache cert directory does not exist: %s", certDir)
|
||||
}
|
||||
|
||||
// Verify validate command works
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.logger.Warn("Apache config validation failed during config check",
|
||||
"error", err,
|
||||
"validate_command", cfg.ValidateCommand)
|
||||
// Don't fail; Apache might not be installed yet
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Apache configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate writes the certificate, key, and chain to configured paths
|
||||
// and reloads Apache to pick up the new certificates.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Write certificate to cert_path with mode 0644
|
||||
// 2. Write private key to key_path with mode 0600 (owner-only read)
|
||||
// 3. Write chain to chain_path with mode 0644
|
||||
// 4. Validate Apache configuration with configtest
|
||||
// 5. Execute graceful reload command
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to Apache httpd",
|
||||
"cert_path", c.config.CertPath,
|
||||
"chain_path", c.config.ChainPath)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Write certificate (0644: rw-r--r--)
|
||||
if err := os.WriteFile(c.config.CertPath, []byte(request.CertPEM), 0644); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
||||
c.logger.Error("certificate deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Write private key with secure permissions (0600: rw-------)
|
||||
if c.config.KeyPath != "" && request.KeyPEM != "" {
|
||||
if err := os.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), 0600); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
||||
c.logger.Error("key deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.KeyPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Write chain (0644: rw-r--r--)
|
||||
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
||||
c.logger.Error("chain deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.ChainPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Validate Apache configuration before reload
|
||||
c.logger.Debug("validating Apache configuration", "validate_command", c.config.ValidateCommand)
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("Apache config validation failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("Apache validation failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Graceful reload
|
||||
c.logger.Debug("reloading Apache", "reload_command", c.config.ReloadCommand)
|
||||
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("Apache reload failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("Apache reload failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
c.logger.Info("certificate deployed to Apache successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", c.config.CertPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.CertPath,
|
||||
DeploymentID: fmt.Sprintf("apache-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed and Apache reloaded successfully",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": c.config.CertPath,
|
||||
"chain_path": c.config.ChainPath,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating Apache deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Validate Apache configuration
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("Apache config validation failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify certificate file exists and is readable
|
||||
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("Apache deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: "Apache configuration valid and certificate accessible",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"validate_command": c.config.ValidateCommand,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package apache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||
)
|
||||
|
||||
func TestApacheConnector_ValidateConfig(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := apache.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing cert_path", func(t *testing.T) {
|
||||
cfg := apache.Config{
|
||||
ChainPath: "/tmp/chain.pem",
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert_path")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing reload_command", func(t *testing.T) {
|
||||
cfg := apache.Config{
|
||||
CertPath: "/tmp/cert.pem",
|
||||
ChainPath: "/tmp/chain.pem",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing reload_command")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON", func(t *testing.T) {
|
||||
connector := apache.New(&apache.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApacheConnector_DeployCertificate(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("successful deployment", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &apache.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify files were written
|
||||
certData, err := os.ReadFile(cfg.CertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
if string(certData) != req.CertPEM {
|
||||
t.Errorf("cert content mismatch")
|
||||
}
|
||||
|
||||
// Verify key has secure permissions
|
||||
info, err := os.Stat(cfg.KeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat key file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected key permissions 0600, got %v", info.Mode().Perm())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate command fails", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &apache.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "false", // always fails
|
||||
}
|
||||
|
||||
connector := apache.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when validate command fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApacheConnector_ValidateDeployment(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("valid deployment", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
os.WriteFile(certPath, []byte("cert"), 0644)
|
||||
|
||||
cfg := &apache.Config{
|
||||
CertPath: certPath,
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Fatal("expected valid deployment")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing cert file", func(t *testing.T) {
|
||||
cfg := &apache.Config{
|
||||
CertPath: "/nonexistent/cert.pem",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert file")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package haproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the HAProxy deployment target configuration.
|
||||
// HAProxy expects a combined PEM file containing the certificate, chain, and private key
|
||||
// concatenated in a single file.
|
||||
type Config struct {
|
||||
PEMPath string `json:"pem_path"` // Path for combined PEM (cert + chain + key)
|
||||
ReloadCommand string `json:"reload_command"` // Command to reload HAProxy (e.g., "systemctl reload haproxy")
|
||||
ValidateCommand string `json:"validate_command"` // Command to validate config (e.g., "haproxy -c -f /etc/haproxy/haproxy.cfg")
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for HAProxy servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
// HAProxy uses a combined PEM file (cert + chain + key) unlike NGINX/Apache which use
|
||||
// separate files.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new HAProxy target connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that all required configuration paths and commands are valid.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid HAProxy config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.PEMPath == "" {
|
||||
return fmt.Errorf("HAProxy pem_path is required")
|
||||
}
|
||||
|
||||
if cfg.ReloadCommand == "" {
|
||||
return fmt.Errorf("HAProxy reload_command is required")
|
||||
}
|
||||
|
||||
c.logger.Info("validating HAProxy configuration",
|
||||
"pem_path", cfg.PEMPath)
|
||||
|
||||
// Verify validate command works if provided
|
||||
if cfg.ValidateCommand != "" {
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.logger.Warn("HAProxy config validation failed during config check",
|
||||
"error", err,
|
||||
"validate_command", cfg.ValidateCommand)
|
||||
// Don't fail; HAProxy might not be installed yet
|
||||
}
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("HAProxy configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate creates a combined PEM file (cert + chain + key) and reloads HAProxy.
|
||||
//
|
||||
// HAProxy requires all TLS material in a single file, concatenated in this order:
|
||||
// 1. Server certificate
|
||||
// 2. Intermediate/chain certificates
|
||||
// 3. Private key
|
||||
//
|
||||
// Steps:
|
||||
// 1. Build combined PEM (cert + chain + key)
|
||||
// 2. Write to pem_path with mode 0600 (contains private key)
|
||||
// 3. Optionally validate HAProxy configuration
|
||||
// 4. Execute reload command
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to HAProxy",
|
||||
"pem_path", c.config.PEMPath)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Build combined PEM: cert + chain + key
|
||||
combinedPEM := request.CertPEM + "\n"
|
||||
if request.ChainPEM != "" {
|
||||
combinedPEM += request.ChainPEM + "\n"
|
||||
}
|
||||
if request.KeyPEM != "" {
|
||||
combinedPEM += request.KeyPEM + "\n"
|
||||
}
|
||||
|
||||
// Write combined PEM with secure permissions (0600: contains private key)
|
||||
if err := os.WriteFile(c.config.PEMPath, []byte(combinedPEM), 0600); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write combined PEM: %v", err)
|
||||
c.logger.Error("PEM deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.PEMPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Validate HAProxy configuration if validate command is configured
|
||||
if c.config.ValidateCommand != "" {
|
||||
c.logger.Debug("validating HAProxy configuration", "validate_command", c.config.ValidateCommand)
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("HAProxy config validation failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("HAProxy validation failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.PEMPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload HAProxy
|
||||
c.logger.Debug("reloading HAProxy", "reload_command", c.config.ReloadCommand)
|
||||
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("HAProxy reload failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("HAProxy reload failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.PEMPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
c.logger.Info("certificate deployed to HAProxy successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"pem_path", c.config.PEMPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.PEMPath,
|
||||
DeploymentID: fmt.Sprintf("haproxy-%d", time.Now().Unix()),
|
||||
Message: "Combined PEM deployed and HAProxy reloaded successfully",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"pem_path": c.config.PEMPath,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating HAProxy deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Validate HAProxy configuration if command provided
|
||||
if c.config.ValidateCommand != "" {
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("HAProxy config validation failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.PEMPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify combined PEM file exists and is readable
|
||||
if _, err := os.Stat(c.config.PEMPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("combined PEM file not found: %s", c.config.PEMPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.PEMPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("HAProxy deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.PEMPath,
|
||||
Message: "HAProxy configuration valid and PEM accessible",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"pem_path": c.config.PEMPath,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package haproxy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||
)
|
||||
|
||||
func TestHAProxyConnector_ValidateConfig(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
cfg := haproxy.Config{
|
||||
PEMPath: "/tmp/haproxy/cert.pem",
|
||||
ReloadCommand: "echo reload",
|
||||
}
|
||||
|
||||
connector := haproxy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing pem_path", func(t *testing.T) {
|
||||
cfg := haproxy.Config{
|
||||
ReloadCommand: "echo reload",
|
||||
}
|
||||
|
||||
connector := haproxy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing pem_path")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing reload_command", func(t *testing.T) {
|
||||
cfg := haproxy.Config{
|
||||
PEMPath: "/tmp/cert.pem",
|
||||
}
|
||||
|
||||
connector := haproxy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing reload_command")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON", func(t *testing.T) {
|
||||
connector := haproxy.New(&haproxy.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHAProxyConnector_DeployCertificate(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("successful deployment with combined PEM", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
pemPath := filepath.Join(tmpDir, "combined.pem")
|
||||
|
||||
cfg := &haproxy.Config{
|
||||
PEMPath: pemPath,
|
||||
ReloadCommand: "echo reload",
|
||||
}
|
||||
|
||||
connector := haproxy.New(cfg, logger)
|
||||
|
||||
certPEM := "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----"
|
||||
chainPEM := "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----"
|
||||
keyPEM := "-----BEGIN EC PRIVATE KEY-----\nkey\n-----END EC PRIVATE KEY-----"
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify combined PEM was written
|
||||
data, err := os.ReadFile(pemPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read PEM file: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "cert") {
|
||||
t.Error("combined PEM missing certificate")
|
||||
}
|
||||
if !strings.Contains(content, "chain") {
|
||||
t.Error("combined PEM missing chain")
|
||||
}
|
||||
if !strings.Contains(content, "key") {
|
||||
t.Error("combined PEM missing key")
|
||||
}
|
||||
|
||||
// Verify secure permissions (contains private key)
|
||||
info, err := os.Stat(pemPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat PEM file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected PEM permissions 0600, got %v", info.Mode().Perm())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reload command fails", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
pemPath := filepath.Join(tmpDir, "combined.pem")
|
||||
|
||||
cfg := &haproxy.Config{
|
||||
PEMPath: pemPath,
|
||||
ReloadCommand: "false", // always fails
|
||||
}
|
||||
|
||||
connector := haproxy.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "cert",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when reload command fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHAProxyConnector_ValidateDeployment(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("valid deployment", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
pemPath := filepath.Join(tmpDir, "combined.pem")
|
||||
os.WriteFile(pemPath, []byte("combined-pem-content"), 0600)
|
||||
|
||||
cfg := &haproxy.Config{
|
||||
PEMPath: pemPath,
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := haproxy.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Fatal("expected valid deployment")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing PEM file", func(t *testing.T) {
|
||||
cfg := &haproxy.Config{
|
||||
PEMPath: "/nonexistent/combined.pem",
|
||||
ReloadCommand: "echo reload",
|
||||
}
|
||||
|
||||
connector := haproxy.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing PEM file")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -120,9 +120,9 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
// Validate NGINX configuration before reload
|
||||
c.logger.Debug("validating NGINX configuration", "validate_command", c.config.ValidateCommand)
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if err := validateCmd.Run(); err != nil {
|
||||
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
|
||||
c.logger.Error("NGINX validation failed", "error", err)
|
||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("NGINX config validation failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("NGINX validation failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
@@ -134,9 +134,9 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
// Reload NGINX
|
||||
c.logger.Debug("reloading NGINX", "reload_command", c.config.ReloadCommand)
|
||||
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if err := reloadCmd.Run(); err != nil {
|
||||
errMsg := fmt.Sprintf("NGINX reload failed: %v", err)
|
||||
c.logger.Error("NGINX reload failed", "error", err)
|
||||
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("NGINX reload failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("NGINX reload failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package nginx_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||
)
|
||||
|
||||
func TestNginxConnector_ValidateConfig_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := nginx.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
connector := nginx.New(&nginx.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_ValidateConfig_MissingCertPath(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := nginx.Config{
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert_path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_ValidateConfig_MissingReloadCommand(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := nginx.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing reload_command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_ValidateConfig_DirectoryNotExists(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := nginx.Config{
|
||||
CertPath: "/nonexistent/directory/cert.pem",
|
||||
ChainPath: "/tmp/chain.pem",
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent cert directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_DeployCertificate_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &nginx.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify cert file was written
|
||||
certData, err := os.ReadFile(cfg.CertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
if string(certData) != req.CertPEM {
|
||||
t.Errorf("cert content mismatch")
|
||||
}
|
||||
|
||||
// Verify chain file was written
|
||||
chainData, err := os.ReadFile(cfg.ChainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read chain file: %v", err)
|
||||
}
|
||||
if string(chainData) != req.ChainPEM {
|
||||
t.Errorf("chain content mismatch")
|
||||
}
|
||||
|
||||
// Verify cert has correct permissions (0644)
|
||||
info, err := os.Stat(cfg.CertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat cert file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0644 {
|
||||
t.Errorf("expected cert permissions 0644, got %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Verify chain has correct permissions (0644)
|
||||
info, err = os.Stat(cfg.ChainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat chain file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0644 {
|
||||
t.Errorf("expected chain permissions 0644, got %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Verify metadata is populated
|
||||
if result.Metadata == nil {
|
||||
t.Fatal("expected metadata in result")
|
||||
}
|
||||
if result.Metadata["cert_path"] != cfg.CertPath {
|
||||
t.Errorf("expected cert_path in metadata")
|
||||
}
|
||||
if result.Metadata["chain_path"] != cfg.ChainPath {
|
||||
t.Errorf("expected chain_path in metadata")
|
||||
}
|
||||
if _, ok := result.Metadata["duration_ms"]; !ok {
|
||||
t.Errorf("expected duration_ms in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_DeployCertificate_CertWriteFail(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &nginx.Config{
|
||||
CertPath: "/nonexistent/directory/cert.pem",
|
||||
ChainPath: "/tmp/chain.pem",
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when cert write fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_DeployCertificate_ChainWriteFail(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &nginx.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
ChainPath: "/nonexistent/directory/chain.pem",
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when chain write fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_DeployCertificate_ValidateCommandFails(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &nginx.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "false",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when validate command fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_DeployCertificate_ReloadCommandFails(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &nginx.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "false",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when reload command fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_ValidateDeployment_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
os.WriteFile(certPath, []byte("cert"), 0644)
|
||||
|
||||
cfg := &nginx.Config{
|
||||
CertPath: certPath,
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Fatal("expected valid deployment")
|
||||
}
|
||||
|
||||
// Verify metadata is populated
|
||||
if result.Metadata == nil {
|
||||
t.Fatal("expected metadata in result")
|
||||
}
|
||||
if _, ok := result.Metadata["duration_ms"]; !ok {
|
||||
t.Errorf("expected duration_ms in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_ValidateDeployment_CertNotFound(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &nginx.Config{
|
||||
CertPath: "/nonexistent/cert.pem",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert file")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxConnector_ValidateDeployment_ValidateCommandFails(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
os.WriteFile(certPath, []byte("cert"), 0644)
|
||||
|
||||
cfg := &nginx.Config{
|
||||
CertPath: certPath,
|
||||
ValidateCommand: "false",
|
||||
}
|
||||
|
||||
connector := nginx.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when validate command fails")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentGroup defines a logical grouping of agents based on metadata criteria
|
||||
// and/or manual membership. Used for policy scoping and fleet management.
|
||||
type AgentGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MatchOS string `json:"match_os"`
|
||||
MatchArchitecture string `json:"match_architecture"`
|
||||
MatchIPCIDR string `json:"match_ip_cidr"`
|
||||
MatchVersion string `json:"match_version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentGroupMembership represents an explicit (manual) agent-to-group mapping.
|
||||
type AgentGroupMembership struct {
|
||||
AgentGroupID string `json:"agent_group_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
MembershipType string `json:"membership_type"` // "include" or "exclude"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// HasDynamicCriteria returns true if this group defines at least one metadata match rule.
|
||||
func (g *AgentGroup) HasDynamicCriteria() bool {
|
||||
return g.MatchOS != "" || g.MatchArchitecture != "" || g.MatchIPCIDR != "" || g.MatchVersion != ""
|
||||
}
|
||||
|
||||
// MatchesAgent checks whether an agent's metadata matches all non-empty criteria.
|
||||
// Empty criteria fields are treated as wildcards (match anything).
|
||||
func (g *AgentGroup) MatchesAgent(agent *Agent) bool {
|
||||
if g.MatchOS != "" && agent.OS != g.MatchOS {
|
||||
return false
|
||||
}
|
||||
if g.MatchArchitecture != "" && agent.Architecture != g.MatchArchitecture {
|
||||
return false
|
||||
}
|
||||
if g.MatchVersion != "" && agent.Version != g.MatchVersion {
|
||||
return false
|
||||
}
|
||||
// IP CIDR matching is more complex — for now, do exact match on the field.
|
||||
// Full CIDR parsing (net.ParseCIDR + Contains) deferred to when we have real use cases.
|
||||
if g.MatchIPCIDR != "" && agent.IPAddress != g.MatchIPCIDR {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -6,23 +6,26 @@ import (
|
||||
|
||||
// ManagedCertificate represents a certificate managed by the control plane.
|
||||
type ManagedCertificate struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
Environment string `json:"environment"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
IssuerID string `json:"issuer_id"`
|
||||
TargetIDs []string `json:"target_ids"`
|
||||
RenewalPolicyID string `json:"renewal_policy_id"`
|
||||
Status CertificateStatus `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
LastRenewalAt *time.Time `json:"last_renewal_at,omitempty"`
|
||||
LastDeploymentAt *time.Time `json:"last_deployment_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
Environment string `json:"environment"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
IssuerID string `json:"issuer_id"`
|
||||
TargetIDs []string `json:"target_ids"`
|
||||
RenewalPolicyID string `json:"renewal_policy_id"`
|
||||
CertificateProfileID string `json:"certificate_profile_id,omitempty"`
|
||||
Status CertificateStatus `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
LastRenewalAt *time.Time `json:"last_renewal_at,omitempty"`
|
||||
LastDeploymentAt *time.Time `json:"last_deployment_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
RevocationReason string `json:"revocation_reason,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CertificateVersion represents a specific version of a certificate.
|
||||
@@ -35,6 +38,8 @@ type CertificateVersion struct {
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||||
PEMChain string `json:"pem_chain"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
KeyAlgorithm string `json:"key_algorithm,omitempty"`
|
||||
KeySize int `json:"key_size,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -54,15 +59,16 @@ const (
|
||||
|
||||
// RenewalPolicy defines renewal parameters for a managed certificate.
|
||||
type RenewalPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RenewalWindowDays int `json:"renewal_window_days"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
RetryInterval int `json:"retry_interval_seconds"`
|
||||
AlertThresholdsDays []int `json:"alert_thresholds_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RenewalWindowDays int `json:"renewal_window_days"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
RetryInterval int `json:"retry_interval_seconds"`
|
||||
AlertThresholdsDays []int `json:"alert_thresholds_days"`
|
||||
CertificateProfileID string `json:"certificate_profile_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DefaultAlertThresholds returns the standard alert thresholds when none are configured.
|
||||
|
||||
@@ -37,6 +37,19 @@ type Agent struct {
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
APIKeyHash string `json:"api_key_hash"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AgentMetadata contains runtime metadata reported by agents via heartbeat.
|
||||
type AgentMetadata struct {
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AgentStatus represents the operational status of an agent.
|
||||
@@ -54,13 +67,17 @@ type IssuerType string
|
||||
const (
|
||||
IssuerTypeACME IssuerType = "ACME"
|
||||
IssuerTypeGenericCA IssuerType = "GenericCA"
|
||||
IssuerTypeStepCA IssuerType = "StepCA"
|
||||
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
||||
)
|
||||
|
||||
// TargetType represents the type of deployment target.
|
||||
type TargetType string
|
||||
|
||||
const (
|
||||
TargetTypeNGINX TargetType = "NGINX"
|
||||
TargetTypeF5 TargetType = "F5"
|
||||
TargetTypeIIS TargetType = "IIS"
|
||||
TargetTypeNGINX TargetType = "NGINX"
|
||||
TargetTypeApache TargetType = "Apache"
|
||||
TargetTypeHAProxy TargetType = "HAProxy"
|
||||
TargetTypeF5 TargetType = "F5"
|
||||
TargetTypeIIS TargetType = "IIS"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DiscoveryStatus represents the triage state of a discovered certificate.
|
||||
type DiscoveryStatus string
|
||||
|
||||
const (
|
||||
// DiscoveryStatusUnmanaged indicates a discovered cert not yet linked to a managed cert.
|
||||
DiscoveryStatusUnmanaged DiscoveryStatus = "Unmanaged"
|
||||
// DiscoveryStatusManaged indicates a discovered cert linked to a managed cert.
|
||||
DiscoveryStatusManaged DiscoveryStatus = "Managed"
|
||||
// DiscoveryStatusDismissed indicates a cert the operator chose to ignore.
|
||||
DiscoveryStatusDismissed DiscoveryStatus = "Dismissed"
|
||||
)
|
||||
|
||||
// IsValidDiscoveryStatus returns true if the status is a recognized discovery status.
|
||||
func IsValidDiscoveryStatus(s string) bool {
|
||||
switch DiscoveryStatus(s) {
|
||||
case DiscoveryStatusUnmanaged, DiscoveryStatusManaged, DiscoveryStatusDismissed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DiscoveredCertificate represents a certificate found on an agent's filesystem.
|
||||
type DiscoveredCertificate struct {
|
||||
ID string `json:"id"`
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
SubjectDN string `json:"subject_dn"`
|
||||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
PEMData string `json:"pem_data,omitempty"`
|
||||
SourcePath string `json:"source_path"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
AgentID string `json:"agent_id"`
|
||||
DiscoveryScanID string `json:"discovery_scan_id,omitempty"`
|
||||
ManagedCertificateID string `json:"managed_certificate_id,omitempty"`
|
||||
Status DiscoveryStatus `json:"status"`
|
||||
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IsExpired returns true if the discovered certificate has expired.
|
||||
func (d *DiscoveredCertificate) IsExpired() bool {
|
||||
if d.NotAfter == nil {
|
||||
return false
|
||||
}
|
||||
return d.NotAfter.Before(time.Now())
|
||||
}
|
||||
|
||||
// DaysUntilExpiry returns the number of days until the certificate expires.
|
||||
// Returns -1 if NotAfter is not set.
|
||||
func (d *DiscoveredCertificate) DaysUntilExpiry() int {
|
||||
if d.NotAfter == nil {
|
||||
return -1
|
||||
}
|
||||
hours := time.Until(*d.NotAfter).Hours()
|
||||
return int(hours / 24)
|
||||
}
|
||||
|
||||
// DiscoveryScan represents a single discovery scan run by an agent.
|
||||
type DiscoveryScan struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Directories []string `json:"directories"`
|
||||
CertificatesFound int `json:"certificates_found"`
|
||||
CertificatesNew int `json:"certificates_new"`
|
||||
ErrorsCount int `json:"errors_count"`
|
||||
ScanDurationMs int `json:"scan_duration_ms"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// DiscoveryReport is the payload an agent sends after scanning its filesystem.
|
||||
type DiscoveryReport struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Directories []string `json:"directories"`
|
||||
Certificates []DiscoveredCertEntry `json:"certificates"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
ScanDurationMs int `json:"scan_duration_ms"`
|
||||
}
|
||||
|
||||
// DiscoveredCertEntry represents a single certificate found during a filesystem scan.
|
||||
// This is the agent-side representation (no server-side IDs yet).
|
||||
type DiscoveredCertEntry struct {
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
SubjectDN string `json:"subject_dn"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
PEMData string `json:"pem_data"`
|
||||
SourcePath string `json:"source_path"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsValidDiscoveryStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"Unmanaged", "Unmanaged", true},
|
||||
{"Managed", "Managed", true},
|
||||
{"Dismissed", "Dismissed", true},
|
||||
{"empty string", "", false},
|
||||
{"invalid status", "Unknown", false},
|
||||
{"partial match", "Manage", false},
|
||||
{"case sensitive", "unmanaged", false},
|
||||
{"lowercase managed", "managed", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidDiscoveryStatus(tt.status); got != tt.want {
|
||||
t.Errorf("IsValidDiscoveryStatus(%q) = %v, want %v", tt.status, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveredCertificate_IsExpired(t *testing.T) {
|
||||
now := time.Now()
|
||||
pastTime := now.AddDate(-1, 0, 0)
|
||||
futureTime := now.AddDate(1, 0, 0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
notAfter *time.Time
|
||||
want bool
|
||||
}{
|
||||
{"expired certificate", &pastTime, true},
|
||||
{"valid certificate", &futureTime, false},
|
||||
{"nil NotAfter", nil, false},
|
||||
{"expires at current time (edge case)", &now, false}, // Before() = false when at same time
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dc := &DiscoveredCertificate{
|
||||
ID: "dcert-1",
|
||||
NotAfter: tt.notAfter,
|
||||
}
|
||||
if got := dc.IsExpired(); got != tt.want {
|
||||
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveredCertificate_DaysUntilExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
// Test with actual future times
|
||||
thirtyDaysFromNow := now.AddDate(0, 0, 30)
|
||||
oneDayFromNow := now.AddDate(0, 0, 1)
|
||||
pastTime := now.AddDate(0, 0, -1)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
notAfter *time.Time
|
||||
wantMin int
|
||||
wantMax int
|
||||
}{
|
||||
{"nil NotAfter", nil, -1, -1},
|
||||
{"expires in 30 days", &thirtyDaysFromNow, 29, 31},
|
||||
{"expires in 1 day", &oneDayFromNow, 0, 2},
|
||||
{"already expired", &pastTime, -2, -1},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dc := &DiscoveredCertificate{
|
||||
ID: "dcert-2",
|
||||
NotAfter: tt.notAfter,
|
||||
}
|
||||
got := dc.DaysUntilExpiry()
|
||||
if got < tt.wantMin || got > tt.wantMax {
|
||||
t.Errorf("DaysUntilExpiry() = %d, want between %d and %d", got, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,13 @@ const (
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobStatusPending JobStatus = "Pending"
|
||||
JobStatusAwaitingCSR JobStatus = "AwaitingCSR"
|
||||
JobStatusRunning JobStatus = "Running"
|
||||
JobStatusCompleted JobStatus = "Completed"
|
||||
JobStatusFailed JobStatus = "Failed"
|
||||
JobStatusCancelled JobStatus = "Cancelled"
|
||||
JobStatusPending JobStatus = "Pending"
|
||||
JobStatusAwaitingCSR JobStatus = "AwaitingCSR"
|
||||
JobStatusAwaitingApproval JobStatus = "AwaitingApproval"
|
||||
JobStatusRunning JobStatus = "Running"
|
||||
JobStatusCompleted JobStatus = "Completed"
|
||||
JobStatusFailed JobStatus = "Failed"
|
||||
JobStatusCancelled JobStatus = "Cancelled"
|
||||
)
|
||||
|
||||
// DeploymentJob represents a job that deploys a certificate to a target via an agent.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// NetworkScanTarget defines a network range to scan for TLS certificates.
|
||||
type NetworkScanTarget struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CIDRs []string `json:"cidrs"`
|
||||
Ports []int `json:"ports"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ScanIntervalHours int `json:"scan_interval_hours"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
||||
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
|
||||
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NetworkScanResult holds the outcome of scanning a single endpoint.
|
||||
type NetworkScanResult struct {
|
||||
Address string // "ip:port"
|
||||
Certs []DiscoveredCertEntry
|
||||
Error string
|
||||
LatencyMs int
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNetworkScanTarget_Defaults(t *testing.T) {
|
||||
target := NetworkScanTarget{
|
||||
ID: "nst-test",
|
||||
Name: "Test Target",
|
||||
CIDRs: []string{"10.0.0.0/24"},
|
||||
Ports: []int{443},
|
||||
Enabled: true,
|
||||
ScanIntervalHours: 6,
|
||||
TimeoutMs: 5000,
|
||||
}
|
||||
|
||||
if target.ID != "nst-test" {
|
||||
t.Errorf("expected ID nst-test, got %s", target.ID)
|
||||
}
|
||||
if len(target.CIDRs) != 1 || target.CIDRs[0] != "10.0.0.0/24" {
|
||||
t.Errorf("unexpected CIDRs: %v", target.CIDRs)
|
||||
}
|
||||
if target.LastScanAt != nil {
|
||||
t.Error("expected nil LastScanAt for new target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanTarget_WithScanResults(t *testing.T) {
|
||||
now := time.Now()
|
||||
duration := 1500
|
||||
found := 12
|
||||
target := NetworkScanTarget{
|
||||
ID: "nst-prod",
|
||||
Name: "Production Network",
|
||||
CIDRs: []string{"192.168.1.0/24", "10.0.0.0/16"},
|
||||
Ports: []int{443, 8443, 636},
|
||||
Enabled: true,
|
||||
ScanIntervalHours: 1,
|
||||
TimeoutMs: 3000,
|
||||
LastScanAt: &now,
|
||||
LastScanDurationMs: &duration,
|
||||
LastScanCertsFound: &found,
|
||||
}
|
||||
|
||||
if len(target.Ports) != 3 {
|
||||
t.Errorf("expected 3 ports, got %d", len(target.Ports))
|
||||
}
|
||||
if *target.LastScanCertsFound != 12 {
|
||||
t.Errorf("expected 12 certs found, got %d", *target.LastScanCertsFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanResult_Fields(t *testing.T) {
|
||||
result := NetworkScanResult{
|
||||
Address: "192.168.1.1:443",
|
||||
Error: "",
|
||||
LatencyMs: 45,
|
||||
}
|
||||
if result.Address != "192.168.1.1:443" {
|
||||
t.Errorf("expected address 192.168.1.1:443, got %s", result.Address)
|
||||
}
|
||||
if result.LatencyMs != 45 {
|
||||
t.Errorf("expected latency 45ms, got %d", result.LatencyMs)
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,17 @@ const (
|
||||
NotificationTypeDeploymentSuccess NotificationType = "DeploymentSuccess"
|
||||
NotificationTypeDeploymentFailure NotificationType = "DeploymentFailure"
|
||||
NotificationTypePolicyViolation NotificationType = "PolicyViolation"
|
||||
NotificationTypeRevocation NotificationType = "Revocation"
|
||||
)
|
||||
|
||||
// NotificationChannel represents the communication medium for a notification.
|
||||
type NotificationChannel string
|
||||
|
||||
const (
|
||||
NotificationChannelEmail NotificationChannel = "Email"
|
||||
NotificationChannelWebhook NotificationChannel = "Webhook"
|
||||
NotificationChannelSlack NotificationChannel = "Slack"
|
||||
NotificationChannelEmail NotificationChannel = "Email"
|
||||
NotificationChannelWebhook NotificationChannel = "Webhook"
|
||||
NotificationChannelSlack NotificationChannel = "Slack"
|
||||
NotificationChannelTeams NotificationChannel = "Teams"
|
||||
NotificationChannelPagerDuty NotificationChannel = "PagerDuty"
|
||||
NotificationChannelOpsGenie NotificationChannel = "OpsGenie"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertificateProfile defines an enrollment profile that controls what kinds of
|
||||
// certificates can be issued: allowed key algorithms, maximum TTL, permitted EKUs,
|
||||
// required SAN patterns, and optional SPIFFE URI SANs for workload identity.
|
||||
type CertificateProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AllowedKeyAlgorithms []KeyAlgorithmRule `json:"allowed_key_algorithms"`
|
||||
MaxTTLSeconds int `json:"max_ttl_seconds"`
|
||||
AllowedEKUs []string `json:"allowed_ekus"`
|
||||
RequiredSANPatterns []string `json:"required_san_patterns"`
|
||||
SPIFFEURIPattern string `json:"spiffe_uri_pattern"`
|
||||
AllowShortLived bool `json:"allow_short_lived"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// KeyAlgorithmRule defines an allowed key algorithm and its minimum key size.
|
||||
type KeyAlgorithmRule struct {
|
||||
Algorithm string `json:"algorithm"` // "RSA", "ECDSA", "Ed25519"
|
||||
MinSize int `json:"min_size"` // RSA: 2048/4096, ECDSA: 256/384, Ed25519: 0 (fixed)
|
||||
}
|
||||
|
||||
// IsShortLived returns true if this profile's max TTL is under 1 hour (3600 seconds).
|
||||
// Short-lived certs use expiry as revocation — no CRL/OCSP needed.
|
||||
func (p *CertificateProfile) IsShortLived() bool {
|
||||
return p.AllowShortLived && p.MaxTTLSeconds > 0 && p.MaxTTLSeconds < 3600
|
||||
}
|
||||
|
||||
// DefaultKeyAlgorithms returns sensible defaults for profiles without explicit rules.
|
||||
func DefaultKeyAlgorithms() []KeyAlgorithmRule {
|
||||
return []KeyAlgorithmRule{
|
||||
{Algorithm: "ECDSA", MinSize: 256},
|
||||
{Algorithm: "RSA", MinSize: 2048},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultEKUs returns the default extended key usages.
|
||||
func DefaultEKUs() []string {
|
||||
return []string{"serverAuth"}
|
||||
}
|
||||
|
||||
// Supported key algorithm constants for validation.
|
||||
const (
|
||||
KeyAlgorithmRSA = "RSA"
|
||||
KeyAlgorithmECDSA = "ECDSA"
|
||||
KeyAlgorithmEd25519 = "Ed25519"
|
||||
)
|
||||
|
||||
// ValidKeyAlgorithms is the set of recognized key algorithm names.
|
||||
var ValidKeyAlgorithms = map[string]bool{
|
||||
KeyAlgorithmRSA: true,
|
||||
KeyAlgorithmECDSA: true,
|
||||
KeyAlgorithmEd25519: true,
|
||||
}
|
||||
|
||||
// ValidEKUs is the set of recognized extended key usage names.
|
||||
var ValidEKUs = map[string]bool{
|
||||
"serverAuth": true,
|
||||
"clientAuth": true,
|
||||
"codeSigning": true,
|
||||
"emailProtection": true,
|
||||
"timeStamping": true,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// RevocationReason represents the reason for revoking a certificate.
|
||||
// Values align with RFC 5280 Section 5.3.1 CRL reason codes.
|
||||
type RevocationReason string
|
||||
|
||||
const (
|
||||
RevocationReasonUnspecified RevocationReason = "unspecified"
|
||||
RevocationReasonKeyCompromise RevocationReason = "keyCompromise"
|
||||
RevocationReasonCACompromise RevocationReason = "caCompromise"
|
||||
RevocationReasonAffiliationChanged RevocationReason = "affiliationChanged"
|
||||
RevocationReasonSuperseded RevocationReason = "superseded"
|
||||
RevocationReasonCessationOfOperation RevocationReason = "cessationOfOperation"
|
||||
RevocationReasonCertificateHold RevocationReason = "certificateHold"
|
||||
RevocationReasonPrivilegeWithdrawn RevocationReason = "privilegeWithdrawn"
|
||||
)
|
||||
|
||||
// ValidRevocationReasons contains all valid revocation reason strings.
|
||||
var ValidRevocationReasons = map[RevocationReason]int{
|
||||
RevocationReasonUnspecified: 0,
|
||||
RevocationReasonKeyCompromise: 1,
|
||||
RevocationReasonCACompromise: 2,
|
||||
RevocationReasonAffiliationChanged: 3,
|
||||
RevocationReasonSuperseded: 4,
|
||||
RevocationReasonCessationOfOperation: 5,
|
||||
RevocationReasonCertificateHold: 6,
|
||||
RevocationReasonPrivilegeWithdrawn: 9,
|
||||
}
|
||||
|
||||
// IsValidRevocationReason checks whether a reason string is a valid RFC 5280 reason code.
|
||||
func IsValidRevocationReason(reason string) bool {
|
||||
_, ok := ValidRevocationReasons[RevocationReason(reason)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// CRLReasonCode returns the RFC 5280 integer reason code for a revocation reason.
|
||||
func CRLReasonCode(reason RevocationReason) int {
|
||||
if code, ok := ValidRevocationReasons[reason]; ok {
|
||||
return code
|
||||
}
|
||||
return 0 // unspecified
|
||||
}
|
||||
|
||||
// CertificateRevocation records the revocation of a specific certificate version.
|
||||
// Used as the authoritative source for CRL generation.
|
||||
type CertificateRevocation struct {
|
||||
ID string `json:"id"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Reason string `json:"reason"`
|
||||
RevokedBy string `json:"revoked_by"`
|
||||
RevokedAt time.Time `json:"revoked_at"`
|
||||
IssuerID string `json:"issuer_id"`
|
||||
IssuerNotified bool `json:"issuer_notified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidRevocationReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
want bool
|
||||
}{
|
||||
{"unspecified", "unspecified", true},
|
||||
{"keyCompromise", "keyCompromise", true},
|
||||
{"caCompromise", "caCompromise", true},
|
||||
{"affiliationChanged", "affiliationChanged", true},
|
||||
{"superseded", "superseded", true},
|
||||
{"cessationOfOperation", "cessationOfOperation", true},
|
||||
{"certificateHold", "certificateHold", true},
|
||||
{"privilegeWithdrawn", "privilegeWithdrawn", true},
|
||||
{"empty string", "", false},
|
||||
{"random string", "notAValidReason", false},
|
||||
{"partial match", "key", false},
|
||||
{"case sensitive", "KeyCompromise", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidRevocationReason(tt.reason); got != tt.want {
|
||||
t.Errorf("IsValidRevocationReason(%q) = %v, want %v", tt.reason, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRLReasonCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
reason RevocationReason
|
||||
want int
|
||||
}{
|
||||
{RevocationReasonUnspecified, 0},
|
||||
{RevocationReasonKeyCompromise, 1},
|
||||
{RevocationReasonCACompromise, 2},
|
||||
{RevocationReasonAffiliationChanged, 3},
|
||||
{RevocationReasonSuperseded, 4},
|
||||
{RevocationReasonCessationOfOperation, 5},
|
||||
{RevocationReasonCertificateHold, 6},
|
||||
{RevocationReasonPrivilegeWithdrawn, 9},
|
||||
{RevocationReason("unknown"), 0}, // falls back to unspecified
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.reason), func(t *testing.T) {
|
||||
if got := CRLReasonCode(tt.reason); got != tt.want {
|
||||
t.Errorf("CRLReasonCode(%q) = %d, want %d", tt.reason, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// TestStatsAndMetricsEndpoints exercises the M14 observability endpoints end-to-end.
|
||||
func TestStatsAndMetricsEndpoints(t *testing.T) {
|
||||
server, _, _, _ := setupTestServer(t)
|
||||
|
||||
t.Run("GetHealth", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/health")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var body map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body["status"] != "healthy" {
|
||||
t.Errorf("expected status=healthy, got %s", body["status"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetReady", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/ready")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetMetrics", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/metrics")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var metrics map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&metrics)
|
||||
if metrics["gauge"] == nil {
|
||||
t.Error("expected gauge in metrics response")
|
||||
}
|
||||
if metrics["counter"] == nil {
|
||||
t.Error("expected counter in metrics response")
|
||||
}
|
||||
if metrics["uptime"] == nil {
|
||||
t.Error("expected uptime in metrics response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetStatsSummary", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/stats/summary")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCertificatesByStatus", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/stats/certificates-by-status")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetExpirationTimeline", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/stats/expiration-timeline?days=90")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetExpirationTimeline_DefaultDays", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/stats/expiration-timeline")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetJobTrends", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/stats/job-trends?days=30")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetIssuanceRate", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/stats/issuance-rate?days=30")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossResourceWorkflow exercises a multi-step workflow spanning certificates,
|
||||
// policies, agents, jobs, audit trail, and notifications — verifying data flows
|
||||
// correctly across service boundaries.
|
||||
func TestCrossResourceWorkflow(t *testing.T) {
|
||||
server, certRepo, jobRepo, agentRepo := setupTestServer(t)
|
||||
|
||||
// Step 1: Create a policy rule
|
||||
var policyID string
|
||||
t.Run("CreatePolicy", func(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"name": "Allowed Domains Policy",
|
||||
"type": "AllowedDomains",
|
||||
"severity": "High",
|
||||
"config": json.RawMessage(`{"domains": ["example.com", "*.example.com"]}`),
|
||||
"description": "Restrict issuance to example.com domains",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/policies", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var rule domain.PolicyRule
|
||||
json.NewDecoder(resp.Body).Decode(&rule)
|
||||
policyID = rule.ID
|
||||
if policyID == "" {
|
||||
t.Fatal("expected policy ID")
|
||||
}
|
||||
t.Logf("Created policy: %s", policyID)
|
||||
})
|
||||
|
||||
// Step 2: Create a certificate
|
||||
var certID string
|
||||
t.Run("CreateCertificate", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
payload := map[string]interface{}{
|
||||
"name": "Workflow Test Cert",
|
||||
"common_name": "workflow.example.com",
|
||||
"sans": []string{"www.workflow.example.com"},
|
||||
"environment": "staging",
|
||||
"owner_id": "owner-ops",
|
||||
"team_id": "team-platform",
|
||||
"issuer_id": "iss-local",
|
||||
"target_ids": []string{},
|
||||
"renewal_policy_id": "policy-standard",
|
||||
"status": "Pending",
|
||||
"expires_at": now.AddDate(0, 3, 0),
|
||||
"tags": map[string]string{"team": "platform"},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var cert domain.ManagedCertificate
|
||||
json.NewDecoder(resp.Body).Decode(&cert)
|
||||
certID = cert.ID
|
||||
t.Logf("Created certificate: %s", certID)
|
||||
})
|
||||
|
||||
// Step 3: Register an agent
|
||||
var agentID string
|
||||
t.Run("RegisterAgent", func(t *testing.T) {
|
||||
payload := map[string]string{"name": "workflow-agent", "hostname": "workflow-host-01"}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/agents", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var agent domain.Agent
|
||||
json.NewDecoder(resp.Body).Decode(&agent)
|
||||
agentID = agent.ID
|
||||
t.Logf("Registered agent: %s", agentID)
|
||||
})
|
||||
|
||||
// Step 4: Trigger renewal
|
||||
t.Run("TriggerRenewal", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/"+certID+"/renew", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 202, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
// Step 5: Verify jobs were created
|
||||
t.Run("VerifyJobsCreated", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/jobs?page=1&per_page=50")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
// data may be null (nil) if no jobs exist, or an array
|
||||
if data, ok := respBody["data"].([]interface{}); ok && len(data) > 0 {
|
||||
t.Logf("Found %d jobs after renewal trigger", len(data))
|
||||
} else {
|
||||
t.Log("No jobs found after renewal trigger (expected — mock TriggerRenewal is async/no-op)")
|
||||
}
|
||||
})
|
||||
|
||||
// Step 6: Agent heartbeat with metadata
|
||||
t.Run("AgentHeartbeatWithMetadata", func(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"ip_address": "10.0.1.50",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/agents/"+agentID+"/heartbeat", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Verify metadata was stored
|
||||
agent, ok := agentRepo.agents[agentID]
|
||||
if !ok {
|
||||
t.Fatal("agent not found in repo after heartbeat")
|
||||
}
|
||||
if agent.LastHeartbeatAt == nil {
|
||||
t.Error("expected heartbeat timestamp to be set")
|
||||
}
|
||||
})
|
||||
|
||||
// Step 7: Add a version to the cert so revocation works
|
||||
t.Run("AddCertVersion", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
certRepo.versions[certID] = []*domain.CertificateVersion{
|
||||
{
|
||||
ID: "cv-workflow-1",
|
||||
CertificateID: certID,
|
||||
SerialNumber: "WORKFLOW-SERIAL-001",
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(0, 3, 0),
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
// Update cert status to Active for revocation
|
||||
if cert, ok := certRepo.certs[certID]; ok {
|
||||
cert.Status = domain.CertificateStatusActive
|
||||
}
|
||||
})
|
||||
|
||||
// Step 8: Revoke the certificate
|
||||
t.Run("RevokeCertificate", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"reason":"cessationOfOperation"}`)
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/"+certID+"/revoke", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Verify cert status changed to Revoked
|
||||
cert := certRepo.certs[certID]
|
||||
if cert.Status != domain.CertificateStatusRevoked {
|
||||
t.Errorf("expected Revoked status, got %s", cert.Status)
|
||||
}
|
||||
})
|
||||
|
||||
// Step 9: Verify revoked cert cannot be renewed
|
||||
t.Run("CannotRenewRevoked", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/"+certID+"/renew", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Revoked cert should not accept renewal (expect error status)
|
||||
if resp.StatusCode == http.StatusAccepted {
|
||||
t.Log("Warning: revoked cert accepted renewal — may need business logic enforcement")
|
||||
}
|
||||
})
|
||||
|
||||
// Step 10: Verify audit trail accumulated events
|
||||
t.Run("AuditTrailAccumulated", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/audit?page=1&per_page=100")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
data, ok := respBody["data"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected data array")
|
||||
}
|
||||
// We should have at least cert_created, agent_registered, renewal_triggered, cert_revoked
|
||||
if len(data) < 3 {
|
||||
t.Logf("Warning: expected at least 3 audit events, got %d", len(data))
|
||||
}
|
||||
t.Logf("Total audit events from workflow: %d", len(data))
|
||||
|
||||
// Verify event types
|
||||
eventTypes := make(map[string]int)
|
||||
for _, evt := range data {
|
||||
if eventMap, ok := evt.(map[string]interface{}); ok {
|
||||
if action, ok := eventMap["action"].(string); ok {
|
||||
eventTypes[action]++
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("Audit event types: %v", eventTypes)
|
||||
})
|
||||
|
||||
// Summary
|
||||
t.Run("WorkflowSummary", func(t *testing.T) {
|
||||
certCount := len(certRepo.certs)
|
||||
jobCount := len(jobRepo.jobs)
|
||||
agentCount := len(agentRepo.agents)
|
||||
t.Logf("Cross-resource workflow complete: %d certs, %d jobs, %d agents", certCount, jobCount, agentCount)
|
||||
})
|
||||
}
|
||||
|
||||
// TestJobApprovalWorkflow exercises the interactive approval flow (M11b).
|
||||
func TestJobApprovalWorkflow(t *testing.T) {
|
||||
server, _, jobRepo, _ := setupTestServer(t)
|
||||
|
||||
// Seed a job in AwaitingApproval state
|
||||
jobID := "job-approval-test-1"
|
||||
jobRepo.jobs[jobID] = &domain.Job{
|
||||
ID: jobID,
|
||||
CertificateID: "mc-test",
|
||||
Type: domain.JobTypeRenewal,
|
||||
Status: domain.JobStatusAwaitingApproval,
|
||||
MaxAttempts: 3,
|
||||
Attempts: 0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
t.Run("ApproveJob_Success", func(t *testing.T) {
|
||||
payload := map[string]string{"reason": "Approved by ops team"}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/"+jobID+"/approve", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Verify job moved to Pending
|
||||
job := jobRepo.jobs[jobID]
|
||||
if job.Status != domain.JobStatusPending {
|
||||
t.Errorf("expected Pending after approval, got %s", job.Status)
|
||||
}
|
||||
})
|
||||
|
||||
// Seed another job for rejection
|
||||
rejectJobID := "job-reject-test-1"
|
||||
jobRepo.jobs[rejectJobID] = &domain.Job{
|
||||
ID: rejectJobID,
|
||||
CertificateID: "mc-test",
|
||||
Type: domain.JobTypeRenewal,
|
||||
Status: domain.JobStatusAwaitingApproval,
|
||||
MaxAttempts: 3,
|
||||
Attempts: 0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
t.Run("RejectJob_Success", func(t *testing.T) {
|
||||
payload := map[string]string{"reason": "Certificate no longer needed"}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/"+rejectJobID+"/reject", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Verify job moved to Cancelled
|
||||
job := jobRepo.jobs[rejectJobID]
|
||||
if job.Status != domain.JobStatusCancelled {
|
||||
t.Errorf("expected Cancelled after rejection, got %s", job.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ApproveNonexistentJob", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/job-ghost/approve", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ApproveNonAwaitingJob", func(t *testing.T) {
|
||||
// The first job is already Pending (approved earlier) — approving again should fail
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/"+jobID+"/approve", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Error("expected error when approving non-AwaitingApproval job")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNotificationEndpoints exercises the M3 notification API.
|
||||
func TestNotificationEndpoints(t *testing.T) {
|
||||
server, _, _, _ := setupTestServer(t)
|
||||
|
||||
t.Run("ListNotifications_Empty", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/notifications?page=1&per_page=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
total, ok := respBody["total"].(float64)
|
||||
if !ok {
|
||||
t.Log("Warning: total field not found or not a number")
|
||||
} else if total != 0 {
|
||||
t.Logf("Found %d notifications (expected 0 on fresh setup)", int(total))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCRLEndpoint exercises the CRL listing endpoint (M15a).
|
||||
func TestCRLEndpoint(t *testing.T) {
|
||||
server, _, _, _ := setupTestServer(t)
|
||||
|
||||
t.Run("GetCRL_JSON", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/crl")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var crl map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&crl)
|
||||
if crl["version"] == nil {
|
||||
t.Error("expected version field in CRL response")
|
||||
}
|
||||
if crl["entries"] == nil {
|
||||
t.Error("expected entries field in CRL response")
|
||||
}
|
||||
t.Logf("CRL response: version=%v, entries_count=%v", crl["version"], crl["total"])
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaginationAcrossEndpoints verifies pagination parameters work consistently.
|
||||
func TestPaginationAcrossEndpoints(t *testing.T) {
|
||||
server, _, _, _ := setupTestServer(t)
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"Certificates", "/api/v1/certificates?page=1&per_page=5"},
|
||||
{"Agents", "/api/v1/agents?page=1&per_page=5"},
|
||||
{"Jobs", "/api/v1/jobs?page=1&per_page=5"},
|
||||
{"Audit", "/api/v1/audit?page=1&per_page=5"},
|
||||
{"Notifications", "/api/v1/notifications?page=1&per_page=5"},
|
||||
{"Policies", "/api/v1/policies?page=1&per_page=5"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.name, func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + ep.url)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 200 for %s, got %d: %s", ep.name, resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssuerAndTargetCRUD exercises issuer and target CRUD lifecycle.
|
||||
func TestIssuerAndTargetCRUD(t *testing.T) {
|
||||
server, _, _, _ := setupTestServer(t)
|
||||
|
||||
// Issuer CRUD
|
||||
var issuerID string
|
||||
t.Run("CreateIssuer", func(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"id": "iss-test-ca",
|
||||
"name": "Test Local CA",
|
||||
"type": "GenericCA",
|
||||
"config": json.RawMessage(`{"ca_common_name": "Test CA"}`),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/issuers", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var issuer domain.Issuer
|
||||
json.NewDecoder(resp.Body).Decode(&issuer)
|
||||
issuerID = issuer.ID
|
||||
t.Logf("Created issuer: %s", issuerID)
|
||||
})
|
||||
|
||||
t.Run("GetIssuer", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/issuers/" + issuerID)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListIssuers", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/issuers")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
// Target CRUD
|
||||
var targetID string
|
||||
t.Run("CreateTarget", func(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"id": "t-test-nginx",
|
||||
"name": "Test NGINX",
|
||||
"type": "NGINX",
|
||||
"agent_id": "agent-1",
|
||||
"config": json.RawMessage(`{"cert_path": "/etc/nginx/ssl/cert.pem"}`),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/targets", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var target domain.DeploymentTarget
|
||||
json.NewDecoder(resp.Body).Decode(&target)
|
||||
targetID = target.ID
|
||||
t.Logf("Created target: %s", targetID)
|
||||
})
|
||||
|
||||
t.Run("GetTarget", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/targets/" + targetID)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteTarget", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/targets/"+targetID, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Accept either 200 or 204
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("expected 200 or 204, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteIssuer", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/issuers/"+issuerID, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("expected 200 or 204, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestM20EnhancedQueryAPI exercises M20 query API enhancements: sorting, time-range filters,
|
||||
// cursor pagination, sparse fields, profile/agent filters, and the deployments endpoint.
|
||||
func TestM20EnhancedQueryAPI(t *testing.T) {
|
||||
server, certRepo, _, _ := setupTestServer(t)
|
||||
|
||||
// Setup: Create a certificate for testing
|
||||
now := time.Now()
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-m20-test-1",
|
||||
Name: "M20 Test Cert",
|
||||
CommonName: "m20.example.com",
|
||||
Environment: "production",
|
||||
Status: domain.CertificateStatusActive,
|
||||
IssuerID: "iss-local",
|
||||
OwnerID: "owner-ops",
|
||||
TeamID: "team-platform",
|
||||
CertificateProfileID: "prof-standard",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
certRepo.certs["mc-m20-test-1"] = cert
|
||||
|
||||
t.Run("ListWithSortDescending", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?sort=-notAfter&page=1&per_page=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["data"]; !ok {
|
||||
t.Error("expected data field in response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListWithSortAscending", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?sort=createdAt&page=1&per_page=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["page"]; !ok {
|
||||
t.Error("expected page-based pagination response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TimeRangeFilter_ExpiresBefore", func(t *testing.T) {
|
||||
future := now.AddDate(0, 0, 365).Format(time.RFC3339)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_before=" + future)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TimeRangeFilter_ExpiresAfter", func(t *testing.T) {
|
||||
past := now.AddDate(0, 0, -90).Format(time.RFC3339)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_after=" + past)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TimeRangeFilter_CreatedAfter", func(t *testing.T) {
|
||||
past := now.AddDate(-1, 0, 0).Format(time.RFC3339)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?created_after=" + past)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SparseFields", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?fields=id,common_name,status")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if data, ok := respBody["data"].([]interface{}); ok && len(data) > 0 {
|
||||
firstCert, ok := data[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected cert object in data array")
|
||||
}
|
||||
// Should have requested fields
|
||||
if _, ok := firstCert["id"]; !ok {
|
||||
t.Error("expected 'id' field in sparse response")
|
||||
}
|
||||
// Should NOT have unrequested fields like 'environment'
|
||||
if _, ok := firstCert["environment"]; ok {
|
||||
t.Error("did not expect 'environment' field in sparse response")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ProfileFilter", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?profile_id=prof-standard")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AgentIDFilter", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?agent_id=agent-prod-001")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CursorPagination", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?cursor=abc123&page_size=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["next_cursor"]; !ok {
|
||||
t.Error("expected next_cursor field with cursor pagination")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CombinedFilters", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?status=Active&environment=production&profile_id=prof-standard&sort=-createdAt&per_page=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCertificateDeployments_Success", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-m20-test-1/deployments")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["data"]; !ok {
|
||||
t.Error("expected data field in response")
|
||||
}
|
||||
if _, ok := respBody["total"]; !ok {
|
||||
t.Error("expected total field in response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCertificateDeployments_NotFound", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-nonexistent-m20/deployments")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidTimeRange", func(t *testing.T) {
|
||||
// Invalid RFC3339 should be silently ignored (no filter applied)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_before=not-a-date&page=1&per_page=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200 (invalid time ignored), got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -52,7 +52,12 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
certificateService := service.NewCertificateService(certRepo, policyService, auditService)
|
||||
notificationService := service.NewNotificationService(notifRepo, make(map[string]service.Notifier))
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, auditService, notificationService, issuerRegistry, "server")
|
||||
revocationRepo := newMockRevocationRepository()
|
||||
certificateService.SetRevocationRepo(revocationRepo)
|
||||
certificateService.SetNotificationService(notificationService)
|
||||
certificateService.SetIssuerRegistry(issuerRegistry)
|
||||
certificateService.SetTargetRepo(targetRepo)
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
@@ -65,11 +70,17 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
agentHandler := handler.NewAgentHandler(agentService)
|
||||
jobHandler := handler.NewJobHandler(jobService)
|
||||
policyHandler := handler.NewPolicyHandler(policyService)
|
||||
profileHandler := handler.NewProfileHandler(&mockProfileService{})
|
||||
teamHandler := handler.NewTeamHandler(&mockTeamService{})
|
||||
ownerHandler := handler.NewOwnerHandler(&mockOwnerService{})
|
||||
agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{})
|
||||
auditHandler := handler.NewAuditHandler(auditService)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
statsHandler := handler.NewStatsHandler(&mockStatsService{})
|
||||
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
|
||||
healthHandler := handler.NewHealthHandler("none")
|
||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||
|
||||
// Create router and register handlers
|
||||
r := router.New()
|
||||
@@ -80,11 +91,17 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
agentHandler,
|
||||
jobHandler,
|
||||
policyHandler,
|
||||
profileHandler,
|
||||
teamHandler,
|
||||
ownerHandler,
|
||||
agentGroupHandler,
|
||||
auditHandler,
|
||||
notificationHandler,
|
||||
statsHandler,
|
||||
metricsHandler,
|
||||
healthHandler,
|
||||
discoveryHandler,
|
||||
networkScanHandler,
|
||||
)
|
||||
|
||||
// Create test server
|
||||
@@ -541,6 +558,14 @@ func (m *mockCertificateRepository) GetExpiringCertificates(ctx context.Context,
|
||||
return expiring, nil
|
||||
}
|
||||
|
||||
func (m *mockCertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
||||
versions := m.versions[certID]
|
||||
if len(versions) == 0 {
|
||||
return nil, fmt.Errorf("no versions found")
|
||||
}
|
||||
return versions[len(versions)-1], nil
|
||||
}
|
||||
|
||||
type mockJobRepository struct {
|
||||
jobs map[string]*domain.Job
|
||||
}
|
||||
@@ -684,7 +709,7 @@ func (m *mockAgentRepository) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentRepository) UpdateHeartbeat(ctx context.Context, id string) error {
|
||||
func (m *mockAgentRepository) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
|
||||
agent, ok := m.agents[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent not found")
|
||||
@@ -994,3 +1019,187 @@ func (m *mockOwnerService) UpdateOwner(id string, owner domain.Owner) (*domain.O
|
||||
func (m *mockOwnerService) DeleteOwner(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockProfileService struct{}
|
||||
|
||||
func (m *mockProfileService) ListProfiles(page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
||||
return []domain.CertificateProfile{}, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockProfileService) GetProfile(id string) (*domain.CertificateProfile, error) {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
func (m *mockProfileService) CreateProfile(profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
func (m *mockProfileService) UpdateProfile(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
||||
profile.ID = id
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
func (m *mockProfileService) DeleteProfile(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockAgentGroupService struct{}
|
||||
|
||||
func (m *mockAgentGroupService) ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) {
|
||||
return []domain.AgentGroup{}, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentGroupService) GetAgentGroup(id string) (*domain.AgentGroup, error) {
|
||||
return nil, fmt.Errorf("agent group not found")
|
||||
}
|
||||
|
||||
func (m *mockAgentGroupService) CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentGroupService) UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||
group.ID = id
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentGroupService) DeleteAgentGroup(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) {
|
||||
return []domain.Agent{}, 0, nil
|
||||
}
|
||||
|
||||
// mockRevocationRepository is a test implementation of RevocationRepository for integration tests.
|
||||
type mockRevocationRepository struct {
|
||||
revocations []*domain.CertificateRevocation
|
||||
}
|
||||
|
||||
func newMockRevocationRepository() *mockRevocationRepository {
|
||||
return &mockRevocationRepository{
|
||||
revocations: make([]*domain.CertificateRevocation, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
|
||||
m.revocations = append(m.revocations, revocation)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) GetBySerial(ctx context.Context, serial string) (*domain.CertificateRevocation, error) {
|
||||
for _, r := range m.revocations {
|
||||
if r.SerialNumber == serial {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("revocation not found")
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
|
||||
return m.revocations, nil
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
|
||||
var result []*domain.CertificateRevocation
|
||||
for _, r := range m.revocations {
|
||||
if r.CertificateID == certID {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) MarkIssuerNotified(ctx context.Context, id string) error {
|
||||
for _, r := range m.revocations {
|
||||
if r.ID == id {
|
||||
r.IssuerNotified = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("revocation not found")
|
||||
}
|
||||
|
||||
// mockStatsService implements both handler.StatsService and handler.MetricsService for integration tests.
|
||||
type mockStatsService struct{}
|
||||
|
||||
func (m *mockStatsService) GetDashboardSummary(ctx context.Context) (interface{}, error) {
|
||||
return &handler.DashboardSummary{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetCertificatesByStatus(ctx context.Context) (interface{}, error) {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetExpirationTimeline(ctx context.Context, days int) (interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetJobStats(ctx context.Context, days int) (interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
// mockDiscoveryService implements handler.DiscoveryService for integration tests.
|
||||
type mockDiscoveryService struct{}
|
||||
|
||||
func (m *mockDiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
|
||||
return &domain.DiscoveryScan{ID: "dscan-test"}, nil
|
||||
}
|
||||
|
||||
func (m *mockDiscoveryService) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockDiscoveryService) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (m *mockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDiscoveryService) DismissDiscovered(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDiscoveryService) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockDiscoveryService) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (m *mockDiscoveryService) GetDiscoverySummary(ctx context.Context) (map[string]int, error) {
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
// mockNetworkScanService implements handler.NetworkScanService for integration tests.
|
||||
type mockNetworkScanService struct{}
|
||||
|
||||
func (m *mockNetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -39,11 +40,18 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
||||
}
|
||||
|
||||
revocationRepo := newMockRevocationRepository()
|
||||
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
certificateService := service.NewCertificateService(certRepo, policyService, auditService)
|
||||
notificationService := service.NewNotificationService(notifRepo, make(map[string]service.Notifier))
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, auditService, notificationService, issuerRegistry, "server")
|
||||
|
||||
// Wire revocation dependencies
|
||||
certificateService.SetRevocationRepo(revocationRepo)
|
||||
certificateService.SetNotificationService(notificationService)
|
||||
certificateService.SetIssuerRegistry(issuerRegistry)
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
@@ -55,11 +63,17 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
agentHandler := handler.NewAgentHandler(agentService)
|
||||
jobHandler := handler.NewJobHandler(jobService)
|
||||
policyHandler := handler.NewPolicyHandler(policyService)
|
||||
profileHandler := handler.NewProfileHandler(&mockProfileService{})
|
||||
teamHandler := handler.NewTeamHandler(&mockTeamService{})
|
||||
ownerHandler := handler.NewOwnerHandler(&mockOwnerService{})
|
||||
agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{})
|
||||
auditHandler := handler.NewAuditHandler(auditService)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
statsHandler := handler.NewStatsHandler(&mockStatsService{})
|
||||
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
|
||||
healthHandler := handler.NewHealthHandler("none")
|
||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||
|
||||
r := router.New()
|
||||
r.RegisterHandlers(
|
||||
@@ -69,11 +83,17 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
agentHandler,
|
||||
jobHandler,
|
||||
policyHandler,
|
||||
profileHandler,
|
||||
teamHandler,
|
||||
ownerHandler,
|
||||
agentGroupHandler,
|
||||
auditHandler,
|
||||
notificationHandler,
|
||||
statsHandler,
|
||||
metricsHandler,
|
||||
healthHandler,
|
||||
discoveryHandler,
|
||||
networkScanHandler,
|
||||
)
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
@@ -388,3 +408,395 @@ func TestCertificateLifecycleWithExpiredCert(t *testing.T) {
|
||||
t.Logf("Renewal trigger on expired cert returned status: %d", resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestM11bEndpoints exercises the M11b endpoints: teams, owners, agent groups.
|
||||
// Tests M11b feature coverage through the HTTP API.
|
||||
func TestM11bEndpoints(t *testing.T) {
|
||||
server, _, _, _ := setupTestServer(t)
|
||||
|
||||
// ========================
|
||||
// Teams API
|
||||
// ========================
|
||||
t.Run("Teams", func(t *testing.T) {
|
||||
t.Run("CreateTeam_Success", func(t *testing.T) {
|
||||
payload := map[string]string{"name": "Platform", "description": "Platform team"}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var team domain.Team
|
||||
json.NewDecoder(resp.Body).Decode(&team)
|
||||
if team.Name != "Platform" {
|
||||
t.Errorf("expected name=Platform, got %s", team.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateTeam_MissingName", func(t *testing.T) {
|
||||
payload := map[string]string{"description": "No name team"}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateTeam_NameTooLong", func(t *testing.T) {
|
||||
longName := ""
|
||||
for i := 0; i < 256; i++ {
|
||||
longName += "a"
|
||||
}
|
||||
payload := map[string]string{"name": longName}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateTeam_InvalidJSON", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader([]byte("not json")))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetTeam_NotFound", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/teams/t-nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListTeams_Empty", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/teams")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteTeam_Success", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/teams/t-platform", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListTeams_MethodNotAllowed", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/teams", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ========================
|
||||
// Owners API
|
||||
// ========================
|
||||
t.Run("Owners", func(t *testing.T) {
|
||||
t.Run("CreateOwner_Success", func(t *testing.T) {
|
||||
payload := map[string]string{"name": "Alice", "email": "alice@example.com", "team_id": "t-platform"}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/owners", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var owner domain.Owner
|
||||
json.NewDecoder(resp.Body).Decode(&owner)
|
||||
if owner.Name != "Alice" {
|
||||
t.Errorf("expected name=Alice, got %s", owner.Name)
|
||||
}
|
||||
if owner.Email != "alice@example.com" {
|
||||
t.Errorf("expected email=alice@example.com, got %s", owner.Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateOwner_MissingName", func(t *testing.T) {
|
||||
payload := map[string]string{"email": "bob@example.com"}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/owners", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOwner_NotFound", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/owners/o-nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListOwners_Empty", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/owners")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteOwner_Success", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/owners/o-alice", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ========================
|
||||
// Agent Groups API
|
||||
// ========================
|
||||
t.Run("AgentGroups", func(t *testing.T) {
|
||||
t.Run("CreateAgentGroup_Success", func(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"name": "Linux Servers",
|
||||
"description": "All linux-based agents",
|
||||
"match_os": "linux",
|
||||
"match_architecture": "amd64",
|
||||
"enabled": true,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/agent-groups", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var group domain.AgentGroup
|
||||
json.NewDecoder(resp.Body).Decode(&group)
|
||||
if group.Name != "Linux Servers" {
|
||||
t.Errorf("expected name=Linux Servers, got %s", group.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateAgentGroup_MissingName", func(t *testing.T) {
|
||||
payload := map[string]string{"description": "No name group"}
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(server.URL+"/api/v1/agent-groups", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetAgentGroup_NotFound", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/agent-groups/ag-nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListAgentGroups_Empty", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/agent-groups")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteAgentGroup_Success", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/agent-groups/ag-linux", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListAgentGroupMembers_Empty", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/agent-groups/ag-linux/members")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestRevocationEndpoints exercises the revocation API endpoints through a full integration stack.
|
||||
func TestRevocationEndpoints(t *testing.T) {
|
||||
server, certRepo, _, _ := setupTestServer(t)
|
||||
|
||||
// Create a test certificate with a version
|
||||
now := time.Now()
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-revoke-test",
|
||||
Name: "Revocation Test Cert",
|
||||
CommonName: "revoke-test.example.com",
|
||||
SANs: []string{},
|
||||
Environment: "test",
|
||||
OwnerID: "owner-test",
|
||||
TeamID: "team-test",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "policy-1",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: now.AddDate(0, 6, 0),
|
||||
Tags: map[string]string{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
certRepo.certs["mc-revoke-test"] = cert
|
||||
certRepo.versions["mc-revoke-test"] = []*domain.CertificateVersion{
|
||||
{
|
||||
ID: "cv-revoke-test",
|
||||
CertificateID: "mc-revoke-test",
|
||||
SerialNumber: "REVOKE-SERIAL-001",
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(1, 0, 0),
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"reason":"keyCompromise"}`)
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-revoke-test/revoke", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result["status"] != "revoked" {
|
||||
t.Errorf("expected status 'revoked', got %s", result["status"])
|
||||
}
|
||||
|
||||
// Verify certificate status updated
|
||||
if cert.Status != domain.CertificateStatusRevoked {
|
||||
t.Errorf("expected Revoked status, got %s", cert.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_AlreadyRevoked", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"reason":"superseded"}`)
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-revoke-test/revoke", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for already revoked, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_NotFound", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-nonexistent/revoke", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCRL_Success", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/crl")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var crl map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&crl)
|
||||
|
||||
if crl["version"] != float64(1) {
|
||||
t.Errorf("expected CRL version 1, got %v", crl["version"])
|
||||
}
|
||||
|
||||
// Should have at least 1 entry from the revocation above
|
||||
total, _ := crl["total"].(float64)
|
||||
if total < 1 {
|
||||
t.Errorf("expected at least 1 CRL entry, got %v", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// mockNetworkScanService is defined in lifecycle_test.go (same package)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a thin HTTP client that forwards requests to the certctl REST API.
|
||||
// It handles auth, base URL resolution, and JSON marshaling.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new certctl API client.
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get performs an HTTP GET and returns the raw JSON response body.
|
||||
func (c *Client) Get(path string, query url.Values) (json.RawMessage, error) {
|
||||
return c.do("GET", path, query, nil)
|
||||
}
|
||||
|
||||
// Post performs an HTTP POST with a JSON body and returns the raw JSON response.
|
||||
func (c *Client) Post(path string, body interface{}) (json.RawMessage, error) {
|
||||
return c.do("POST", path, nil, body)
|
||||
}
|
||||
|
||||
// Put performs an HTTP PUT with a JSON body and returns the raw JSON response.
|
||||
func (c *Client) Put(path string, body interface{}) (json.RawMessage, error) {
|
||||
return c.do("PUT", path, nil, body)
|
||||
}
|
||||
|
||||
// Delete performs an HTTP DELETE and returns the raw JSON response (may be empty for 204).
|
||||
func (c *Client) Delete(path string) (json.RawMessage, error) {
|
||||
return c.do("DELETE", path, nil, nil)
|
||||
}
|
||||
|
||||
// GetRaw performs an HTTP GET and returns the raw response body bytes and content type.
|
||||
// Used for binary responses (DER CRL, OCSP).
|
||||
func (c *Client) GetRaw(path string) ([]byte, string, error) {
|
||||
u, err := url.JoinPath(c.baseURL, path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, query url.Values, body interface{}) (json.RawMessage, error) {
|
||||
u, err := url.JoinPath(c.baseURL, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if query != nil && len(query) > 0 {
|
||||
u = u + "?" + query.Encode()
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
// 204 No Content — return empty JSON object
|
||||
if resp.StatusCode == 204 {
|
||||
return json.RawMessage(`{"status":"deleted"}`), nil
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return json.RawMessage(respBody), nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
c := NewClient("http://localhost:8443", "test-key")
|
||||
if c.baseURL != "http://localhost:8443" {
|
||||
t.Errorf("expected baseURL http://localhost:8443, got %s", c.baseURL)
|
||||
}
|
||||
if c.apiKey != "test-key" {
|
||||
t.Errorf("expected apiKey test-key, got %s", c.apiKey)
|
||||
}
|
||||
if c.httpClient == nil {
|
||||
t.Fatal("expected httpClient to be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Get(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
t.Errorf("expected Bearer test-key auth, got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
t.Errorf("expected Accept application/json, got %s", r.Header.Get("Accept"))
|
||||
}
|
||||
if r.URL.Query().Get("status") != "Active" {
|
||||
t.Errorf("expected status=Active query param, got %s", r.URL.Query().Get("status"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": []interface{}{},
|
||||
"total": 0,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
data, err := c.Get("/api/v1/certificates", map[string][]string{"status": {"Active"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil response data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Get_NoAuth(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("expected no auth header, got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "")
|
||||
_, err := c.Get("/api/v1/certificates", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Post(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
if parsed["name"] != "test-cert" {
|
||||
t.Errorf("expected name=test-cert, got %v", parsed["name"])
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": "mc-test"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
data, err := c.Post("/api/v1/certificates", map[string]string{"name": "test-cert"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if result["id"] != "mc-test" {
|
||||
t.Errorf("expected id=mc-test, got %s", result["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Put(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
t.Errorf("expected PUT, got %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":"mc-test","name":"updated"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
data, err := c.Put("/api/v1/certificates/mc-test", map[string]string{"name": "updated"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil response data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Delete_204(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
data, err := c.Delete("/api/v1/certificates/mc-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if result["status"] != "deleted" {
|
||||
t.Errorf("expected status=deleted for 204, got %s", result["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ErrorResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
_, err := c.Get("/api/v1/certificates/nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
}
|
||||
expected := "API error (HTTP 404)"
|
||||
if !containsStr(err.Error(), expected) {
|
||||
t.Errorf("expected error containing %q, got %q", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
_, err := c.Post("/api/v1/certificates", map[string]string{"name": "test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
expected := "API error (HTTP 500)"
|
||||
if !containsStr(err.Error(), expected) {
|
||||
t.Errorf("expected error containing %q, got %q", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetRaw(t *testing.T) {
|
||||
derData := []byte{0x30, 0x82, 0x01, 0x00} // fake DER bytes
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pkix-crl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(derData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
data, contentType, err := c.GetRaw("/api/v1/crl/iss-local")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if contentType != "application/pkix-crl" {
|
||||
t.Errorf("expected content-type application/pkix-crl, got %s", contentType)
|
||||
}
|
||||
if len(data) != len(derData) {
|
||||
t.Errorf("expected %d bytes, got %d", len(derData), len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetRaw_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("issuer not found"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
_, _, err := c.GetRaw("/api/v1/crl/nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ConnectionRefused(t *testing.T) {
|
||||
c := NewClient("http://localhost:1", "test-key")
|
||||
_, err := c.Get("/api/v1/certificates", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for connection refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PostNilBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-Type") != "" {
|
||||
t.Errorf("expected no Content-Type for nil body, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte(`{"status":"accepted"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
data, err := c.Post("/api/v1/certificates/mc-test/renew", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_QueryParams(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("page") != "2" {
|
||||
t.Errorf("expected page=2, got %s", r.URL.Query().Get("page"))
|
||||
}
|
||||
if r.URL.Query().Get("per_page") != "10" {
|
||||
t.Errorf("expected per_page=10, got %s", r.URL.Query().Get("per_page"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL, "test-key")
|
||||
q := paginationQuery(2, 10)
|
||||
_, err := c.Get("/api/v1/certificates", q)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// containsStr is a simple helper to avoid importing strings in tests.
|
||||
func containsStr(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,412 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// requestLog captures HTTP requests made by MCP tool handlers.
|
||||
type requestLog struct {
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
}
|
||||
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (rl *requestLog) add(r capturedRequest) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
rl.requests = append(rl.requests, r)
|
||||
}
|
||||
|
||||
func (rl *requestLog) last() capturedRequest {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
if len(rl.requests) == 0 {
|
||||
return capturedRequest{}
|
||||
}
|
||||
return rl.requests[len(rl.requests)-1]
|
||||
}
|
||||
|
||||
// mockCertctlAPI returns a test server that records all requests and returns
|
||||
// canned JSON responses based on the path.
|
||||
func mockCertctlAPI(log *requestLog) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := ""
|
||||
if r.Body != nil {
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
body = string(buf[:n])
|
||||
}
|
||||
|
||||
log.add(capturedRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case r.Method == "DELETE":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.HasSuffix(r.URL.Path, "/renew") || strings.HasSuffix(r.URL.Path, "/deploy"):
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "accepted", "job_id": "job-001"})
|
||||
case r.Method == "POST":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": "new-resource"})
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": []interface{}{map[string]string{"id": "test-1"}},
|
||||
"total": 1,
|
||||
})
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRegisterTools_ToolCount(t *testing.T) {
|
||||
server := gomcp.NewServer(&gomcp.Implementation{
|
||||
Name: "certctl-test",
|
||||
Version: "test",
|
||||
}, nil)
|
||||
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
RegisterTools(server, client)
|
||||
|
||||
// The server should have tools registered — we can verify by listing them
|
||||
// Since the SDK doesn't expose a tool count method, we verify through the
|
||||
// request capabilities
|
||||
t.Log("RegisterTools completed without panic")
|
||||
}
|
||||
|
||||
func TestPaginationQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
perPage int
|
||||
wantLen int
|
||||
}{
|
||||
{"both set", 2, 50, 2},
|
||||
{"page only", 3, 0, 1},
|
||||
{"per_page only", 0, 100, 1},
|
||||
{"neither set", 0, 0, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
q := paginationQuery(tt.page, tt.perPage)
|
||||
if len(q) != tt.wantLen {
|
||||
t.Errorf("expected %d query params, got %d", tt.wantLen, len(q))
|
||||
}
|
||||
if tt.page > 0 {
|
||||
if q.Get("page") != string(rune('0'+tt.page)) && q.Get("page") == "" {
|
||||
t.Errorf("expected page param to be set")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextResult(t *testing.T) {
|
||||
data := json.RawMessage(`{"id":"mc-test","status":"Active"}`)
|
||||
result, metadata, err := textResult(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if metadata != nil {
|
||||
t.Errorf("expected nil metadata, got %v", metadata)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if len(result.Content) != 1 {
|
||||
t.Fatalf("expected 1 content item, got %d", len(result.Content))
|
||||
}
|
||||
tc, ok := result.Content[0].(*gomcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatal("expected TextContent type")
|
||||
}
|
||||
if tc.Text != `{"id":"mc-test","status":"Active"}` {
|
||||
t.Errorf("unexpected text content: %s", tc.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResult(t *testing.T) {
|
||||
result, _, err := errorResult(http.ErrServerClosed)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result, got %v", result)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolEndToEnd_ListCertificates verifies the full flow:
|
||||
// MCP tool handler → HTTP client → mock API → response formatting
|
||||
func TestToolEndToEnd_ListCertificates(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
|
||||
// Manually call the handler logic that would be registered as a tool
|
||||
q := paginationQuery(1, 50)
|
||||
q.Set("status", "Active")
|
||||
data, err := client.Get("/api/v1/certificates", q)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := log.last()
|
||||
if req.Method != "GET" {
|
||||
t.Errorf("expected GET, got %s", req.Method)
|
||||
}
|
||||
if req.Path != "/api/v1/certificates" {
|
||||
t.Errorf("expected path /api/v1/certificates, got %s", req.Path)
|
||||
}
|
||||
if !strings.Contains(req.Query, "status=Active") {
|
||||
t.Errorf("expected status=Active in query, got %s", req.Query)
|
||||
}
|
||||
if !strings.Contains(req.Query, "page=1") {
|
||||
t.Errorf("expected page=1 in query, got %s", req.Query)
|
||||
}
|
||||
|
||||
result, _, err := textResult(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error formatting result: %v", err)
|
||||
}
|
||||
if len(result.Content) != 1 {
|
||||
t.Fatalf("expected 1 content item, got %d", len(result.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_CreateCertificate(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
|
||||
input := CreateCertificateInput{
|
||||
Name: "API Production",
|
||||
CommonName: "api.example.com",
|
||||
IssuerID: "iss-local",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "team-platform",
|
||||
}
|
||||
|
||||
data, err := client.Post("/api/v1/certificates", input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := log.last()
|
||||
if req.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", req.Method)
|
||||
}
|
||||
if req.Path != "/api/v1/certificates" {
|
||||
t.Errorf("expected path /api/v1/certificates, got %s", req.Path)
|
||||
}
|
||||
if !strings.Contains(req.Body, "api.example.com") {
|
||||
t.Errorf("expected common_name in body, got %s", req.Body)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if result["id"] != "new-resource" {
|
||||
t.Errorf("expected id=new-resource, got %s", result["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_TriggerRenewal(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
data, err := client.Post("/api/v1/certificates/mc-api-prod/renew", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := log.last()
|
||||
if req.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", req.Method)
|
||||
}
|
||||
if req.Path != "/api/v1/certificates/mc-api-prod/renew" {
|
||||
t.Errorf("expected path /api/v1/certificates/mc-api-prod/renew, got %s", req.Path)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if result["job_id"] != "job-001" {
|
||||
t.Errorf("expected job_id=job-001, got %s", result["job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_DeleteTarget(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
data, err := client.Delete("/api/v1/targets/t-platform")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := log.last()
|
||||
if req.Method != "DELETE" {
|
||||
t.Errorf("expected DELETE, got %s", req.Method)
|
||||
}
|
||||
if req.Path != "/api/v1/targets/t-platform" {
|
||||
t.Errorf("expected path /api/v1/targets/t-platform, got %s", req.Path)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if result["status"] != "deleted" {
|
||||
t.Errorf("expected status=deleted, got %s", result["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_RevokeCertificate(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
input := RevokeCertificateInput{
|
||||
ID: "mc-api-prod",
|
||||
Reason: "keyCompromise",
|
||||
}
|
||||
_, err := client.Post("/api/v1/certificates/"+input.ID+"/revoke", map[string]string{"reason": input.Reason})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := log.last()
|
||||
if req.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", req.Method)
|
||||
}
|
||||
if req.Path != "/api/v1/certificates/mc-api-prod/revoke" {
|
||||
t.Errorf("expected path /api/v1/certificates/mc-api-prod/revoke, got %s", req.Path)
|
||||
}
|
||||
if !strings.Contains(req.Body, "keyCompromise") {
|
||||
t.Errorf("expected reason in body, got %s", req.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_AgentHeartbeat(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
_, err := client.Post("/api/v1/agents/agent-001/heartbeat", map[string]string{
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := log.last()
|
||||
if req.Path != "/api/v1/agents/agent-001/heartbeat" {
|
||||
t.Errorf("expected path /api/v1/agents/agent-001/heartbeat, got %s", req.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_ListWithFilters(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := mockCertctlAPI(log)
|
||||
defer api.Close()
|
||||
|
||||
client := NewClient(api.URL, "test-key")
|
||||
q := paginationQuery(1, 25)
|
||||
q.Set("status", "Pending")
|
||||
q.Set("type", "Renewal")
|
||||
_, err := client.Get("/api/v1/jobs", q)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := log.last()
|
||||
if req.Path != "/api/v1/jobs" {
|
||||
t.Errorf("expected path /api/v1/jobs, got %s", req.Path)
|
||||
}
|
||||
if !strings.Contains(req.Query, "status=Pending") {
|
||||
t.Errorf("expected status filter in query, got %s", req.Query)
|
||||
}
|
||||
if !strings.Contains(req.Query, "type=Renewal") {
|
||||
t.Errorf("expected type filter in query, got %s", req.Query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_GetRawBinary(t *testing.T) {
|
||||
derData := []byte{0x30, 0x82, 0x01, 0x22}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pkix-crl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(derData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
data, ct, err := client.GetRaw("/api/v1/crl/iss-local")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ct != "application/pkix-crl" {
|
||||
t.Errorf("expected content-type application/pkix-crl, got %s", ct)
|
||||
}
|
||||
if len(data) != 4 {
|
||||
t.Errorf("expected 4 bytes, got %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolEndToEnd_ErrorPropagation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"forbidden"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
_, err := client.Get("/api/v1/certificates", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 response")
|
||||
}
|
||||
result, _, toolErr := errorResult(err)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result from errorResult")
|
||||
}
|
||||
if toolErr == nil {
|
||||
t.Fatal("expected non-nil error from errorResult")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package mcp
|
||||
|
||||
// Input types for MCP tool arguments.
|
||||
// The jsonschema struct tags provide descriptions for LLM tool discovery.
|
||||
|
||||
// ── Pagination ──────────────────────────────────────────────────────
|
||||
|
||||
type ListParams struct {
|
||||
Page int `json:"page,omitempty" jsonschema:"Page number (default 1)"`
|
||||
PerPage int `json:"per_page,omitempty" jsonschema:"Results per page (default 50, max 500)"`
|
||||
}
|
||||
|
||||
// ── Certificates ────────────────────────────────────────────────────
|
||||
|
||||
type ListCertificatesInput struct {
|
||||
ListParams
|
||||
Status string `json:"status,omitempty" jsonschema:"Filter by status: Pending, Active, Expiring, Expired, RenewalInProgress, Failed, Revoked, Archived"`
|
||||
Environment string `json:"environment,omitempty" jsonschema:"Filter by environment"`
|
||||
OwnerID string `json:"owner_id,omitempty" jsonschema:"Filter by owner ID"`
|
||||
TeamID string `json:"team_id,omitempty" jsonschema:"Filter by team ID"`
|
||||
IssuerID string `json:"issuer_id,omitempty" jsonschema:"Filter by issuer ID"`
|
||||
}
|
||||
|
||||
type GetByIDInput struct {
|
||||
ID string `json:"id" jsonschema:"Resource ID (e.g. mc-api-prod, t-platform)"`
|
||||
}
|
||||
|
||||
type CreateCertificateInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Certificate ID (auto-generated if empty)"`
|
||||
Name string `json:"name" jsonschema:"Display name"`
|
||||
CommonName string `json:"common_name" jsonschema:"Certificate common name (e.g. api.example.com)"`
|
||||
SANs []string `json:"sans,omitempty" jsonschema:"Subject Alternative Names"`
|
||||
Environment string `json:"environment,omitempty" jsonschema:"Environment (e.g. production, staging)"`
|
||||
OwnerID string `json:"owner_id" jsonschema:"Owner ID (required)"`
|
||||
TeamID string `json:"team_id" jsonschema:"Team ID (required)"`
|
||||
IssuerID string `json:"issuer_id" jsonschema:"Issuer connector ID"`
|
||||
TargetIDs []string `json:"target_ids,omitempty" jsonschema:"Deployment target IDs"`
|
||||
RenewalPolicyID string `json:"renewal_policy_id,omitempty" jsonschema:"Renewal policy ID"`
|
||||
ProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Certificate profile ID"`
|
||||
Tags map[string]string `json:"tags,omitempty" jsonschema:"Key-value tags"`
|
||||
}
|
||||
|
||||
type UpdateCertificateInput struct {
|
||||
ID string `json:"id" jsonschema:"Certificate ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Display name"`
|
||||
Environment string `json:"environment,omitempty" jsonschema:"Environment"`
|
||||
OwnerID string `json:"owner_id,omitempty" jsonschema:"Owner ID"`
|
||||
TeamID string `json:"team_id,omitempty" jsonschema:"Team ID"`
|
||||
TargetIDs []string `json:"target_ids,omitempty" jsonschema:"Deployment target IDs"`
|
||||
RenewalPolicyID string `json:"renewal_policy_id,omitempty" jsonschema:"Renewal policy ID"`
|
||||
ProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Certificate profile ID"`
|
||||
Tags map[string]string `json:"tags,omitempty" jsonschema:"Key-value tags"`
|
||||
}
|
||||
|
||||
type TriggerDeploymentInput struct {
|
||||
ID string `json:"id" jsonschema:"Certificate ID"`
|
||||
TargetID string `json:"target_id,omitempty" jsonschema:"Optional specific target ID"`
|
||||
}
|
||||
|
||||
type RevokeCertificateInput struct {
|
||||
ID string `json:"id" jsonschema:"Certificate ID to revoke"`
|
||||
Reason string `json:"reason,omitempty" jsonschema:"RFC 5280 reason: unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn"`
|
||||
}
|
||||
|
||||
type ListVersionsInput struct {
|
||||
ID string `json:"id" jsonschema:"Certificate ID"`
|
||||
ListParams
|
||||
}
|
||||
|
||||
// ── CRL & OCSP ──────────────────────────────────────────────────────
|
||||
|
||||
type GetDERCRLInput struct {
|
||||
IssuerID string `json:"issuer_id" jsonschema:"Issuer ID for DER-encoded CRL"`
|
||||
}
|
||||
|
||||
type OCSPInput struct {
|
||||
IssuerID string `json:"issuer_id" jsonschema:"Issuer ID"`
|
||||
Serial string `json:"serial" jsonschema:"Hex-encoded certificate serial number"`
|
||||
}
|
||||
|
||||
// ── Issuers ─────────────────────────────────────────────────────────
|
||||
|
||||
type CreateIssuerInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Issuer ID"`
|
||||
Name string `json:"name" jsonschema:"Issuer display name"`
|
||||
Type string `json:"type" jsonschema:"Issuer type: ACME, GenericCA, StepCA"`
|
||||
Config interface{} `json:"config,omitempty" jsonschema:"Issuer-specific configuration"`
|
||||
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the issuer is enabled"`
|
||||
}
|
||||
|
||||
type UpdateIssuerInput struct {
|
||||
ID string `json:"id" jsonschema:"Issuer ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Issuer display name"`
|
||||
Type string `json:"type,omitempty" jsonschema:"Issuer type"`
|
||||
Config interface{} `json:"config,omitempty" jsonschema:"Issuer-specific configuration"`
|
||||
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the issuer is enabled"`
|
||||
}
|
||||
|
||||
// ── Targets ─────────────────────────────────────────────────────────
|
||||
|
||||
type CreateTargetInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Target ID"`
|
||||
Name string `json:"name" jsonschema:"Target display name"`
|
||||
Type string `json:"type" jsonschema:"Target type: NGINX, Apache, HAProxy, F5, IIS"`
|
||||
AgentID string `json:"agent_id,omitempty" jsonschema:"Agent ID that manages this target"`
|
||||
Config interface{} `json:"config,omitempty" jsonschema:"Target-specific configuration"`
|
||||
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the target is enabled"`
|
||||
}
|
||||
|
||||
type UpdateTargetInput struct {
|
||||
ID string `json:"id" jsonschema:"Target ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Target display name"`
|
||||
Type string `json:"type,omitempty" jsonschema:"Target type"`
|
||||
AgentID string `json:"agent_id,omitempty" jsonschema:"Agent ID"`
|
||||
Config interface{} `json:"config,omitempty" jsonschema:"Target-specific configuration"`
|
||||
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the target is enabled"`
|
||||
}
|
||||
|
||||
// ── Agents ──────────────────────────────────────────────────────────
|
||||
|
||||
type RegisterAgentInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Agent ID"`
|
||||
Name string `json:"name" jsonschema:"Agent display name"`
|
||||
Hostname string `json:"hostname" jsonschema:"Agent hostname"`
|
||||
}
|
||||
|
||||
type AgentCSRInput struct {
|
||||
AgentID string `json:"agent_id" jsonschema:"Agent ID"`
|
||||
CSRPEM string `json:"csr_pem" jsonschema:"PEM-encoded certificate signing request"`
|
||||
CertificateID string `json:"certificate_id,omitempty" jsonschema:"Certificate ID for the CSR"`
|
||||
}
|
||||
|
||||
type AgentPickupInput struct {
|
||||
AgentID string `json:"agent_id" jsonschema:"Agent ID"`
|
||||
CertID string `json:"cert_id" jsonschema:"Certificate ID to pick up"`
|
||||
}
|
||||
|
||||
type AgentJobStatusInput struct {
|
||||
AgentID string `json:"agent_id" jsonschema:"Agent ID"`
|
||||
JobID string `json:"job_id" jsonschema:"Job ID"`
|
||||
Status string `json:"status" jsonschema:"Job status to report"`
|
||||
Error string `json:"error,omitempty" jsonschema:"Error message if job failed"`
|
||||
}
|
||||
|
||||
// ── Jobs ────────────────────────────────────────────────────────────
|
||||
|
||||
type ListJobsInput struct {
|
||||
ListParams
|
||||
Status string `json:"status,omitempty" jsonschema:"Filter by status: Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled"`
|
||||
Type string `json:"type,omitempty" jsonschema:"Filter by type: Issuance, Renewal, Deployment, Validation"`
|
||||
}
|
||||
|
||||
type RejectJobInput struct {
|
||||
ID string `json:"id" jsonschema:"Job ID to reject"`
|
||||
Reason string `json:"reason,omitempty" jsonschema:"Reason for rejection"`
|
||||
}
|
||||
|
||||
// ── Policies ────────────────────────────────────────────────────────
|
||||
|
||||
type CreatePolicyInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Policy ID"`
|
||||
Name string `json:"name" jsonschema:"Policy display name"`
|
||||
Type string `json:"type" jsonschema:"Policy type: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"`
|
||||
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
|
||||
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
|
||||
}
|
||||
|
||||
type UpdatePolicyInput struct {
|
||||
ID string `json:"id" jsonschema:"Policy ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Policy display name"`
|
||||
Type string `json:"type,omitempty" jsonschema:"Policy type"`
|
||||
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
|
||||
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
|
||||
}
|
||||
|
||||
type ListViolationsInput struct {
|
||||
ID string `json:"id" jsonschema:"Policy ID"`
|
||||
ListParams
|
||||
}
|
||||
|
||||
// ── Profiles ────────────────────────────────────────────────────────
|
||||
|
||||
type CreateProfileInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Profile ID"`
|
||||
Name string `json:"name" jsonschema:"Profile display name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"Profile description"`
|
||||
AllowedKeyAlgorithms interface{} `json:"allowed_key_algorithms,omitempty" jsonschema:"Allowed key algorithms and minimum sizes"`
|
||||
MaxTTLSeconds int `json:"max_ttl_seconds,omitempty" jsonschema:"Maximum certificate TTL in seconds"`
|
||||
AllowedEKUs []string `json:"allowed_ekus,omitempty" jsonschema:"Allowed Extended Key Usages"`
|
||||
RequiredSANPatterns []string `json:"required_san_patterns,omitempty" jsonschema:"Required SAN patterns"`
|
||||
AllowShortLived bool `json:"allow_short_lived,omitempty" jsonschema:"Allow short-lived certificates (TTL < 1 hour)"`
|
||||
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the profile is enabled"`
|
||||
}
|
||||
|
||||
type UpdateProfileInput struct {
|
||||
ID string `json:"id" jsonschema:"Profile ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Profile display name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"Profile description"`
|
||||
AllowedKeyAlgorithms interface{} `json:"allowed_key_algorithms,omitempty" jsonschema:"Allowed key algorithms and minimum sizes"`
|
||||
MaxTTLSeconds *int `json:"max_ttl_seconds,omitempty" jsonschema:"Maximum certificate TTL in seconds"`
|
||||
AllowedEKUs []string `json:"allowed_ekus,omitempty" jsonschema:"Allowed Extended Key Usages"`
|
||||
RequiredSANPatterns []string `json:"required_san_patterns,omitempty" jsonschema:"Required SAN patterns"`
|
||||
AllowShortLived *bool `json:"allow_short_lived,omitempty" jsonschema:"Allow short-lived certificates"`
|
||||
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the profile is enabled"`
|
||||
}
|
||||
|
||||
// ── Teams ───────────────────────────────────────────────────────────
|
||||
|
||||
type CreateTeamInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Team ID"`
|
||||
Name string `json:"name" jsonschema:"Team name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"Team description"`
|
||||
}
|
||||
|
||||
type UpdateTeamInput struct {
|
||||
ID string `json:"id" jsonschema:"Team ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Team name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"Team description"`
|
||||
}
|
||||
|
||||
// ── Owners ──────────────────────────────────────────────────────────
|
||||
|
||||
type CreateOwnerInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Owner ID"`
|
||||
Name string `json:"name" jsonschema:"Owner display name"`
|
||||
Email string `json:"email,omitempty" jsonschema:"Owner email for notifications"`
|
||||
TeamID string `json:"team_id,omitempty" jsonschema:"Team ID the owner belongs to"`
|
||||
}
|
||||
|
||||
type UpdateOwnerInput struct {
|
||||
ID string `json:"id" jsonschema:"Owner ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Owner display name"`
|
||||
Email string `json:"email,omitempty" jsonschema:"Owner email"`
|
||||
TeamID string `json:"team_id,omitempty" jsonschema:"Team ID"`
|
||||
}
|
||||
|
||||
// ── Agent Groups ────────────────────────────────────────────────────
|
||||
|
||||
type CreateAgentGroupInput struct {
|
||||
ID string `json:"id,omitempty" jsonschema:"Agent group ID"`
|
||||
Name string `json:"name" jsonschema:"Group display name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"Group description"`
|
||||
MatchOS string `json:"match_os,omitempty" jsonschema:"Match agents by OS (e.g. linux, darwin, windows)"`
|
||||
MatchArchitecture string `json:"match_architecture,omitempty" jsonschema:"Match agents by architecture (e.g. amd64, arm64)"`
|
||||
MatchIPCIDR string `json:"match_ip_cidr,omitempty" jsonschema:"Match agents by IP CIDR range"`
|
||||
MatchVersion string `json:"match_version,omitempty" jsonschema:"Match agents by version"`
|
||||
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the group is enabled"`
|
||||
}
|
||||
|
||||
type UpdateAgentGroupInput struct {
|
||||
ID string `json:"id" jsonschema:"Agent group ID to update"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Group display name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"Group description"`
|
||||
MatchOS string `json:"match_os,omitempty" jsonschema:"Match agents by OS"`
|
||||
MatchArchitecture string `json:"match_architecture,omitempty" jsonschema:"Match agents by architecture"`
|
||||
MatchIPCIDR string `json:"match_ip_cidr,omitempty" jsonschema:"Match agents by IP CIDR range"`
|
||||
MatchVersion string `json:"match_version,omitempty" jsonschema:"Match agents by version"`
|
||||
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the group is enabled"`
|
||||
}
|
||||
|
||||
// ── Stats ───────────────────────────────────────────────────────────
|
||||
|
||||
type TimelineInput struct {
|
||||
Days int `json:"days,omitempty" jsonschema:"Number of days to look back (default 30, max 365)"`
|
||||
}
|
||||
|
||||
// ── Empty ───────────────────────────────────────────────────────────
|
||||
|
||||
type EmptyInput struct{}
|
||||
@@ -9,8 +9,27 @@ type CertificateFilter struct {
|
||||
OwnerID string
|
||||
TeamID string
|
||||
IssuerID string
|
||||
Page int // 1-indexed; default 1
|
||||
PerPage int // default 50, max 500
|
||||
AgentID string // filter by agent_id (via deployment targets)
|
||||
ProfileID string // filter by certificate_profile_id
|
||||
Page int // 1-indexed; default 1
|
||||
PerPage int // default 50, max 500
|
||||
|
||||
// Time-range filters
|
||||
ExpiresBefore *time.Time // certs expiring before this date
|
||||
ExpiresAfter *time.Time // certs expiring after this date
|
||||
CreatedAfter *time.Time // certs created after this date
|
||||
UpdatedAfter *time.Time // certs updated after this date
|
||||
|
||||
// Sorting
|
||||
Sort string // field name to sort by (e.g., "notAfter", "createdAt", "commonName")
|
||||
SortDesc bool // true = DESC, false = ASC
|
||||
|
||||
// Cursor pagination (alternative to page-based)
|
||||
Cursor string // opaque cursor token (base64-encoded "created_at:id")
|
||||
PageSize int // for cursor-based pagination (alias for PerPage)
|
||||
|
||||
// Sparse fields
|
||||
Fields []string // if non-empty, only return these JSON field names
|
||||
}
|
||||
|
||||
// JobFilter defines filtering criteria for job queries.
|
||||
|
||||
@@ -25,6 +25,22 @@ type CertificateRepository interface {
|
||||
CreateVersion(ctx context.Context, version *domain.CertificateVersion) error
|
||||
// GetExpiringCertificates returns certificates expiring before the given time.
|
||||
GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error)
|
||||
// GetLatestVersion returns the most recent certificate version for a certificate.
|
||||
GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error)
|
||||
}
|
||||
|
||||
// RevocationRepository defines operations for managing certificate revocations.
|
||||
type RevocationRepository interface {
|
||||
// Create records a new certificate revocation.
|
||||
Create(ctx context.Context, revocation *domain.CertificateRevocation) error
|
||||
// GetBySerial retrieves a revocation by serial number.
|
||||
GetBySerial(ctx context.Context, serial string) (*domain.CertificateRevocation, error)
|
||||
// ListAll returns all revocations, ordered by revocation time (for CRL generation).
|
||||
ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error)
|
||||
// ListByCertificate returns all revocations for a certificate.
|
||||
ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error)
|
||||
// MarkIssuerNotified updates the issuer_notified flag for a revocation.
|
||||
MarkIssuerNotified(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// IssuerRepository defines operations for managing certificate issuers.
|
||||
@@ -69,8 +85,8 @@ type AgentRepository interface {
|
||||
Update(ctx context.Context, agent *domain.Agent) error
|
||||
// Delete removes an agent.
|
||||
Delete(ctx context.Context, id string) error
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp.
|
||||
UpdateHeartbeat(ctx context.Context, id string) error
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp and metadata.
|
||||
UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error
|
||||
// GetByAPIKey retrieves an agent by hashed API key.
|
||||
GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error)
|
||||
}
|
||||
@@ -155,6 +171,91 @@ type TeamRepository interface {
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// CertificateProfileRepository defines operations for managing certificate profiles.
|
||||
type CertificateProfileRepository interface {
|
||||
// List returns all certificate profiles.
|
||||
List(ctx context.Context) ([]*domain.CertificateProfile, error)
|
||||
// Get retrieves a certificate profile by ID.
|
||||
Get(ctx context.Context, id string) (*domain.CertificateProfile, error)
|
||||
// Create stores a new certificate profile.
|
||||
Create(ctx context.Context, profile *domain.CertificateProfile) error
|
||||
// Update modifies an existing certificate profile.
|
||||
Update(ctx context.Context, profile *domain.CertificateProfile) error
|
||||
// Delete removes a certificate profile.
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// AgentGroupRepository defines operations for managing agent groups.
|
||||
type AgentGroupRepository interface {
|
||||
// List returns all agent groups.
|
||||
List(ctx context.Context) ([]*domain.AgentGroup, error)
|
||||
// Get retrieves an agent group by ID.
|
||||
Get(ctx context.Context, id string) (*domain.AgentGroup, error)
|
||||
// Create stores a new agent group.
|
||||
Create(ctx context.Context, group *domain.AgentGroup) error
|
||||
// Update modifies an existing agent group.
|
||||
Update(ctx context.Context, group *domain.AgentGroup) error
|
||||
// Delete removes an agent group.
|
||||
Delete(ctx context.Context, id string) error
|
||||
// ListMembers returns agents in a group (both dynamic matches and manual includes).
|
||||
ListMembers(ctx context.Context, groupID string) ([]*domain.Agent, error)
|
||||
// AddMember adds a manual membership.
|
||||
AddMember(ctx context.Context, groupID, agentID, membershipType string) error
|
||||
// RemoveMember removes a manual membership.
|
||||
RemoveMember(ctx context.Context, groupID, agentID string) error
|
||||
}
|
||||
|
||||
// DiscoveryRepository defines operations for managing certificate discovery.
|
||||
type DiscoveryRepository interface {
|
||||
// CreateScan stores a new discovery scan record.
|
||||
CreateScan(ctx context.Context, scan *domain.DiscoveryScan) error
|
||||
// GetScan retrieves a discovery scan by ID.
|
||||
GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error)
|
||||
// ListScans returns discovery scans, optionally filtered by agent ID.
|
||||
ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
|
||||
// CreateDiscovered stores a new discovered certificate (upserts by fingerprint+agent+path).
|
||||
// Returns true if the certificate was newly inserted (not just updated).
|
||||
CreateDiscovered(ctx context.Context, cert *domain.DiscoveredCertificate) (bool, error)
|
||||
// GetDiscovered retrieves a discovered certificate by ID.
|
||||
GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
|
||||
// ListDiscovered returns discovered certificates matching the filter.
|
||||
ListDiscovered(ctx context.Context, filter *DiscoveryFilter) ([]*domain.DiscoveredCertificate, int, error)
|
||||
// UpdateDiscoveredStatus updates the status and optional managed certificate link.
|
||||
UpdateDiscoveredStatus(ctx context.Context, id string, status domain.DiscoveryStatus, managedCertID string) error
|
||||
// GetByFingerprint retrieves discovered certificates by SHA-256 fingerprint.
|
||||
GetByFingerprint(ctx context.Context, fingerprint string) ([]*domain.DiscoveredCertificate, error)
|
||||
// CountByStatus returns counts of discovered certificates grouped by status.
|
||||
CountByStatus(ctx context.Context) (map[string]int, error)
|
||||
}
|
||||
|
||||
// DiscoveryFilter defines filters for listing discovered certificates.
|
||||
type DiscoveryFilter struct {
|
||||
AgentID string
|
||||
Status string
|
||||
IsExpired bool
|
||||
IsCA bool
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
// NetworkScanRepository defines operations for managing network scan targets.
|
||||
type NetworkScanRepository interface {
|
||||
// List returns all network scan targets.
|
||||
List(ctx context.Context) ([]*domain.NetworkScanTarget, error)
|
||||
// ListEnabled returns only enabled scan targets.
|
||||
ListEnabled(ctx context.Context) ([]*domain.NetworkScanTarget, error)
|
||||
// Get retrieves a network scan target by ID.
|
||||
Get(ctx context.Context, id string) (*domain.NetworkScanTarget, error)
|
||||
// Create stores a new network scan target.
|
||||
Create(ctx context.Context, target *domain.NetworkScanTarget) error
|
||||
// Update modifies an existing network scan target.
|
||||
Update(ctx context.Context, target *domain.NetworkScanTarget) error
|
||||
// Delete removes a network scan target.
|
||||
Delete(ctx context.Context, id string) error
|
||||
// UpdateScanResults records the outcome of the last scan for a target.
|
||||
UpdateScanResults(ctx context.Context, id string, scanAt time.Time, durationMs int, certsFound int) error
|
||||
}
|
||||
|
||||
// OwnerRepository defines operations for managing certificate owners.
|
||||
type OwnerRepository interface {
|
||||
// List returns all owners.
|
||||
|
||||
@@ -23,7 +23,8 @@ func NewAgentRepository(db *sql.DB) *AgentRepository {
|
||||
// List returns all agents
|
||||
func (r *AgentRepository) List(ctx context.Context) ([]*domain.Agent, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version
|
||||
FROM agents
|
||||
ORDER BY registered_at DESC
|
||||
`)
|
||||
@@ -52,7 +53,8 @@ func (r *AgentRepository) List(ctx context.Context) ([]*domain.Agent, error) {
|
||||
// Get retrieves an agent by ID
|
||||
func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version
|
||||
FROM agents
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
@@ -75,11 +77,13 @@ func (r *AgentRepository) Create(ctx context.Context, agent *domain.Agent) error
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, agent.ID, agent.Name, agent.Hostname, agent.Status, agent.LastHeartbeatAt,
|
||||
agent.RegisteredAt, agent.APIKeyHash).Scan(&agent.ID)
|
||||
agent.RegisteredAt, agent.APIKeyHash,
|
||||
agent.OS, agent.Architecture, agent.IPAddress, agent.Version).Scan(&agent.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create agent: %w", err)
|
||||
@@ -96,9 +100,14 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
|
||||
hostname = $2,
|
||||
status = $3,
|
||||
last_heartbeat_at = $4,
|
||||
api_key_hash = $5
|
||||
WHERE id = $6
|
||||
`, agent.Name, agent.Hostname, agent.Status, agent.LastHeartbeatAt, agent.APIKeyHash, agent.ID)
|
||||
api_key_hash = $5,
|
||||
os = $6,
|
||||
architecture = $7,
|
||||
ip_address = $8,
|
||||
version = $9
|
||||
WHERE id = $10
|
||||
`, agent.Name, agent.Hostname, agent.Status, agent.LastHeartbeatAt, agent.APIKeyHash,
|
||||
agent.OS, agent.Architecture, agent.IPAddress, agent.Version, agent.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update agent: %w", err)
|
||||
@@ -136,11 +145,27 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp
|
||||
func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET last_heartbeat_at = $1 WHERE id = $2
|
||||
`, time.Now(), id)
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp and metadata
|
||||
func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
|
||||
var result sql.Result
|
||||
var err error
|
||||
|
||||
if metadata != nil {
|
||||
result, err = r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET
|
||||
last_heartbeat_at = $1,
|
||||
hostname = CASE WHEN $3 = '' THEN hostname ELSE $3 END,
|
||||
os = CASE WHEN $4 = '' THEN os ELSE $4 END,
|
||||
architecture = CASE WHEN $5 = '' THEN architecture ELSE $5 END,
|
||||
ip_address = CASE WHEN $6 = '' THEN ip_address ELSE $6 END,
|
||||
version = CASE WHEN $7 = '' THEN version ELSE $7 END
|
||||
WHERE id = $2
|
||||
`, time.Now(), id, metadata.Hostname, metadata.OS, metadata.Architecture, metadata.IPAddress, metadata.Version)
|
||||
} else {
|
||||
result, err = r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET last_heartbeat_at = $1 WHERE id = $2
|
||||
`, time.Now(), id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update heartbeat: %w", err)
|
||||
@@ -161,7 +186,8 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string) error
|
||||
// GetByAPIKey retrieves an agent by hashed API key
|
||||
func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version
|
||||
FROM agents
|
||||
WHERE api_key_hash = $1
|
||||
`, keyHash)
|
||||
@@ -183,7 +209,8 @@ func scanAgent(scanner interface {
|
||||
}) (*domain.Agent, error) {
|
||||
var agent domain.Agent
|
||||
err := scanner.Scan(&agent.ID, &agent.Name, &agent.Hostname, &agent.Status,
|
||||
&agent.LastHeartbeatAt, &agent.RegisteredAt, &agent.APIKeyHash)
|
||||
&agent.LastHeartbeatAt, &agent.RegisteredAt, &agent.APIKeyHash,
|
||||
&agent.OS, &agent.Architecture, &agent.IPAddress, &agent.Version)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan agent: %w", err)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user