mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:31:34 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a53b648b1 | |||
| cb72292b83 | |||
| 3a11e447cf | |||
| bad02e6f23 | |||
| 4c3b7cbb16 | |||
| e8c64b47dd | |||
| 9feb6c796d | |||
| fd05bacb76 | |||
| f51571297d | |||
| 9a41d0ca39 | |||
| 8b52da6aef | |||
| adfb682754 | |||
| 0822f748a5 | |||
| 368ea681a5 | |||
| b059ec930f | |||
| 2238f28610 | |||
| bbba618beb | |||
| cfc4d3f3e8 | |||
| c06d23dd7a | |||
| 6c8d4eca40 | |||
| 836534f2a7 | |||
| 648e2f7ab1 | |||
| 6375909591 | |||
| 3e5ff4b9c3 | |||
| 76d0ce2a0f | |||
| 207f2c6879 | |||
| 46a58d518a | |||
| c5be6d059f | |||
| ec209c9736 | |||
| d4f02c5f4b | |||
| 2409f2e464 | |||
| 225c7141b8 | |||
| 8807a7303d | |||
| a6515b4323 | |||
| 11173a74c6 | |||
| ec0e7a3560 |
@@ -62,6 +62,7 @@ certctl-agent
|
||||
certctl-cli
|
||||
/server
|
||||
/agent
|
||||
/cli
|
||||
|
||||
# Private strategy docs
|
||||
roadmap.md
|
||||
|
||||
@@ -7,72 +7,70 @@
|
||||
|
||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||
|
||||
```mermaid
|
||||
timeline
|
||||
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
||||
2015 : 5 years
|
||||
2018 : 825 days
|
||||
2020 : 398 days
|
||||
March 2026 : 200 days
|
||||
March 2027 : 100 days
|
||||
March 2029 : 47 days
|
||||
```
|
||||
|
||||
TLS certificate lifespans are shrinking fast. 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. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
|
||||
|
||||
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||
[](https://github.com/shankar0123/certctl/releases)
|
||||
[](https://github.com/shankar0123/certctl/stargazers)
|
||||
|
||||
## Documentation
|
||||
TLS certificate lifespans are shrinking fast. 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. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Why certctl?](docs/why-certctl.md) | Competitive positioning — how certctl compares to open-source and enterprise certificate management platforms |
|
||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||
| [Quick Start](docs/quickstart.md) | Get running in 5 minutes — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||
| [Migrate from Certbot](docs/migrate-from-certbot.md) | Step-by-step migration from Certbot/Let's Encrypt cron jobs |
|
||||
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
|
||||
| [certctl for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure |
|
||||
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong. It's free, self-hosted, and covers the same lifecycle that enterprise platforms charge $100K+/year for.
|
||||
|
||||
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,471 Go tests + 193 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
|
||||
```mermaid
|
||||
gantt
|
||||
title TLS Certificate Maximum Lifespan — CA/Browser Forum Ballot SC-081v3
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat
|
||||
todayMarker off
|
||||
section 2015
|
||||
5 years (1825 days) :done, 2020-01-01, 1825d
|
||||
section 2018
|
||||
825 days :done, 2020-01-01, 825d
|
||||
section 2020
|
||||
398 days :active, 2020-01-01, 398d
|
||||
section 2026
|
||||
200 days :crit, 2020-01-01, 200d
|
||||
section 2027
|
||||
100 days :crit, 2020-01-01, 100d
|
||||
section 2029
|
||||
47 days :crit, 2020-01-01, 47d
|
||||
```
|
||||
|
||||
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs 1,554+ tests with race detection, static analysis, and vulnerability scanning on every commit.
|
||||
|
||||
## Why certctl Exists
|
||||
|
||||
Certificate lifecycle tooling today falls into two camps: expensive enterprise platforms (Venafi, Keyfactor, Sectigo) that cost six figures and take months to deploy, or single-purpose tools (cert-manager, certbot) that handle one slice of the problem. If you run a mixed infrastructure — some NGINX, some Apache, a few HAProxy nodes, maybe an F5 — and you need to manage certificates from multiple CAs, there's nothing self-hosted that covers the full lifecycle without vendor lock-in.
|
||||
Certificate lifecycle tooling today falls into two camps: expensive enterprise platforms (Venafi, Keyfactor, Sectigo) that cost six figures and take months to deploy, or single-purpose tools (cert-manager, certbot) that handle one slice of the problem. If you run a mixed infrastructure — some NGINX, some Apache, a few HAProxy nodes, IIS on Windows, maybe an F5 — and you need to manage certificates from multiple CAs, there's nothing self-hosted that covers the full lifecycle without vendor lock-in.
|
||||
|
||||
certctl fills that gap. It's **CA-agnostic** — the issuer connector interface means you can plug in any certificate authority: a self-signed local CA for dev, Let's Encrypt via ACME for public certs, Smallstep step-ca for your private PKI, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. You're never locked to a single CA vendor, and you can run multiple issuers simultaneously for different certificate types.
|
||||
certctl fills that gap. It's **CA-agnostic** — plug in any certificate authority: Let's Encrypt via ACME, Smallstep step-ca, HashiCorp Vault PKI, DigiCert CertCentral, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. Run multiple issuers simultaneously for different certificate types.
|
||||
|
||||
It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, and Caddy — all using the same pluggable connector model for any server that accepts cert files. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, and IIS (local PowerShell or remote WinRM) — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venafi, Keyfactor), see [Why certctl?](docs/why-certctl.md)
|
||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [Why certctl?](docs/why-certctl.md)
|
||||
|
||||
## Who Is This For
|
||||
|
||||
**Platform engineering and DevOps teams** managing 10–500+ certificates across mixed infrastructure who need automated renewal, deployment, and a single dashboard for visibility. If you're currently running certbot cron jobs, manually renewing certs, or stitching together scripts — certctl replaces all of that.
|
||||
|
||||
**Security and compliance teams** who need an immutable audit trail, certificate ownership tracking, policy enforcement, and evidence for SOC 2, PCI-DSS 4.0, or NIST SP 800-57 audits.
|
||||
|
||||
**Small teams without enterprise budgets** who need the lifecycle automation that Venafi and Keyfactor provide but can't justify six-figure licensing for a 50-server environment.
|
||||
|
||||
## What It Does
|
||||
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||
- **Certificates renew and deploy themselves.** The scheduler monitors expiration, creates renewal jobs, issues certificates through your CA, and deploys them to target servers — all without human intervention. ACME ARI (RFC 9702) lets your CA tell certctl exactly when to renew.
|
||||
|
||||
- **Web dashboard** — 22 operational pages: certificate inventory, deployment timeline with TLS verification, bulk operations (renew/revoke/reassign), discovery triage, network scan management, approval workflows, audit trail with CSV/JSON export, agent fleet overview with OS/arch grouping, short-lived credential monitoring, digest email preview
|
||||
- **REST API** — 99 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters
|
||||
- **Agents** — generate private keys locally (ECDSA P-256), discover existing certs on disk (PEM/DER), submit CSRs only (private keys never leave your servers)
|
||||
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents, concurrent scanning with configurable timeouts
|
||||
- **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included
|
||||
- **S/MIME + EKU support** — issue certificates with emailProtection, codeSigning, timeStamping, clientAuth EKUs; email SAN routing for S/MIME
|
||||
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||
- **Post-deployment verification** — agent-side TLS probe confirms the target serves the correct certificate by SHA-256 fingerprint match
|
||||
- **Approval workflows** — require human sign-off on renewals before deployment
|
||||
- **Background scheduler** — 7 automated loops: renewal checks, job processing, agent health, notifications, short-lived cert expiry, network scanning, and scheduled certificate digest emails
|
||||
- **ACME Renewal Information (ARI, RFC 9702)** — CA-directed renewal timing; certctl asks the CA when to renew instead of using fixed thresholds
|
||||
- **Scheduled certificate digest emails** — HTML digest with certificate stats, expiration timeline, and job health; optional daily briefing via SMTP
|
||||
- **Helm chart** — Production-ready Kubernetes deployment with server, PostgreSQL, and agent DaemonSet
|
||||
- **You see everything in one place.** A 25-page operational dashboard shows every certificate across every server: status, ownership, expiration timeline, deployment history with TLS verification, discovery triage, and real-time agent fleet health. Bulk operations (renew, revoke, reassign) work across selections.
|
||||
|
||||
For the full capability breakdown — revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
|
||||
- **Private keys never leave your servers.** Agents generate ECDSA P-256 keys locally and submit only the CSR. The control plane never touches private keys. Post-deployment TLS verification confirms the right certificate is actually being served.
|
||||
|
||||
- **Discover what you don't know about.** Agents scan filesystems for existing PEM/DER certificates. The network scanner probes TLS endpoints across CIDR ranges without requiring agents. Both feed into a triage workflow where you claim, dismiss, or import discovered certificates.
|
||||
|
||||
- **Everything is auditable.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Certificate digest emails deliver daily briefings. Prometheus metrics endpoint for Grafana dashboards.
|
||||
|
||||
- **Multiple interfaces for different workflows.** REST API (97 endpoints) for automation, CLI for scripting, MCP server for AI assistants (Claude, Cursor, Windsurf), EST server (RFC 7030) for device enrollment, Helm chart for Kubernetes, and the web dashboard for day-to-day operations.
|
||||
|
||||
For the full capability breakdown — revocation infrastructure (CRL + OCSP), policy engine, certificate profiles, S/MIME support, approval workflows, and more — see the [Feature Inventory](docs/features.md).
|
||||
|
||||
## Supported Integrations
|
||||
|
||||
@@ -84,8 +82,12 @@ For the full capability breakdown — revocation infrastructure, policy engine,
|
||||
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
||||
| step-ca | Implemented | `StepCA` |
|
||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||
| Vault PKI | Future | — |
|
||||
| DigiCert | Future | — |
|
||||
| Vault PKI | Beta | `VaultPKI` |
|
||||
| DigiCert CertCentral | Beta | `DigiCert` |
|
||||
| Sectigo SCM | Beta | `Sectigo` |
|
||||
| Google CAS | Beta | `GoogleCAS` |
|
||||
|
||||
**Vault PKI, DigiCert, Sectigo, and Google CAS connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
|
||||
|
||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
||||
|
||||
@@ -97,8 +99,11 @@ For the full capability breakdown — revocation infrastructure, policy engine,
|
||||
| HAProxy | Implemented | `HAProxy` |
|
||||
| Traefik | Implemented | `Traefik` |
|
||||
| Caddy | Implemented | `Caddy` |
|
||||
| Envoy | Implemented | `Envoy` |
|
||||
| Postfix | Implemented | `Postfix` |
|
||||
| Dovecot | Implemented | `Dovecot` |
|
||||
| Microsoft IIS | Implemented (local + WinRM) | `IIS` |
|
||||
| F5 BIG-IP | Interface only | `F5` |
|
||||
| Microsoft IIS | Interface only | `IIS` |
|
||||
|
||||
### Notifiers
|
||||
| Notifier | Status | Type |
|
||||
@@ -128,10 +133,10 @@ All connectors are pluggable — build your own by implementing the [connector i
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
|
||||
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
|
||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td>
|
||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca, Vault PKI, DigiCert</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
|
||||
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy, IIS deployment</sub></td>
|
||||
<td><a href="docs/screenshots/v2-owners.png"><img src="docs/screenshots/v2-owners.png" width="270" alt="Owners"></a><br><b>Owners</b><br><sub>Cert ownership with team assignment</sub></td>
|
||||
<td><a href="docs/screenshots/v2-teams.png"><img src="docs/screenshots/v2-teams.png" width="270" alt="Teams"></a><br><b>Teams</b><br><sub>Org grouping for notification routing</sub></td>
|
||||
</tr>
|
||||
@@ -142,17 +147,8 @@ All connectors are pluggable — build your own by implementing the [connector i
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> **22 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (with approval workflow), notifications, policies, profiles, issuers, targets (wizard with NGINX/Apache/HAProxy/Traefik/Caddy/F5/IIS), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, and network scan management.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker Pull
|
||||
|
||||
```bash
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||
```
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
@@ -163,15 +159,14 @@ docker compose -f deploy/docker-compose.yml up -d --build
|
||||
|
||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
||||
|
||||
The dashboard comes pre-loaded with 35 demo certificates across 5 issuers, 8 agents, 90 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
|
||||
The dashboard comes pre-loaded with 32 demo certificates across 7 issuers, 8 agents, 180 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
|
||||
|
||||
Verify the API:
|
||||
```bash
|
||||
curl http://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||
# 35
|
||||
# 32
|
||||
```
|
||||
|
||||
### Agent Install (One-Liner)
|
||||
@@ -182,32 +177,30 @@ curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-a
|
||||
|
||||
Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh) for details.
|
||||
|
||||
### Manual Build
|
||||
### Docker Pull
|
||||
|
||||
```bash
|
||||
# Prerequisites: Go 1.25+, PostgreSQL 16+, Docker (for testcontainers-go)
|
||||
go mod download
|
||||
make build
|
||||
|
||||
# Set up database
|
||||
export CERTCTL_DATABASE_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
|
||||
export CERTCTL_AUTH_TYPE=none
|
||||
make migrate-up
|
||||
|
||||
# Start server
|
||||
./bin/server
|
||||
|
||||
# Start agent (separate terminal)
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=change-me-in-production
|
||||
export CERTCTL_AGENT_NAME=local-agent
|
||||
export CERTCTL_AGENT_ID=agent-local-01
|
||||
./bin/agent --agent-id=agent-local-01
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Pick the scenario closest to your setup and have it running in 2 minutes.
|
||||
|
||||
| Example | Scenario |
|
||||
|---------|----------|
|
||||
| [`examples/acme-nginx/`](examples/acme-nginx/) | Let's Encrypt + NGINX, HTTP-01 challenges |
|
||||
| [`examples/acme-wildcard-dns01/`](examples/acme-wildcard-dns01/) | Wildcard certs via DNS-01 (Cloudflare hook included) |
|
||||
| [`examples/private-ca-traefik/`](examples/private-ca-traefik/) | Local CA (self-signed or sub-CA) + Traefik file provider |
|
||||
| [`examples/step-ca-haproxy/`](examples/step-ca-haproxy/) | Smallstep step-ca + HAProxy combined PEM |
|
||||
| [`examples/multi-issuer/`](examples/multi-issuer/) | ACME for public + Local CA for internal, one dashboard |
|
||||
|
||||
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 7 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
||||
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). For Windows servers without a local agent, a proxy agent in the same network zone handles deployment via WinRM. Background scheduler runs 7 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
@@ -216,206 +209,20 @@ export CERTCTL_AGENT_ID=agent-local-01
|
||||
- **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons.
|
||||
- **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution.
|
||||
|
||||
PostgreSQL 16 with 21 tables covering certificates, versions, policies, issuers, targets, agents, jobs, teams, owners, profiles, agent groups, revocations, discovery, network scans, and audit events. See the [Architecture Guide](docs/architecture.md) for the full schema.
|
||||
## Documentation
|
||||
|
||||
## Configuration
|
||||
|
||||
All environment variables use the `CERTCTL_` prefix. Full reference below (39 variables across server, agent, and connector config).
|
||||
|
||||
### Server — Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
||||
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port (1–65535) |
|
||||
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string (required) |
|
||||
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | PostgreSQL connection pool size (min 1) |
|
||||
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to migration SQL files |
|
||||
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max HTTP request body in bytes (default 1MB) |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` (structured) or `text` (human-readable) |
|
||||
|
||||
### Server — Auth, CORS, Rate Limiting
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` (demo only) |
|
||||
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
||||
| `CERTCTL_CORS_ORIGINS` | *(empty = deny all)* | Comma-separated allowed origins, or `*` for dev |
|
||||
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable token bucket rate limiting |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second per client |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Max burst size |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
|
||||
|
||||
### Server — Scheduler
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often to check expiring certs (min 1m) |
|
||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often to process pending jobs (min 1s) |
|
||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | Agent heartbeat check frequency (min 1s) |
|
||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | Notification send frequency (min 1s) |
|
||||
|
||||
### Server — Sub-CA Mode
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_CA_CERT_PATH` | — | PEM-encoded CA certificate for sub-CA mode |
|
||||
| `CERTCTL_CA_KEY_PATH` | — | PEM-encoded CA private key (RSA, ECDSA, PKCS#8) |
|
||||
|
||||
### Server — Feature Flags
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST enrollment endpoints |
|
||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||
| `CERTCTL_EST_PROFILE_ID` | — | Constrain EST to a specific certificate profile |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side TLS network scanning |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often scheduled scans run |
|
||||
| `CERTCTL_VERIFY_DEPLOYMENT` | `true` | TLS verification after certificate deployment |
|
||||
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||
| `CERTCTL_VERIFY_DELAY` | `2s` | Delay before verification probe |
|
||||
|
||||
### Server — Notification Connectors
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL (enables Slack) |
|
||||
| `CERTCTL_SLACK_CHANNEL` | — | Override default webhook channel |
|
||||
| `CERTCTL_SLACK_USERNAME` | `certctl` | Bot display name |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams webhook URL (enables Teams) |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 key (enables PagerDuty) |
|
||||
| `CERTCTL_PAGERDUTY_SEVERITY` | `warning` | Event severity: `info`, `warning`, `error`, `critical` |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key (enables OpsGenie) |
|
||||
| `CERTCTL_OPSGENIE_PRIORITY` | `P3` | Alert priority: `P1`–`P5` |
|
||||
|
||||
### Agent
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
||||
| `CERTCTL_API_KEY` | — | Agent API key for authentication |
|
||||
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory (0600 perms) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
|
||||
|
||||
Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dev tools (golangci-lint, migrate CLI, air)
|
||||
make install-tools
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run tests with race detection (same as CI)
|
||||
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/...
|
||||
|
||||
# Run with coverage
|
||||
make test-coverage
|
||||
|
||||
# Lint (runs golangci-lint with project config)
|
||||
make lint
|
||||
|
||||
# Vulnerability scan
|
||||
govulncheck ./...
|
||||
|
||||
# Format
|
||||
make fmt
|
||||
```
|
||||
|
||||
### CI Pipeline
|
||||
|
||||
Every push and PR runs: `go vet`, `go test -race` (race detection), `golangci-lint` (11 linters including gosec and bodyclose), `govulncheck` (dependency CVE scanning), and per-layer coverage thresholds (service 60%, handler 60%, domain 40%, middleware 50%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build. See `.github/workflows/ci.yml` for details.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
make docker-up # Start stack (server + postgres + agent)
|
||||
make docker-down # Stop stack
|
||||
make docker-logs-server # Server logs
|
||||
make docker-logs-agent # Agent logs
|
||||
make docker-clean # Stop + remove volumes
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Private Key Management
|
||||
- **Agent keygen mode (default)**: Agents generate ECDSA P-256 keys locally and store them with 0600 permissions in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`). Only the CSR (public key) is sent to the control plane. Private keys never leave agent infrastructure.
|
||||
- **Server keygen mode (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.
|
||||
|
||||
### Authentication
|
||||
- Agent-to-server: API key (registered at agent creation)
|
||||
- API key and JWT auth types supported; `none` for demo/development
|
||||
- Auth type and secret configured via `CERTCTL_AUTH_TYPE` and `CERTCTL_AUTH_SECRET`
|
||||
|
||||
### CORS
|
||||
- **Deny-by-default**: Empty `CERTCTL_CORS_ORIGINS` blocks all cross-origin requests. Operators must explicitly list allowed origins (comma-separated) or set `*` for development.
|
||||
|
||||
### Input Validation
|
||||
- Shell command injection prevention on all connector scripts (strict character whitelist, no metacharacters)
|
||||
- RFC 1123 domain name validation, base64url ACME token validation
|
||||
- SSRF protection in network scanner (loopback, link-local, multicast, broadcast ranges filtered)
|
||||
|
||||
### Concurrency Safety
|
||||
- Scheduler loops protected by `sync/atomic.Bool` idempotency guards — duplicate ticks are skipped
|
||||
- Graceful shutdown waits up to 30 seconds for in-flight work before database close
|
||||
|
||||
### Audit Trail
|
||||
- Immutable append-only log in PostgreSQL (`audit_events` table)
|
||||
- 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
|
||||
|
||||
## API Overview
|
||||
|
||||
99 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
|
||||
### Key Endpoints
|
||||
```
|
||||
# Certificate lifecycle
|
||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/certificates/{id}/export/pem Export PEM (JSON or file download)
|
||||
POST /api/v1/certificates/{id}/export/pkcs12 Export PKCS#12 bundle (no private key)
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
|
||||
# Agent operations
|
||||
POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
||||
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
||||
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
||||
|
||||
# Discovery & network scanning
|
||||
GET /api/v1/discovered-certificates List discovered certs (?agent_id, ?status)
|
||||
POST /api/v1/discovered-certificates/{id}/claim Link to managed cert
|
||||
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate TLS scan
|
||||
|
||||
# Jobs & approval
|
||||
POST /api/v1/jobs/{id}/approve Approve interactive renewal
|
||||
POST /api/v1/jobs/{id}/reject Reject interactive renewal
|
||||
|
||||
# Post-deployment verification
|
||||
POST /api/v1/jobs/{id}/verify Submit TLS verification result
|
||||
GET /api/v1/jobs/{id}/verification Get verification status
|
||||
|
||||
# Observability
|
||||
GET /api/v1/metrics/prometheus Prometheus exposition format
|
||||
GET /api/v1/stats/summary Dashboard summary
|
||||
|
||||
# Digest emails (scheduled briefing)
|
||||
GET /api/v1/digest/preview HTML email preview
|
||||
POST /api/v1/digest/send Send digest immediately
|
||||
|
||||
# EST enrollment (RFC 7030)
|
||||
POST /.well-known/est/simpleenroll Device certificate enrollment
|
||||
GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
|
||||
```
|
||||
|
||||
Full CRUD is available for certificates, agents, issuers, targets, teams, owners, policies, profiles, agent groups, notifications, and audit events. See the [OpenAPI spec](api/openapi.yaml) or [Feature Inventory](docs/features.md) for the complete endpoint reference.
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Why certctl?](docs/why-certctl.md) | How certctl compares to ACME clients, agent-based SaaS, and enterprise platforms |
|
||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||
| [Quick Start](docs/quickstart.md) | 5-minute setup — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||
| [Deployment Examples](docs/examples.md) | 5 turnkey scenarios (ACME+NGINX, wildcard DNS-01, private CA, step-ca, multi-issuer) with migration guides |
|
||||
| [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 |
|
||||
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||
| [Connector Reference](docs/connectors.md) | Configuration for all 7 issuers, 10 targets, and 5 notifier connectors |
|
||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||
| [OpenAPI 3.1 Spec](api/openapi.yaml) | 97 operations, full request/response schemas |
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -427,38 +234,26 @@ go install github.com/shankar0123/certctl/cmd/cli@latest
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
|
||||
# Certificate commands
|
||||
# Usage
|
||||
certctl-cli certs list # List all certificates
|
||||
certctl-cli certs get mc-api-prod # Get certificate details
|
||||
certctl-cli certs renew mc-api-prod # Trigger renewal
|
||||
certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
||||
|
||||
# Agent and job commands
|
||||
certctl-cli agents list # List registered agents
|
||||
certctl-cli jobs list # List jobs
|
||||
certctl-cli jobs cancel job-123 # Cancel a pending job
|
||||
|
||||
# Operations
|
||||
certctl-cli status # Server health + summary stats
|
||||
certctl-cli import certs.pem # Bulk import from PEM file
|
||||
|
||||
# Output formats
|
||||
certctl-cli certs list --format json # JSON output (default: table)
|
||||
```
|
||||
|
||||
## MCP Server (AI Integration)
|
||||
|
||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
# Install and run
|
||||
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
|
||||
# Run (stdio transport — add to your AI client config)
|
||||
mcp-server
|
||||
```
|
||||
|
||||
@@ -477,72 +272,44 @@ mcp-server
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
certctl is designed with a security-first architecture. Agents generate ECDSA P-256 keys locally — private keys never touch the control plane. API key auth is enforced by default with SHA-256 hashing and constant-time comparison. CORS is deny-by-default. All connector scripts are validated against shell injection. The network scanner filters reserved IP ranges (SSRF protection). Scheduler loops use atomic idempotency guards. Every API call is recorded to an immutable audit trail with actor attribution, SHA-256 body hash, and latency tracking. See the [Architecture Guide](docs/architecture.md) for the full security model.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make build # Build server + agent binaries
|
||||
make test # Run tests
|
||||
make lint # golangci-lint (11 linters)
|
||||
govulncheck ./... # Vulnerability scan
|
||||
make docker-up # Start Docker Compose stack
|
||||
```
|
||||
|
||||
CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck`, and per-layer coverage thresholds (service 55%, handler 60%, domain 40%, middleware 30%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0)
|
||||
### V1 (v1.0.0) — Shipped
|
||||
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||
|
||||
### V2: Operational Maturity
|
||||
### V2: Operational Maturity — Shipped
|
||||
30+ milestones, 1,554+ tests. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01, step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS targets. RFC 5280 revocation with CRL + OCSP. Certificate profiles, ownership tracking, approval workflows. Filesystem and network certificate discovery. Prometheus metrics, dashboard charts, agent fleet overview. EST server (RFC 7030), ACME ARI (RFC 9702), certificate export, S/MIME support, Helm chart, MCP server, CLI, scheduled digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). See the [Feature Inventory](docs/features.md) for details.
|
||||
|
||||
30 milestones complete, 1500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
|
||||
**What shipped (all ✅):**
|
||||
|
||||
- **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing), ACME ARI (RFC 9702, CA-directed renewal timing)
|
||||
- **Revocation** — RFC 5280 reason codes, DER-encoded X.509 CRL, embedded OCSP responder, short-lived cert exemption
|
||||
- **Profiles + Ownership** — certificate profiles (key types, max TTL, crypto constraints), ownership tracking (owners + teams), dynamic agent groups, interactive renewal approval
|
||||
- **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
|
||||
- **Discovery** — filesystem scanning (PEM/DER) + network TLS scanning (CIDR ranges), triage workflow (claim/dismiss), network scan target management
|
||||
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
||||
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
|
||||
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
|
||||
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
|
||||
- **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
|
||||
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
|
||||
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
|
||||
|
||||
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match, verification status visible in deployment timeline
|
||||
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based), both in target wizard GUI
|
||||
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail, GUI export buttons
|
||||
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing, EKU badges in GUI
|
||||
- **ACME ARI (RFC 9702)** — CA-directed renewal timing: the CA tells certctl the optimal renewal window, gracefully degrading to fixed thresholds when ARI is unavailable
|
||||
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; configurable daily/hourly/weekly briefings via SMTP
|
||||
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress
|
||||
|
||||
**Coming in v2.1.0:**
|
||||
- Vault PKI issuer connector (HashiCorp Vault /sign API)
|
||||
- DigiCert CertCentral issuer connector (enterprise CA)
|
||||
- Dynamic issuer and target configuration via GUI (no env var restarts)
|
||||
- Issuer catalog page (see all supported CAs, configure from dashboard)
|
||||
- First-run onboarding wizard
|
||||
- Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer)
|
||||
- Migration guides (Certbot, acme.sh, cert-manager complement)
|
||||
- One-line agent install script with cross-compiled binaries
|
||||
**Coming in v2.1.0:** Dynamic issuer and target configuration via GUI (no env var restarts), first-run onboarding wizard.
|
||||
|
||||
### V3: certctl Pro
|
||||
|
||||
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views.
|
||||
Team access controls and identity provider integration (OIDC/SSO). Role-based access control with profile-gating. Event-driven architecture (NATS) with real-time operational views. Advanced search DSL, compliance and risk scoring, bulk fleet operations.
|
||||
|
||||
### V4+: Cloud, Scale & Passive Discovery
|
||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Google CAS, EJBCA, Sectigo), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||
|
||||
## Examples
|
||||
|
||||
Turnkey Docker Compose configurations for common scenarios — pick the one closest to your setup and have it running in 2 minutes.
|
||||
|
||||
| Example | Scenario |
|
||||
|---------|----------|
|
||||
| [`examples/acme-nginx/`](examples/acme-nginx/) | Let's Encrypt + NGINX, HTTP-01 challenges |
|
||||
| [`examples/acme-wildcard-dns01/`](examples/acme-wildcard-dns01/) | Wildcard certs via DNS-01 (Cloudflare hook included) |
|
||||
| [`examples/private-ca-traefik/`](examples/private-ca-traefik/) | Local CA (self-signed or sub-CA) + Traefik file provider |
|
||||
| [`examples/step-ca-haproxy/`](examples/step-ca-haproxy/) | Smallstep step-ca + HAProxy combined PEM |
|
||||
| [`examples/multi-issuer/`](examples/multi-issuer/) | ACME for public + Local CA for internal, one dashboard |
|
||||
|
||||
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
|
||||
|
||||
## License
|
||||
|
||||
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties. The BSL 1.1 license converts automatically to Apache 2.0 on March 1, 2033, providing perpetual freedom.
|
||||
|
||||
For licensing inquiries: certctl@proton.me
|
||||
|
||||
---
|
||||
|
||||
If certctl solves a problem you have, [star the repo](https://github.com/shankar0123/certctl) to help others find it. Questions, bugs, or feature requests — [open an issue](https://github.com/shankar0123/certctl/issues).
|
||||
|
||||
+2
-2
@@ -2643,7 +2643,7 @@ components:
|
||||
# ─── Issuers ─────────────────────────────────────────────────────
|
||||
IssuerType:
|
||||
type: string
|
||||
enum: [ACME, GenericCA, StepCA]
|
||||
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS]
|
||||
|
||||
Issuer:
|
||||
type: object
|
||||
@@ -2669,7 +2669,7 @@ components:
|
||||
# ─── Targets ─────────────────────────────────────────────────────
|
||||
TargetType:
|
||||
type: string
|
||||
enum: [NGINX, Apache, HAProxy, F5, IIS]
|
||||
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5]
|
||||
|
||||
DeploymentTarget:
|
||||
type: object
|
||||
|
||||
+32
-1
@@ -29,6 +29,8 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
||||
pf "github.com/shankar0123/certctl/internal/connector/target/postfix"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||
@@ -592,7 +594,7 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
return nil, fmt.Errorf("invalid IIS config: %w", err)
|
||||
}
|
||||
}
|
||||
return iis.New(&cfg, a.logger), nil
|
||||
return iis.New(&cfg, a.logger)
|
||||
|
||||
case "Traefik":
|
||||
var cfg traefik.Config
|
||||
@@ -612,6 +614,35 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
}
|
||||
return caddy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Envoy":
|
||||
var cfg envoy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Envoy config: %w", err)
|
||||
}
|
||||
}
|
||||
return envoy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Postfix":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "postfix"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Postfix config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
case "Dovecot":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "dovecot"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Dovecot config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||
}
|
||||
|
||||
+116
-6
@@ -19,8 +19,12 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||
googlecasissuer "github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||
sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
||||
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
||||
@@ -110,6 +114,7 @@ func main() {
|
||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
||||
Insecure: cfg.ACME.Insecure,
|
||||
}, logger)
|
||||
logger.Info("initialized ACME issuer connector")
|
||||
|
||||
@@ -117,6 +122,7 @@ func main() {
|
||||
// Uses the native /sign API with JWK provisioner authentication.
|
||||
stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{
|
||||
CAURL: os.Getenv("CERTCTL_STEPCA_URL"),
|
||||
RootCertPath: os.Getenv("CERTCTL_STEPCA_ROOT_CERT"),
|
||||
ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"),
|
||||
ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"),
|
||||
ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"),
|
||||
@@ -133,6 +139,51 @@ func main() {
|
||||
}, logger)
|
||||
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
||||
|
||||
// Initialize Vault PKI issuer connector (for HashiCorp Vault internal PKI).
|
||||
// Uses the Vault HTTP API with token authentication.
|
||||
vaultConnector := vaultissuer.New(&vaultissuer.Config{
|
||||
Addr: os.Getenv("CERTCTL_VAULT_ADDR"),
|
||||
Token: os.Getenv("CERTCTL_VAULT_TOKEN"),
|
||||
Mount: getEnvDefault("CERTCTL_VAULT_MOUNT", "pki"),
|
||||
Role: os.Getenv("CERTCTL_VAULT_ROLE"),
|
||||
TTL: getEnvDefault("CERTCTL_VAULT_TTL", "8760h"),
|
||||
}, logger)
|
||||
logger.Info("initialized Vault PKI issuer connector")
|
||||
|
||||
// Initialize DigiCert CertCentral issuer connector (for enterprise public CA).
|
||||
// Uses the DigiCert REST API with async order model.
|
||||
digicertConnector := digicertissuer.New(&digicertissuer.Config{
|
||||
APIKey: os.Getenv("CERTCTL_DIGICERT_API_KEY"),
|
||||
OrgID: os.Getenv("CERTCTL_DIGICERT_ORG_ID"),
|
||||
ProductType: getEnvDefault("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
||||
BaseURL: getEnvDefault("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
||||
}, logger)
|
||||
logger.Info("initialized DigiCert CertCentral issuer connector")
|
||||
|
||||
// Initialize Sectigo SCM issuer connector (for enterprise public CA).
|
||||
// Uses the Sectigo SCM REST API with async order model.
|
||||
sectigoConnector := sectigoissuer.New(§igoissuer.Config{
|
||||
CustomerURI: cfg.Sectigo.CustomerURI,
|
||||
Login: cfg.Sectigo.Login,
|
||||
Password: cfg.Sectigo.Password,
|
||||
OrgID: cfg.Sectigo.OrgID,
|
||||
CertType: cfg.Sectigo.CertType,
|
||||
Term: cfg.Sectigo.Term,
|
||||
BaseURL: cfg.Sectigo.BaseURL,
|
||||
}, logger)
|
||||
logger.Info("initialized Sectigo SCM issuer connector")
|
||||
|
||||
// Initialize Google CAS issuer connector (for GCP private CA).
|
||||
// Uses the Google CAS REST API with OAuth2 service account auth.
|
||||
googlecasConnector := googlecasissuer.New(&googlecasissuer.Config{
|
||||
Project: cfg.GoogleCAS.Project,
|
||||
Location: cfg.GoogleCAS.Location,
|
||||
CAPool: cfg.GoogleCAS.CAPool,
|
||||
Credentials: cfg.GoogleCAS.Credentials,
|
||||
TTL: cfg.GoogleCAS.TTL,
|
||||
}, logger)
|
||||
logger.Info("initialized Google CAS 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.
|
||||
@@ -145,6 +196,31 @@ func main() {
|
||||
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
||||
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
||||
}
|
||||
|
||||
// Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set)
|
||||
if os.Getenv("CERTCTL_VAULT_ADDR") != "" {
|
||||
issuerRegistry["iss-vault"] = service.NewIssuerConnectorAdapter(vaultConnector)
|
||||
logger.Info("Vault PKI issuer registered", "id", "iss-vault")
|
||||
}
|
||||
|
||||
// Conditionally register DigiCert (only if CERTCTL_DIGICERT_API_KEY is set)
|
||||
if os.Getenv("CERTCTL_DIGICERT_API_KEY") != "" {
|
||||
issuerRegistry["iss-digicert"] = service.NewIssuerConnectorAdapter(digicertConnector)
|
||||
logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert")
|
||||
}
|
||||
|
||||
// Conditionally register Sectigo SCM (only if all 3 auth credentials are set)
|
||||
if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" {
|
||||
issuerRegistry["iss-sectigo"] = service.NewIssuerConnectorAdapter(sectigoConnector)
|
||||
logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo")
|
||||
}
|
||||
|
||||
// Conditionally register Google CAS (only if project and credentials are set)
|
||||
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
|
||||
issuerRegistry["iss-googlecas"] = service.NewIssuerConnectorAdapter(googlecasConnector)
|
||||
logger.Info("Google CAS issuer registered", "id", "iss-googlecas")
|
||||
}
|
||||
|
||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||
|
||||
// Initialize revocation repository
|
||||
@@ -225,7 +301,10 @@ func main() {
|
||||
certificateService.SetRevocationSvc(revocationSvc)
|
||||
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||
certificateService.SetTargetRepo(targetRepo)
|
||||
certificateService.SetJobRepo(jobRepo)
|
||||
certificateService.SetKeygenMode(cfg.Keygen.Mode)
|
||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||
renewalService.SetTargetRepo(targetRepo)
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
@@ -467,13 +546,28 @@ func main() {
|
||||
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
||||
webDir = "./web"
|
||||
}
|
||||
// Health/ready routes bypass the full middleware stack (no auth required).
|
||||
// These are registered on the inner router without auth, but the outer
|
||||
// middleware chain wraps everything. Route them directly to the inner router.
|
||||
noAuthHandler := middleware.Chain(apiRouter,
|
||||
middleware.RequestID,
|
||||
structuredLogger,
|
||||
middleware.Recovery,
|
||||
)
|
||||
|
||||
if _, err := os.Stat(webDir + "/index.html"); err == nil {
|
||||
fileServer := http.FileServer(http.Dir(webDir))
|
||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
// API, health, and EST routes go to the API handler
|
||||
if path == "/health" || path == "/ready" ||
|
||||
(len(path) >= 8 && path[:8] == "/api/v1/") ||
|
||||
// Health/ready and auth/info bypass auth middleware.
|
||||
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
||||
// auth/info: React app calls this before login to detect auth mode.
|
||||
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// All other API and EST routes go through the full middleware stack (with auth)
|
||||
if (len(path) >= 8 && path[:8] == "/api/v1/") ||
|
||||
(len(path) >= 16 && path[:16] == "/.well-known/est") {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -488,7 +582,15 @@ func main() {
|
||||
})
|
||||
logger.Info("dashboard available at /", "web_dir", webDir)
|
||||
} else {
|
||||
finalHandler = apiHandler
|
||||
// No dashboard: route health/auth-info without auth, everything else through full stack
|
||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
})
|
||||
logger.Info("dashboard directory not found, serving API only")
|
||||
}
|
||||
|
||||
@@ -497,9 +599,9 @@ func main() {
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
@@ -543,6 +645,14 @@ func main() {
|
||||
logger.Info("certctl server stopped")
|
||||
}
|
||||
|
||||
// getEnvDefault reads an environment variable with a default fallback.
|
||||
func getEnvDefault(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// getEnvIntDefault parses an integer from a string with a default fallback.
|
||||
func getEnvIntDefault(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# =============================================================================
|
||||
# certctl Testing Environment — Docker Compose
|
||||
# =============================================================================
|
||||
#
|
||||
# Spins up the full certctl platform with real CA backends for manual QA:
|
||||
#
|
||||
# 1. PostgreSQL 16 — database (clean, no demo data)
|
||||
# 2. certctl-server — control plane API + web dashboard on :8443
|
||||
# 3. certctl-agent — polls for work, deploys certs to NGINX
|
||||
# 4. step-ca — private CA (JWK provisioner, auto-bootstraps)
|
||||
# 5. Pebble — ACME test server (simulates Let's Encrypt)
|
||||
# 6. pebble-challtestsrv — DNS/HTTP challenge test server for Pebble
|
||||
# 7. NGINX — TLS target server on :8080 (HTTP) / :8444 (HTTPS)
|
||||
#
|
||||
# Usage:
|
||||
# cd deploy
|
||||
# docker compose -f docker-compose.test.yml up --build
|
||||
#
|
||||
# Dashboard: http://localhost:8443
|
||||
# API key: test-key-2026
|
||||
# NGINX: https://localhost:8444 (self-signed placeholder until cert deployed)
|
||||
#
|
||||
# See docs/test-env.md for the full walkthrough.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-test-postgres
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: testpass
|
||||
volumes:
|
||||
- test_postgres_data:/var/lib/postgresql/data
|
||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
||||
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/015_seed_test.sql
|
||||
# No seed_demo.sql — start with a clean database for real testing
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.2
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U certctl -d certctl"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble — ACME test server (simulates Let's Encrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble is the official ACME test server from Let's Encrypt (RFC 8555).
|
||||
# It validates challenges via the companion challtestsrv.
|
||||
# Root CA cert available at https://pebble:15000/roots/0 (management API).
|
||||
pebble-challtestsrv:
|
||||
image: ghcr.io/letsencrypt/pebble-challtestsrv:latest
|
||||
container_name: certctl-test-challtestsrv
|
||||
# ENTRYPOINT is /app (the binary). command: provides only the FLAGS.
|
||||
# Matches the official Pebble docker-compose format.
|
||||
# -doh "" disables DoH (default :8443 would conflict with certctl server).
|
||||
# defaultIPv4 must point to the certctl-server (10.30.50.6) because that's where
|
||||
# the ACME HTTP-01 challenge server runs (port 80 inside the container).
|
||||
# Pebble resolves domains via challtestsrv, then connects to this IP to validate.
|
||||
command: -defaultIPv4 10.30.50.6 -defaultIPv6 "" -doh ""
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.3
|
||||
restart: unless-stopped
|
||||
|
||||
pebble:
|
||||
image: ghcr.io/letsencrypt/pebble:latest
|
||||
container_name: certctl-test-pebble
|
||||
depends_on:
|
||||
- pebble-challtestsrv
|
||||
environment:
|
||||
PEBBLE_VA_NOSLEEP: 1
|
||||
PEBBLE_VA_ALWAYS_VALID: 0
|
||||
# ENTRYPOINT is /app (the binary). command: provides only the FLAGS.
|
||||
command:
|
||||
- -config
|
||||
- /test/config/pebble-config.json
|
||||
- -dnsserver
|
||||
- "10.30.50.3:8053"
|
||||
- -strict
|
||||
volumes:
|
||||
- ./test/pebble-config.json:/test/config/pebble-config.json:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.4
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# step-ca — Private CA (Smallstep)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-bootstraps on first run: generates root CA + JWK provisioner "admin".
|
||||
# Root cert: /home/step/certs/root_ca.crt (inside stepca_data volume)
|
||||
# Provisioner key: /home/step/secrets/provisioner_key (encrypted JWK)
|
||||
step-ca:
|
||||
image: smallstep/step-ca:latest
|
||||
container_name: certctl-test-stepca
|
||||
environment:
|
||||
DOCKER_STEPCA_INIT_NAME: "certctl-test-ca"
|
||||
DOCKER_STEPCA_INIT_DNS_NAMES: "step-ca,localhost"
|
||||
DOCKER_STEPCA_INIT_PROVISIONER_NAME: "admin"
|
||||
DOCKER_STEPCA_INIT_PASSWORD: "password123"
|
||||
DOCKER_STEPCA_INIT_ADDRESS: ":9000"
|
||||
volumes:
|
||||
- stepca_data:/home/step
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.5
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fk", "https://localhost:9000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# certctl Server (Control Plane)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connects to PostgreSQL, Pebble (ACME), step-ca, and Local CA.
|
||||
#
|
||||
# TLS trust problem: Pebble and step-ca use self-signed root CAs that
|
||||
# aren't in Alpine's trust store. The ACME and step-ca connectors use
|
||||
# Go's default http.Client (no InsecureSkipVerify), so they need the
|
||||
# CA certs in the system trust store.
|
||||
#
|
||||
# Solution: setup-trust.sh runs as root, fetches Pebble CA from its
|
||||
# management API, copies step-ca root cert from the shared volume,
|
||||
# runs update-ca-certificates, then execs the server binary.
|
||||
certctl-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
container_name: certctl-test-server
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
pebble:
|
||||
condition: service_started
|
||||
step-ca:
|
||||
condition: service_healthy
|
||||
# Run as root so update-ca-certificates can write to /etc/ssl/certs.
|
||||
# Container isolation provides the security boundary.
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "/app/setup-trust.sh"]
|
||||
environment:
|
||||
# Database
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:testpass@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
|
||||
# Auth — API key required (production-like)
|
||||
CERTCTL_AUTH_TYPE: api-key
|
||||
CERTCTL_AUTH_SECRET: test-key-2026
|
||||
|
||||
# Key generation — agent-side (production-like)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
|
||||
# Local CA issuer (iss-local) — self-signed mode (no CA cert/key paths)
|
||||
# This is the simplest issuer, always available.
|
||||
|
||||
# ACME issuer (iss-acme-staging) — pointed at Pebble
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://pebble:14000/dir
|
||||
CERTCTL_ACME_EMAIL: test@certctl.dev
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||
CERTCTL_ACME_INSECURE: "true"
|
||||
|
||||
# step-ca issuer (iss-stepca)
|
||||
CERTCTL_STEPCA_URL: https://step-ca:9000
|
||||
CERTCTL_STEPCA_ROOT_CERT: /stepca-data/certs/root_ca.crt
|
||||
CERTCTL_STEPCA_PROVISIONER: admin
|
||||
CERTCTL_STEPCA_PASSWORD: password123
|
||||
CERTCTL_STEPCA_KEY_PATH: /stepca-data/secrets/provisioner_key
|
||||
|
||||
# EST server (RFC 7030) — uses Local CA by default
|
||||
CERTCTL_EST_ENABLED: "true"
|
||||
CERTCTL_EST_ISSUER_ID: iss-local
|
||||
|
||||
# Network scanning
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
|
||||
# Post-deployment TLS verification
|
||||
CERTCTL_VERIFY_DEPLOYMENT: "true"
|
||||
CERTCTL_VERIFY_TIMEOUT: "10s"
|
||||
CERTCTL_VERIFY_DELAY: "3s"
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- ./test/setup-trust.sh:/app/setup-trust.sh:ro
|
||||
# step-ca data volume (root cert at /certs/root_ca.crt, key at /secrets/provisioner_key)
|
||||
- stepca_data:/stepca-data:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.6
|
||||
healthcheck:
|
||||
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the Bearer token
|
||||
test: ["CMD", "curl", "-f", "-H", "Authorization: Bearer test-key-2026", "http://localhost:8443/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NGINX — TLS Target Server
|
||||
# ---------------------------------------------------------------------------
|
||||
# The agent deploys certificates here via the shared nginx_certs volume.
|
||||
# nginx-entrypoint.sh generates a self-signed placeholder cert so NGINX
|
||||
# can boot before the agent deploys a real cert.
|
||||
#
|
||||
# Ports: 8080 (HTTP) / 8444 (HTTPS) — offset to avoid conflict with server.
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: certctl-test-nginx
|
||||
entrypoint: ["/bin/sh", "/entrypoint.sh"]
|
||||
volumes:
|
||||
- ./test/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./test/nginx-entrypoint.sh:/entrypoint.sh:ro
|
||||
- nginx_certs:/etc/nginx/certs
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8444:443"
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.7
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fk https://localhost/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# certctl Agent
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polls the server for work, generates ECDSA P-256 keys locally,
|
||||
# deploys certs to NGINX via the shared volume, and discovers existing
|
||||
# certs in the NGINX cert directory.
|
||||
certctl-agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
container_name: certctl-test-agent
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: test-key-2026
|
||||
CERTCTL_AGENT_NAME: test-agent-01
|
||||
CERTCTL_AGENT_ID: agent-test-01
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
CERTCTL_DISCOVERY_DIRS: /nginx-certs
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
- nginx_certs:/nginx-certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.8
|
||||
restart: unless-stopped
|
||||
|
||||
# =============================================================================
|
||||
# Network
|
||||
# =============================================================================
|
||||
# Static IPs are required because:
|
||||
# - Pebble needs to know the challtestsrv DNS server address (10.30.50.3)
|
||||
# - challtestsrv resolves all domains to certctl-server (10.30.50.6) for HTTP-01 challenges
|
||||
# - Avoids DNS race conditions during startup
|
||||
networks:
|
||||
certctl-test:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.30.50.0/24
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
test_postgres_data:
|
||||
driver: local
|
||||
stepca_data:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
nginx_certs:
|
||||
driver: local
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Generate a self-signed placeholder certificate so NGINX can boot
|
||||
# before the certctl agent deploys a real certificate.
|
||||
# Once the agent deploys, it overwrites these files and reloads NGINX.
|
||||
|
||||
CERT_DIR="/etc/nginx/certs"
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Make cert directory world-writable so the certctl-agent container
|
||||
# (which shares this volume) can overwrite the placeholder certs.
|
||||
chmod 777 "$CERT_DIR"
|
||||
|
||||
if [ ! -f "$CERT_DIR/cert.pem" ]; then
|
||||
echo "Generating self-signed placeholder certificate..."
|
||||
apk add --no-cache openssl > /dev/null 2>&1
|
||||
openssl req -x509 -nodes -days 1 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-keyout "$CERT_DIR/key.pem" \
|
||||
-out "$CERT_DIR/cert.pem" \
|
||||
-subj "/CN=placeholder.certctl.test" \
|
||||
2>/dev/null
|
||||
# Make placeholder certs writable by the agent container
|
||||
chmod 666 "$CERT_DIR/cert.pem" "$CERT_DIR/key.pem"
|
||||
echo "Placeholder certificate generated."
|
||||
fi
|
||||
|
||||
# Start NGINX in foreground
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -0,0 +1,42 @@
|
||||
# NGINX configuration for certctl test environment.
|
||||
# The agent deploys certificates to /etc/nginx/certs/ and reloads NGINX.
|
||||
# On startup, NGINX uses a self-signed placeholder so it can boot before any cert is deployed.
|
||||
|
||||
# Generate a self-signed placeholder on container start (see entrypoint in compose).
|
||||
# Once the agent deploys a real cert, it overwrites these files and reloads.
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# HTTP → redirect to HTTPS (optional, for realism)
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS server — serves whatever cert the agent has deployed
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
|
||||
# Modern TLS settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
location / {
|
||||
default_type text/plain;
|
||||
return 200 'certctl test environment — NGINX is serving TLS\n';
|
||||
}
|
||||
|
||||
location /health {
|
||||
default_type text/plain;
|
||||
return 200 'ok\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pebble": {
|
||||
"listenAddress": "0.0.0.0:14000",
|
||||
"managementListenAddress": "0.0.0.0:15000",
|
||||
"certificate": "test/certs/localhost/cert.pem",
|
||||
"privateKey": "test/certs/localhost/key.pem",
|
||||
"httpPort": 80,
|
||||
"tlsPort": 443,
|
||||
"ocspResponderURL": "",
|
||||
"externalAccountBindingRequired": false,
|
||||
"retryAfter": {
|
||||
"authz": 3,
|
||||
"order": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+937
@@ -0,0 +1,937 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# certctl End-to-End Test Script
|
||||
# =============================================================================
|
||||
#
|
||||
# Automates the full lifecycle test from docs/test-env.md:
|
||||
# 1. Bring up all 7 containers (build from source)
|
||||
# 2. Wait for every service to be healthy
|
||||
# 3. Verify pre-seeded data (agents, issuers, targets, profiles)
|
||||
# 4. Issue a certificate via Local CA → deploy to NGINX → verify TLS
|
||||
# 5. Issue a certificate via ACME/Pebble → verify
|
||||
# 6. Issue a certificate via step-ca → verify
|
||||
# 7. Test revocation + CRL
|
||||
# 8. Test discovery
|
||||
# 9. Test renewal (re-issue step-ca cert, check version history)
|
||||
# 10. EST enrollment (RFC 7030) — cacerts + simpleenroll
|
||||
# 11. S/MIME issuance — emailProtection EKU + adaptive KeyUsage
|
||||
# 12. API spot checks + print summary
|
||||
#
|
||||
# Usage:
|
||||
# cd certctl/deploy
|
||||
# ./test/run-test.sh # full run (build + test)
|
||||
# ./test/run-test.sh --no-build # skip docker build, reuse existing containers
|
||||
# ./test/run-test.sh --no-teardown # leave containers running after test
|
||||
#
|
||||
# Requirements: docker, curl, openssl, jq (or python3 for json parsing)
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
COMPOSE_FILE="docker-compose.test.yml"
|
||||
API_URL="http://localhost:8443"
|
||||
API_KEY="test-key-2026"
|
||||
NGINX_TLS="localhost:8444"
|
||||
AUTH_HEADER="Authorization: Bearer ${API_KEY}"
|
||||
|
||||
# Flags
|
||||
BUILD=true
|
||||
TEARDOWN=true
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) BUILD=false ;;
|
||||
--no-teardown) TEARDOWN=false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
pass() {
|
||||
PASS=$((PASS + 1))
|
||||
echo -e " ${GREEN}PASS${NC} $1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAIL=$((FAIL + 1))
|
||||
echo -e " ${RED}FAIL${NC} $1"
|
||||
if [ -n "${2:-}" ]; then
|
||||
echo -e " ${RED}$2${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
skip() {
|
||||
SKIP=$((SKIP + 1))
|
||||
echo -e " ${YELLOW}SKIP${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${CYAN}==>${NC} $1"
|
||||
}
|
||||
|
||||
header() {
|
||||
echo ""
|
||||
echo -e "${BOLD}─── $1 ───${NC}"
|
||||
}
|
||||
|
||||
# API helper: GET endpoint, return JSON body. Exits 1 on HTTP error.
|
||||
api_get() {
|
||||
local path="$1"
|
||||
curl -sf -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
||||
}
|
||||
|
||||
# API helper: POST with optional JSON body
|
||||
api_post() {
|
||||
local path="$1"
|
||||
local body="${2:-}"
|
||||
if [ -n "$body" ]; then
|
||||
curl -sf -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/json" \
|
||||
-d "$body" "${API_URL}${path}" 2>/dev/null
|
||||
else
|
||||
curl -sf -X POST -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait for an HTTP endpoint to return 200. Retries with backoff.
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
local max_wait="${3:-120}"
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
|
||||
while [ $elapsed -lt $max_wait ]; do
|
||||
if curl -sf -H "${AUTH_HEADER}" "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract a field from JSON using python3 (no jq dependency)
|
||||
json_field() {
|
||||
python3 -c "import sys,json; d=json.load(sys.stdin); print($1)" 2>/dev/null
|
||||
}
|
||||
|
||||
# Wait for a job to reach a terminal state (Completed or Failed)
|
||||
# Usage: wait_for_job <cert_id> <max_seconds>
|
||||
# Returns 0 if Completed, 1 if Failed/timeout
|
||||
wait_for_jobs_done() {
|
||||
local cert_id="$1"
|
||||
local max_wait="${2:-180}"
|
||||
local elapsed=0
|
||||
local interval=5
|
||||
|
||||
while [ $elapsed -lt $max_wait ]; do
|
||||
local jobs_json
|
||||
jobs_json=$(api_get "/api/v1/jobs" 2>/dev/null || echo '{"data":[]}')
|
||||
|
||||
# Check if all jobs for this cert are in terminal state
|
||||
# API returns jobs under "data" key (not "jobs")
|
||||
local pending
|
||||
pending=$(echo "$jobs_json" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
jobs = data.get('data') or data.get('jobs') or []
|
||||
active = [j for j in jobs if j.get('certificate_id') == '$cert_id'
|
||||
and j.get('status') not in ('Completed', 'Failed', 'Cancelled')]
|
||||
print(len(active))
|
||||
" 2>/dev/null || echo "99")
|
||||
|
||||
if [ "$pending" = "0" ]; then
|
||||
# Check how many jobs exist and their terminal states
|
||||
local job_counts
|
||||
job_counts=$(echo "$jobs_json" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
jobs = data.get('data') or data.get('jobs') or []
|
||||
mine = [j for j in jobs if j.get('certificate_id') == '$cert_id']
|
||||
completed = len([j for j in mine if j.get('status') == 'Completed'])
|
||||
failed = len([j for j in mine if j.get('status') in ('Failed', 'Cancelled')])
|
||||
print(f'{len(mine)} {completed} {failed}')
|
||||
" 2>/dev/null || echo "0 0 0")
|
||||
local total_jobs completed_jobs failed_jobs
|
||||
total_jobs=$(echo "$job_counts" | cut -d' ' -f1)
|
||||
completed_jobs=$(echo "$job_counts" | cut -d' ' -f2)
|
||||
failed_jobs=$(echo "$job_counts" | cut -d' ' -f3)
|
||||
|
||||
if [ "$completed_jobs" -gt 0 ]; then
|
||||
return 0 # At least one job completed successfully
|
||||
fi
|
||||
if [ "$total_jobs" -gt 0 ] && [ "$failed_jobs" -gt 0 ]; then
|
||||
return 1 # All jobs are in terminal state but none completed — all failed
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get the TLS cert subject from NGINX for a given SNI
|
||||
get_tls_subject() {
|
||||
local sni="$1"
|
||||
echo | openssl s_client -connect "$NGINX_TLS" -servername "$sni" 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null \
|
||||
| sed 's/subject=//' | sed 's/^ *//'
|
||||
}
|
||||
|
||||
get_tls_issuer() {
|
||||
local sni="$1"
|
||||
echo | openssl s_client -connect "$NGINX_TLS" -servername "$sni" 2>/dev/null \
|
||||
| openssl x509 -noout -issuer 2>/dev/null \
|
||||
| sed 's/issuer=//' | sed 's/^ *//'
|
||||
}
|
||||
|
||||
# Get the TLS cert SANs from NGINX for a given SNI
|
||||
# Modern CAs (including Let's Encrypt / Pebble) put domains only in SAN, not Subject CN.
|
||||
get_tls_san() {
|
||||
local sni="$1"
|
||||
echo | openssl s_client -connect "$NGINX_TLS" -servername "$sni" 2>/dev/null \
|
||||
| openssl x509 -noout -ext subjectAltName 2>/dev/null \
|
||||
| grep -i "DNS:" | sed 's/^ *//'
|
||||
}
|
||||
|
||||
# Check if NGINX is serving a cert that matches the given domain (checks Subject then SAN)
|
||||
check_tls_identity() {
|
||||
local domain="$1"
|
||||
local subject issuer san
|
||||
subject=$(get_tls_subject "$domain")
|
||||
issuer=$(get_tls_issuer "$domain")
|
||||
san=$(get_tls_san "$domain")
|
||||
if echo "$subject" | grep -qi "$domain" || echo "$san" | grep -qi "$domain"; then
|
||||
echo "MATCH"
|
||||
echo "Subject: $subject"
|
||||
echo "SAN: $san"
|
||||
echo "Issuer: $issuer"
|
||||
else
|
||||
echo "NO_MATCH"
|
||||
echo "Subject: $subject"
|
||||
echo "SAN: $san"
|
||||
echo "Issuer: $issuer"
|
||||
fi
|
||||
}
|
||||
|
||||
# SQL exec in the postgres container
|
||||
psql_exec() {
|
||||
docker exec certctl-test-postgres psql -U certctl -d certctl -tAc "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup trap
|
||||
# ---------------------------------------------------------------------------
|
||||
cleanup() {
|
||||
if [ "$TEARDOWN" = true ]; then
|
||||
info "Tearing down test environment..."
|
||||
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
||||
else
|
||||
info "Leaving containers running (--no-teardown)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 0: Environment Check
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 0: Environment Check"
|
||||
|
||||
# Make sure we're in the deploy directory
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo -e "${RED}ERROR: $COMPOSE_FILE not found.${NC}"
|
||||
echo "Run this script from the certctl/deploy directory:"
|
||||
echo " cd certctl/deploy && ./test/run-test.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in docker curl openssl python3; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
pass "$cmd available"
|
||||
else
|
||||
fail "$cmd not found" "Install $cmd and try again"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
pass "docker compose available"
|
||||
else
|
||||
fail "docker compose not available" "Install Docker Compose v2+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 1: Start the Stack
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 1: Start Test Environment"
|
||||
|
||||
# Teardown any previous run
|
||||
info "Cleaning up previous test environment..."
|
||||
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
||||
|
||||
# Set the cleanup trap AFTER the initial teardown
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "$BUILD" = true ]; then
|
||||
info "Building and starting containers (this takes 2-5 minutes on first run)..."
|
||||
docker compose -f "$COMPOSE_FILE" up --build -d 2>&1 | tail -5
|
||||
else
|
||||
info "Starting containers (--no-build)..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d 2>&1 | tail -5
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 2: Wait for Services
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 2: Waiting for Services"
|
||||
|
||||
info "Waiting for PostgreSQL..."
|
||||
if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U certctl -d certctl >/dev/null 2>&1 ||
|
||||
wait_for_http "${API_URL}/health" "postgres" 60; then
|
||||
pass "PostgreSQL ready"
|
||||
else
|
||||
fail "PostgreSQL not ready after 60s"
|
||||
fi
|
||||
|
||||
info "Waiting for certctl server..."
|
||||
if wait_for_http "${API_URL}/health" "server" 120; then
|
||||
pass "certctl server healthy"
|
||||
# Show trust setup + connector init for debugging
|
||||
echo " --- Server startup (trust setup) ---"
|
||||
docker logs certctl-test-server 2>&1 | grep -E "trust|Added|Extract|provisioner|Pre-launch|key file|WARNING|CERTCTL_" | head -15
|
||||
echo " ---"
|
||||
else
|
||||
fail "certctl server not healthy after 120s"
|
||||
echo ""
|
||||
echo "Server logs:"
|
||||
docker logs certctl-test-server --tail 30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Waiting for NGINX..."
|
||||
if wait_for_http "http://localhost:8080" "nginx" 30; then
|
||||
pass "NGINX healthy"
|
||||
else
|
||||
# NGINX might not respond to plain curl on /health without the right path
|
||||
# Check docker health instead
|
||||
if docker inspect certctl-test-nginx --format='{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then
|
||||
pass "NGINX healthy (docker healthcheck)"
|
||||
else
|
||||
skip "NGINX health check inconclusive (will verify via TLS later)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Give the agent a few seconds to register and send first heartbeat
|
||||
info "Waiting for agent heartbeat (up to 45s)..."
|
||||
AGENT_READY=false
|
||||
for i in $(seq 1 15); do
|
||||
AGENT_STATUS=$(api_get "/api/v1/agents/agent-test-01" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "")
|
||||
if [ "$AGENT_STATUS" = "online" ]; then
|
||||
AGENT_READY=true
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ "$AGENT_READY" = true ]; then
|
||||
pass "Agent online"
|
||||
else
|
||||
skip "Agent not yet online (may be slow to heartbeat — continuing)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 3: Verify Pre-Seeded Data
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 3: Verify Pre-Seeded Data"
|
||||
|
||||
# Agents
|
||||
AGENT_COUNT=$(api_get "/api/v1/agents" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$AGENT_COUNT" -ge 2 ]; then
|
||||
pass "Agents: $AGENT_COUNT found (agent-test-01 + server-scanner)"
|
||||
else
|
||||
fail "Agents: expected >= 2, got $AGENT_COUNT"
|
||||
fi
|
||||
|
||||
# Issuers
|
||||
ISSUER_COUNT=$(api_get "/api/v1/issuers" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$ISSUER_COUNT" -ge 3 ]; then
|
||||
pass "Issuers: $ISSUER_COUNT found (iss-local, iss-acme-staging, iss-stepca)"
|
||||
else
|
||||
fail "Issuers: expected >= 3, got $ISSUER_COUNT" "Check seed_test.sql loaded correctly"
|
||||
fi
|
||||
|
||||
# Targets
|
||||
TARGET_COUNT=$(api_get "/api/v1/targets" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$TARGET_COUNT" -ge 1 ]; then
|
||||
pass "Targets: $TARGET_COUNT found (target-test-nginx)"
|
||||
else
|
||||
fail "Targets: expected >= 1, got $TARGET_COUNT" "seed_test.sql may have failed after iss-local"
|
||||
fi
|
||||
|
||||
# Profile
|
||||
PROFILE_RESP=$(api_get "/api/v1/profiles" 2>/dev/null || echo '{"total":0}')
|
||||
PROFILE_COUNT=$(echo "$PROFILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$PROFILE_COUNT" -ge 2 ]; then
|
||||
pass "Profiles: $PROFILE_COUNT found (prof-test-tls, prof-test-smime)"
|
||||
else
|
||||
fail "Profiles: expected >= 1, got $PROFILE_COUNT"
|
||||
fi
|
||||
|
||||
# Bail if seed data is broken
|
||||
if [ "$ISSUER_COUNT" -lt 3 ] || [ "$TARGET_COUNT" -lt 1 ]; then
|
||||
echo ""
|
||||
echo -e "${RED}Seed data is incomplete. Cannot continue.${NC}"
|
||||
echo "Check PostgreSQL logs: docker logs certctl-test-postgres"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 4: Local CA Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 4: Local CA Certificate Issuance"
|
||||
|
||||
info "Creating certificate record mc-local-test..."
|
||||
CREATE_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-local-test",
|
||||
"name": "local-test-cert",
|
||||
"common_name": "local.certctl.test",
|
||||
"sans": ["local.certctl.test"],
|
||||
"issuer_id": "iss-local",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-tls",
|
||||
"environment": "development"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$CREATE_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-local-test'" 2>/dev/null; then
|
||||
pass "Certificate record created"
|
||||
else
|
||||
fail "Certificate creation failed" "$CREATE_RESP"
|
||||
fi
|
||||
|
||||
info "Linking certificate to NGINX target..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-local-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
pass "Target mapping inserted"
|
||||
|
||||
info "Triggering issuance..."
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/mc-local-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Issuance triggered"
|
||||
else
|
||||
fail "Trigger failed" "$RENEW_RESP"
|
||||
fi
|
||||
|
||||
# Verify a job was created (this is the bug fix check)
|
||||
sleep 2
|
||||
JOB_COUNT=$(api_get "/api/v1/jobs" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
jobs = [j for j in (data.get('data') or data.get('jobs') or []) if j.get('certificate_id') == 'mc-local-test']
|
||||
print(len(jobs))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$JOB_COUNT" -gt 0 ]; then
|
||||
pass "Job created ($JOB_COUNT jobs for mc-local-test)"
|
||||
else
|
||||
fail "No jobs created — TriggerRenewalWithActor bug still present"
|
||||
fi
|
||||
|
||||
info "Waiting for issuance + deployment (up to 180s)..."
|
||||
if wait_for_jobs_done "mc-local-test" 180; then
|
||||
pass "All jobs completed"
|
||||
else
|
||||
fail "Jobs did not complete within 180s"
|
||||
echo " Current jobs:"
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -m json.tool 2>/dev/null | head -30
|
||||
fi
|
||||
|
||||
info "Reloading NGINX to pick up deployed certificate..."
|
||||
docker exec certctl-test-nginx nginx -s reload 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
info "Verifying TLS certificate on NGINX..."
|
||||
TLS_CHECK=$(check_tls_identity "local.certctl.test")
|
||||
TLS_RESULT=$(echo "$TLS_CHECK" | head -1)
|
||||
if [ "$TLS_RESULT" = "MATCH" ]; then
|
||||
pass "NGINX serving cert for local.certctl.test"
|
||||
echo "$TLS_CHECK" | tail -n +2 | while read -r line; do echo -e " $line"; done
|
||||
else
|
||||
fail "NGINX not serving expected cert" "$(echo "$TLS_CHECK" | tail -n +2 | tr '\n' ', ')"
|
||||
fi
|
||||
|
||||
# Check cert status in API
|
||||
CERT_STATUS=$(api_get "/api/v1/certificates/mc-local-test" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown")
|
||||
if [ "$CERT_STATUS" = "Active" ]; then
|
||||
pass "Certificate status: Active"
|
||||
else
|
||||
skip "Certificate status: $CERT_STATUS (expected Active — may need more time)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 5: ACME (Pebble) Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 5: ACME (Pebble) Certificate Issuance"
|
||||
|
||||
info "Creating certificate record mc-acme-test..."
|
||||
CREATE_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-acme-test",
|
||||
"name": "acme-test-cert",
|
||||
"common_name": "acme.certctl.test",
|
||||
"sans": ["acme.certctl.test"],
|
||||
"issuer_id": "iss-acme-staging",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-tls",
|
||||
"environment": "staging"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$CREATE_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-acme-test'" 2>/dev/null; then
|
||||
pass "Certificate record created"
|
||||
else
|
||||
fail "Certificate creation failed" "$CREATE_RESP"
|
||||
fi
|
||||
|
||||
info "Linking to target and triggering issuance..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-acme-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/mc-acme-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Issuance triggered"
|
||||
else
|
||||
fail "Trigger failed" "$RENEW_RESP"
|
||||
fi
|
||||
|
||||
info "Waiting for ACME issuance + deployment (up to 180s)..."
|
||||
if wait_for_jobs_done "mc-acme-test" 180; then
|
||||
pass "All jobs completed"
|
||||
|
||||
info "Reloading NGINX to pick up deployed certificate..."
|
||||
docker exec certctl-test-nginx nginx -s reload 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
TLS_CHECK=$(check_tls_identity "acme.certctl.test")
|
||||
TLS_RESULT=$(echo "$TLS_CHECK" | head -1)
|
||||
if [ "$TLS_RESULT" = "MATCH" ]; then
|
||||
pass "NGINX serving cert for acme.certctl.test"
|
||||
echo "$TLS_CHECK" | tail -n +2 | while read -r line; do echo -e " $line"; done
|
||||
else
|
||||
fail "NGINX not serving expected ACME cert" "$(echo "$TLS_CHECK" | tail -n +2 | tr '\n' ', ')"
|
||||
fi
|
||||
else
|
||||
fail "ACME jobs did not complete within 180s"
|
||||
info "Checking ACME job status..."
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for j in data.get('data', []):
|
||||
if j.get('certificate_id') == 'mc-acme-test':
|
||||
print(f\" Job {j['id']}: type={j['type']} status={j['status']} error={j.get('last_error','')}\")" 2>/dev/null || true
|
||||
echo " Server logs (last 20 lines):"
|
||||
docker logs certctl-test-server --tail 20 2>&1 | grep -i "acme\|error\|fail\|CSR" | head -10 || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 6: step-ca Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 6: step-ca (Private CA) Certificate Issuance"
|
||||
|
||||
info "Creating certificate record mc-stepca-test..."
|
||||
CREATE_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-stepca-test",
|
||||
"name": "stepca-test-cert",
|
||||
"common_name": "stepca.certctl.test",
|
||||
"sans": ["stepca.certctl.test"],
|
||||
"issuer_id": "iss-stepca",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-tls",
|
||||
"environment": "staging"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$CREATE_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-stepca-test'" 2>/dev/null; then
|
||||
pass "Certificate record created"
|
||||
else
|
||||
fail "Certificate creation failed" "$CREATE_RESP"
|
||||
fi
|
||||
|
||||
info "Linking to target and triggering issuance..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-stepca-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/mc-stepca-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Issuance triggered"
|
||||
else
|
||||
fail "Trigger failed" "$RENEW_RESP"
|
||||
fi
|
||||
|
||||
info "Waiting for step-ca issuance + deployment (up to 120s)..."
|
||||
if wait_for_jobs_done "mc-stepca-test" 120; then
|
||||
pass "All jobs completed"
|
||||
else
|
||||
fail "Jobs did not complete in time"
|
||||
info "Checking step-ca job status..."
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for j in data.get('data', []):
|
||||
if j.get('certificate_id') == 'mc-stepca-test':
|
||||
print(f\" Job {j['id']}: type={j['type']} status={j['status']} error={j.get('last_error','')}\")" 2>/dev/null || true
|
||||
echo " Server logs (step-ca related):"
|
||||
docker logs certctl-test-server --tail 30 2>&1 | grep -i "stepca\|step-ca\|provisioner\|jwe\|decrypt\|CSR.*fail\|error" | head -10 || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 7: Revocation
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 7: Revocation"
|
||||
|
||||
info "Revoking mc-local-test (reason: superseded)..."
|
||||
REVOKE_RESP=$(api_post "/api/v1/certificates/mc-local-test/revoke" '{"reason": "superseded"}' 2>/dev/null || echo "ERROR")
|
||||
if echo "$REVOKE_RESP" | grep -qi "revoked\|status"; then
|
||||
pass "Certificate revoked"
|
||||
else
|
||||
fail "Revocation failed" "$REVOKE_RESP"
|
||||
fi
|
||||
|
||||
info "Checking CRL..."
|
||||
CRL_RESP=$(api_get "/api/v1/crl" 2>/dev/null || echo '{"total":0}')
|
||||
CRL_TOTAL=$(echo "$CRL_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$CRL_TOTAL" -ge 1 ]; then
|
||||
pass "CRL contains $CRL_TOTAL revoked certificate(s)"
|
||||
else
|
||||
fail "CRL empty after revocation"
|
||||
fi
|
||||
|
||||
CERT_STATUS=$(api_get "/api/v1/certificates/mc-local-test" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown")
|
||||
if [ "$CERT_STATUS" = "Revoked" ]; then
|
||||
pass "Certificate status updated to Revoked"
|
||||
else
|
||||
fail "Certificate status: $CERT_STATUS (expected Revoked)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 8: Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 8: Certificate Discovery"
|
||||
|
||||
info "Checking discovered certificates..."
|
||||
DISC_RESP=$(api_get "/api/v1/discovered-certificates" 2>/dev/null || echo '{"total":0}')
|
||||
DISC_TOTAL=$(echo "$DISC_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$DISC_TOTAL" -ge 1 ]; then
|
||||
pass "Discovered $DISC_TOTAL certificate(s) on filesystem"
|
||||
else
|
||||
skip "No discovered certificates yet (agent scan may not have run)"
|
||||
fi
|
||||
|
||||
SUMMARY_RESP=$(api_get "/api/v1/discovery-summary" 2>/dev/null || echo '{}')
|
||||
echo -e " Discovery summary: $SUMMARY_RESP"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 9: Renewal (re-issue ACME cert)
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 9: Renewal"
|
||||
|
||||
# Try mc-stepca-test first (mc-local-test was revoked in Phase 7).
|
||||
# Fall back to mc-acme-test if step-ca cert isn't Active.
|
||||
RENEWAL_CERT=""
|
||||
for candidate in mc-stepca-test mc-acme-test; do
|
||||
STATUS=$(api_get "/api/v1/certificates/$candidate" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown")
|
||||
if [ "$STATUS" = "Active" ]; then
|
||||
RENEWAL_CERT="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$RENEWAL_CERT" ]; then
|
||||
skip "Cannot test renewal — no certificate in Active state"
|
||||
else
|
||||
info "Using $RENEWAL_CERT for renewal test..."
|
||||
info "Triggering renewal on $RENEWAL_CERT..."
|
||||
RENEW_RESP=$(api_post "/api/v1/certificates/$RENEWAL_CERT/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$RENEW_RESP" | grep -q "renewal_triggered\|status"; then
|
||||
pass "Renewal triggered"
|
||||
else
|
||||
skip "Renewal trigger returned: $RENEW_RESP"
|
||||
fi
|
||||
|
||||
info "Waiting for renewal to complete (up to 180s)..."
|
||||
if wait_for_jobs_done "$RENEWAL_CERT" 180; then
|
||||
pass "Renewal jobs completed"
|
||||
|
||||
info "Reloading NGINX to pick up renewed certificate..."
|
||||
docker exec certctl-test-nginx nginx -s reload 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# Verify version history shows multiple versions
|
||||
VERSIONS=$(api_get "/api/v1/certificates/$RENEWAL_CERT/versions" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d) if isinstance(d, list) else d.get('total', 0))" 2>/dev/null || echo 0)
|
||||
if [ "$VERSIONS" -ge 2 ]; then
|
||||
pass "Certificate has $VERSIONS versions (original + renewal)"
|
||||
else
|
||||
skip "Expected 2+ versions, got $VERSIONS"
|
||||
fi
|
||||
else
|
||||
skip "Renewal jobs did not complete within 180s"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 10: EST Enrollment (RFC 7030)
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 10: EST Enrollment (RFC 7030)"
|
||||
|
||||
# Test cacerts endpoint — should return PKCS#7 with CA cert chain
|
||||
info "Testing EST cacerts endpoint..."
|
||||
EST_CACERTS_RESP=$(curl -sf -H "${AUTH_HEADER}" "${API_URL}/.well-known/est/cacerts" 2>/dev/null || echo "ERROR")
|
||||
if [ "$EST_CACERTS_RESP" != "ERROR" ] && [ -n "$EST_CACERTS_RESP" ]; then
|
||||
# Response should be base64-encoded PKCS#7
|
||||
if echo "$EST_CACERTS_RESP" | base64 -d >/dev/null 2>&1; then
|
||||
pass "EST cacerts returns valid base64 PKCS#7 response"
|
||||
else
|
||||
fail "EST cacerts returned non-base64 data"
|
||||
fi
|
||||
else
|
||||
fail "EST cacerts endpoint failed" "$EST_CACERTS_RESP"
|
||||
fi
|
||||
|
||||
# Test csrattrs endpoint
|
||||
info "Testing EST csrattrs endpoint..."
|
||||
EST_CSRATTRS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -H "${AUTH_HEADER}" "${API_URL}/.well-known/est/csrattrs" 2>/dev/null || echo "000")
|
||||
if [ "$EST_CSRATTRS_STATUS" = "200" ] || [ "$EST_CSRATTRS_STATUS" = "204" ]; then
|
||||
pass "EST csrattrs returns $EST_CSRATTRS_STATUS"
|
||||
else
|
||||
fail "EST csrattrs returned $EST_CSRATTRS_STATUS (expected 200 or 204)"
|
||||
fi
|
||||
|
||||
# Test simpleenroll — generate CSR, POST as base64-encoded DER
|
||||
info "Testing EST simpleenroll with generated CSR..."
|
||||
EST_KEY_FILE=$(mktemp /tmp/est-key-XXXXXX.pem)
|
||||
EST_CSR_PEM_FILE=$(mktemp /tmp/est-csr-XXXXXX.pem)
|
||||
EST_CSR_DER_FILE=$(mktemp /tmp/est-csr-XXXXXX.der)
|
||||
trap "rm -f $EST_KEY_FILE $EST_CSR_PEM_FILE $EST_CSR_DER_FILE" EXIT
|
||||
|
||||
# Generate ECDSA key + CSR
|
||||
openssl ecparam -genkey -name prime256v1 -noout -out "$EST_KEY_FILE" 2>/dev/null
|
||||
openssl req -new -key "$EST_KEY_FILE" -out "$EST_CSR_PEM_FILE" -subj "/CN=est-device.certctl.test" 2>/dev/null
|
||||
openssl req -in "$EST_CSR_PEM_FILE" -out "$EST_CSR_DER_FILE" -outform DER 2>/dev/null
|
||||
|
||||
# base64-encode the DER CSR (EST wire format)
|
||||
EST_CSR_B64=$(base64 < "$EST_CSR_DER_FILE" | tr -d '\n')
|
||||
|
||||
EST_ENROLL_RESP=$(curl -sf \
|
||||
-X POST \
|
||||
-H "${AUTH_HEADER}" \
|
||||
-H "Content-Type: application/pkcs10" \
|
||||
-d "$EST_CSR_B64" \
|
||||
"${API_URL}/.well-known/est/simpleenroll" 2>/dev/null || echo "ERROR")
|
||||
|
||||
if [ "$EST_ENROLL_RESP" != "ERROR" ] && [ -n "$EST_ENROLL_RESP" ]; then
|
||||
# Response should be base64-encoded PKCS#7 containing the issued cert
|
||||
if echo "$EST_ENROLL_RESP" | base64 -d >/dev/null 2>&1; then
|
||||
pass "EST simpleenroll issued certificate via PKCS#7 response"
|
||||
else
|
||||
fail "EST simpleenroll returned non-base64 data"
|
||||
fi
|
||||
else
|
||||
fail "EST simpleenroll failed" "$(curl -s -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/pkcs10" -d "$EST_CSR_B64" "${API_URL}/.well-known/est/simpleenroll" 2>&1 | head -5)"
|
||||
fi
|
||||
|
||||
# Test simplereenroll (should work identically)
|
||||
info "Testing EST simplereenroll..."
|
||||
EST_REENROLL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "${AUTH_HEADER}" \
|
||||
-H "Content-Type: application/pkcs10" \
|
||||
-d "$EST_CSR_B64" \
|
||||
"${API_URL}/.well-known/est/simplereenroll" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$EST_REENROLL_STATUS" = "200" ]; then
|
||||
pass "EST simplereenroll works (status 200)"
|
||||
else
|
||||
fail "EST simplereenroll returned $EST_REENROLL_STATUS (expected 200)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 11: S/MIME Certificate Issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 11: S/MIME Certificate Issuance"
|
||||
|
||||
info "Creating S/MIME certificate record..."
|
||||
SMIME_RESP=$(api_post "/api/v1/certificates" '{
|
||||
"id": "mc-smime-test",
|
||||
"name": "smime-test-cert",
|
||||
"common_name": "testuser@certctl.test",
|
||||
"sans": ["testuser@certctl.test"],
|
||||
"issuer_id": "iss-local",
|
||||
"owner_id": "owner-test-admin",
|
||||
"team_id": "team-test-ops",
|
||||
"renewal_policy_id": "rp-default",
|
||||
"certificate_profile_id": "prof-test-smime",
|
||||
"environment": "staging"
|
||||
}' 2>/dev/null || echo "ERROR")
|
||||
|
||||
if echo "$SMIME_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('id')=='mc-smime-test'" 2>/dev/null; then
|
||||
pass "S/MIME certificate record created"
|
||||
else
|
||||
fail "S/MIME certificate creation failed" "$SMIME_RESP"
|
||||
fi
|
||||
|
||||
info "Linking S/MIME cert to target (needed for agent work routing)..."
|
||||
psql_exec "INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-smime-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
|
||||
|
||||
info "Triggering S/MIME issuance..."
|
||||
SMIME_RENEW=$(api_post "/api/v1/certificates/mc-smime-test/renew" 2>/dev/null || echo "ERROR")
|
||||
if echo "$SMIME_RENEW" | grep -q "renewal_triggered\|status"; then
|
||||
pass "S/MIME issuance triggered"
|
||||
else
|
||||
fail "S/MIME trigger failed" "$SMIME_RENEW"
|
||||
fi
|
||||
|
||||
info "Waiting for S/MIME issuance (up to 120s)..."
|
||||
if wait_for_jobs_done "mc-smime-test" 120; then
|
||||
pass "S/MIME jobs completed"
|
||||
|
||||
# Fetch the issued cert and verify EKU
|
||||
info "Verifying S/MIME certificate EKU..."
|
||||
SMIME_VERSIONS=$(api_get "/api/v1/certificates/mc-smime-test/versions" 2>/dev/null || echo "[]")
|
||||
SMIME_PEM=$(echo "$SMIME_VERSIONS" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
versions = data if isinstance(data, list) else data.get('data', [])
|
||||
if versions:
|
||||
print(versions[-1].get('pem_chain', versions[-1].get('pem', '')))
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$SMIME_PEM" ]; then
|
||||
# Parse the cert and check for emailProtection EKU
|
||||
SMIME_EKU=$(echo "$SMIME_PEM" | openssl x509 -noout -text 2>/dev/null | grep -A2 "Extended Key Usage" || echo "")
|
||||
if echo "$SMIME_EKU" | grep -qi "emailProtection\|E-mail Protection"; then
|
||||
pass "S/MIME cert has emailProtection EKU"
|
||||
else
|
||||
fail "S/MIME cert missing emailProtection EKU" "Got: $SMIME_EKU"
|
||||
fi
|
||||
|
||||
# Check KeyUsage flags (S/MIME should have Digital Signature + Content Commitment)
|
||||
SMIME_KU=$(echo "$SMIME_PEM" | openssl x509 -noout -text 2>/dev/null | awk '/X509v3 Key Usage:/{getline; print; exit}')
|
||||
if echo "$SMIME_KU" | grep -qi "Digital Signature"; then
|
||||
pass "S/MIME cert has Digital Signature KeyUsage"
|
||||
else
|
||||
fail "S/MIME cert missing Digital Signature KeyUsage" "Got: $SMIME_KU"
|
||||
fi
|
||||
|
||||
# Check that email SAN is present
|
||||
SMIME_SAN=$(echo "$SMIME_PEM" | openssl x509 -noout -ext subjectAltName 2>/dev/null || echo "")
|
||||
if echo "$SMIME_SAN" | grep -qi "email:testuser@certctl.test"; then
|
||||
pass "S/MIME cert has email SAN"
|
||||
else
|
||||
# Some implementations use rfc822Name instead of email:
|
||||
if echo "$SMIME_SAN" | grep -qi "testuser@certctl.test"; then
|
||||
pass "S/MIME cert has email SAN (rfc822Name)"
|
||||
else
|
||||
skip "S/MIME email SAN not found in cert (may be in CN only)"
|
||||
echo " SAN content: $SMIME_SAN"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "Could not extract S/MIME cert PEM for EKU verification"
|
||||
fi
|
||||
else
|
||||
fail "S/MIME issuance did not complete within 120s"
|
||||
info "Checking S/MIME job status..."
|
||||
api_get "/api/v1/jobs" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for j in data.get('data', []):
|
||||
if j.get('certificate_id') == 'mc-smime-test':
|
||||
print(f\" Job {j['id']}: type={j['type']} status={j['status']} error={j.get('last_error','')}\")" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHASE 12: API Spot Checks
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Phase 12: API Spot Checks"
|
||||
|
||||
# Health
|
||||
if api_get "/health" >/dev/null 2>&1; then
|
||||
pass "GET /health returns 200"
|
||||
else
|
||||
fail "GET /health failed"
|
||||
fi
|
||||
|
||||
# Metrics
|
||||
METRICS_RESP=$(api_get "/api/v1/metrics" 2>/dev/null || echo "ERROR")
|
||||
if echo "$METRICS_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'gauge' in d" 2>/dev/null; then
|
||||
pass "GET /api/v1/metrics returns valid JSON"
|
||||
else
|
||||
fail "Metrics endpoint broken"
|
||||
fi
|
||||
|
||||
# Stats summary
|
||||
STATS_RESP=$(api_get "/api/v1/stats/summary" 2>/dev/null || echo "ERROR")
|
||||
if echo "$STATS_RESP" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
|
||||
pass "GET /api/v1/stats/summary returns valid JSON"
|
||||
else
|
||||
fail "Stats summary endpoint broken"
|
||||
fi
|
||||
|
||||
# Audit trail
|
||||
AUDIT_RESP=$(api_get "/api/v1/audit" 2>/dev/null || echo '{"total":0}')
|
||||
AUDIT_TOTAL=$(echo "$AUDIT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
if [ "$AUDIT_TOTAL" -gt 0 ]; then
|
||||
pass "Audit trail: $AUDIT_TOTAL events recorded"
|
||||
else
|
||||
fail "Audit trail empty"
|
||||
fi
|
||||
|
||||
# Jobs summary
|
||||
JOBS_RESP=$(api_get "/api/v1/jobs" 2>/dev/null || echo '{"total":0}')
|
||||
JOBS_TOTAL=$(echo "$JOBS_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
|
||||
pass "Total jobs created: $JOBS_TOTAL"
|
||||
|
||||
# Prometheus
|
||||
PROM_RESP=$(curl -sf -H "${AUTH_HEADER}" "${API_URL}/api/v1/metrics/prometheus" 2>/dev/null || echo "")
|
||||
if echo "$PROM_RESP" | grep -q "certctl_certificate_total"; then
|
||||
pass "Prometheus metrics endpoint working"
|
||||
else
|
||||
fail "Prometheus metrics endpoint broken"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
header "Test Summary"
|
||||
|
||||
TOTAL=$((PASS + FAIL + SKIP))
|
||||
echo ""
|
||||
echo -e " ${GREEN}Passed: $PASS${NC}"
|
||||
echo -e " ${RED}Failed: $FAIL${NC}"
|
||||
echo -e " ${YELLOW}Skipped: $SKIP${NC}"
|
||||
echo -e " Total: $TOTAL"
|
||||
echo ""
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo -e "${GREEN}${BOLD}All tests passed.${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}${BOLD}$FAIL test(s) failed.${NC}"
|
||||
echo ""
|
||||
echo "Useful debug commands:"
|
||||
echo " docker logs certctl-test-server --tail 50"
|
||||
echo " docker logs certctl-test-agent --tail 50"
|
||||
echo " docker compose -f $COMPOSE_FILE ps"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/bin/sh
|
||||
# This script runs inside the certctl-server container at startup.
|
||||
# It fetches CA certificates from Pebble and step-ca, adds them to the
|
||||
# system trust store, then starts the certctl server.
|
||||
#
|
||||
# Why: The ACME connector and step-ca connector use Go's default http.Client
|
||||
# with no InsecureSkipVerify. They rely on the system trust store to verify
|
||||
# TLS connections. Pebble and step-ca both use self-signed root CAs that
|
||||
# aren't in Alpine's default CA bundle, so we must add them manually.
|
||||
#
|
||||
# This script runs as root (user: "0:0" in docker-compose) so that
|
||||
# update-ca-certificates can write to /etc/ssl/certs/.
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== certctl trust store setup ==="
|
||||
|
||||
# --- Pebble CA cert (fetched from management API) ---
|
||||
# Pebble's management API serves the root CA at /roots/0.
|
||||
# We use -k because we can't verify Pebble's TLS cert yet (chicken-and-egg).
|
||||
echo "Fetching Pebble root CA from management API..."
|
||||
PEBBLE_CA=""
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if PEBBLE_CA=$(curl -sk https://pebble:15000/roots/0 2>/dev/null); then
|
||||
if [ -n "$PEBBLE_CA" ]; then
|
||||
echo "$PEBBLE_CA" > /usr/local/share/ca-certificates/pebble-ca.crt
|
||||
echo " Added: Pebble test CA"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
echo " Waiting for Pebble (attempt $i/10)..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$PEBBLE_CA" ]; then
|
||||
echo " WARNING: Could not fetch Pebble CA. ACME issuance will fail."
|
||||
fi
|
||||
|
||||
# --- step-ca root cert (from shared volume) ---
|
||||
# The step-ca container writes its root CA to /home/step/certs/root_ca.crt.
|
||||
# We mount the step-ca data volume at /stepca-data inside this container.
|
||||
STEPCA_ROOT="/stepca-data/certs/root_ca.crt"
|
||||
echo "Waiting for step-ca root cert..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if [ -f "$STEPCA_ROOT" ]; then
|
||||
cp "$STEPCA_ROOT" /usr/local/share/ca-certificates/step-ca-root.crt
|
||||
echo " Added: step-ca root CA"
|
||||
break
|
||||
fi
|
||||
echo " Waiting for step-ca root cert (attempt $i/10)..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ ! -f "$STEPCA_ROOT" ]; then
|
||||
echo " WARNING: step-ca root cert not found at $STEPCA_ROOT"
|
||||
echo " step-ca issuance may fail until the cert is available."
|
||||
fi
|
||||
|
||||
# --- step-ca provisioner key (extracted from ca.json) ---
|
||||
# When step-ca auto-bootstraps via DOCKER_STEPCA_INIT_* env vars, the
|
||||
# encrypted provisioner key (JWE) is NOT written as a separate file.
|
||||
# Instead, it's embedded in ca.json under:
|
||||
# authority.provisioners[0].encryptedKey
|
||||
# We extract it here and write to /tmp so the certctl server can read it.
|
||||
# The stepca_data volume is mounted :ro, so we can't write there.
|
||||
STEPCA_CA_JSON="/stepca-data/config/ca.json"
|
||||
STEPCA_KEY_EXTRACTED="/tmp/step-ca-provisioner-key"
|
||||
echo "Extracting step-ca provisioner key from ca.json..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if [ -f "$STEPCA_CA_JSON" ]; then
|
||||
# Extract the encryptedKey value using grep+sed (no jq in Alpine base)
|
||||
# The field looks like: "encryptedKey": "eyJhbGciOi..."
|
||||
ENCRYPTED_KEY=$(grep -o '"encryptedKey":"[^"]*"' "$STEPCA_CA_JSON" | head -1 | sed 's/"encryptedKey":"//;s/"$//')
|
||||
if [ -z "$ENCRYPTED_KEY" ]; then
|
||||
# Try with spaces around colon (JSON formatting varies)
|
||||
ENCRYPTED_KEY=$(grep -o '"encryptedKey" *: *"[^"]*"' "$STEPCA_CA_JSON" | head -1 | sed 's/"encryptedKey" *: *"//;s/"$//')
|
||||
fi
|
||||
if [ -n "$ENCRYPTED_KEY" ]; then
|
||||
# Check if it's JWE compact serialization (dot-separated) or JSON serialization
|
||||
case "$ENCRYPTED_KEY" in
|
||||
\{*)
|
||||
# Already JSON serialization — write as-is
|
||||
echo "$ENCRYPTED_KEY" > "$STEPCA_KEY_EXTRACTED"
|
||||
;;
|
||||
*)
|
||||
# JWE compact serialization: header.encrypted_key.iv.ciphertext.tag
|
||||
# Convert to JSON serialization expected by Go decryptProvisionerKey()
|
||||
JWE_PROTECTED=$(echo "$ENCRYPTED_KEY" | cut -d. -f1)
|
||||
JWE_ENCKEY=$(echo "$ENCRYPTED_KEY" | cut -d. -f2)
|
||||
JWE_IV=$(echo "$ENCRYPTED_KEY" | cut -d. -f3)
|
||||
JWE_CT=$(echo "$ENCRYPTED_KEY" | cut -d. -f4)
|
||||
JWE_TAG=$(echo "$ENCRYPTED_KEY" | cut -d. -f5)
|
||||
printf '{"protected":"%s","encrypted_key":"%s","iv":"%s","ciphertext":"%s","tag":"%s"}' \
|
||||
"$JWE_PROTECTED" "$JWE_ENCKEY" "$JWE_IV" "$JWE_CT" "$JWE_TAG" > "$STEPCA_KEY_EXTRACTED"
|
||||
;;
|
||||
esac
|
||||
echo " Extracted provisioner key to $STEPCA_KEY_EXTRACTED"
|
||||
echo " Key file size: $(wc -c < "$STEPCA_KEY_EXTRACTED") bytes"
|
||||
echo " Key starts with: $(head -c 40 "$STEPCA_KEY_EXTRACTED")..."
|
||||
# Override the env var so the server reads from the extracted file
|
||||
export CERTCTL_STEPCA_KEY_PATH="$STEPCA_KEY_EXTRACTED"
|
||||
break
|
||||
else
|
||||
echo " ca.json found but encryptedKey not found in it (attempt $i/10)"
|
||||
fi
|
||||
else
|
||||
echo " Waiting for step-ca ca.json (attempt $i/10)..."
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ ! -f "$STEPCA_KEY_EXTRACTED" ]; then
|
||||
echo " WARNING: Could not extract step-ca provisioner key"
|
||||
echo " Listing /stepca-data/config/ for debugging:"
|
||||
ls -la /stepca-data/config/ 2>/dev/null || echo " /stepca-data/config/ does not exist"
|
||||
echo " step-ca issuance will fail."
|
||||
fi
|
||||
|
||||
# --- Update system trust store ---
|
||||
echo "Updating system CA trust store..."
|
||||
update-ca-certificates 2>/dev/null || true
|
||||
|
||||
echo "Trust store updated."
|
||||
|
||||
# --- Debug: verify configuration before starting server ---
|
||||
echo "=== Pre-launch verification ==="
|
||||
echo " CERTCTL_STEPCA_KEY_PATH=$CERTCTL_STEPCA_KEY_PATH"
|
||||
if [ -f "$CERTCTL_STEPCA_KEY_PATH" ]; then
|
||||
echo " step-ca key file exists ($(wc -c < "$CERTCTL_STEPCA_KEY_PATH") bytes)"
|
||||
echo " step-ca key preview: $(head -c 60 "$CERTCTL_STEPCA_KEY_PATH")..."
|
||||
else
|
||||
echo " WARNING: step-ca key file NOT FOUND at $CERTCTL_STEPCA_KEY_PATH"
|
||||
fi
|
||||
echo " CERTCTL_ACME_DIRECTORY_URL=$CERTCTL_ACME_DIRECTORY_URL"
|
||||
echo " CERTCTL_ACME_INSECURE=$CERTCTL_ACME_INSECURE"
|
||||
echo " Pebble CA cert: $(ls -la /usr/local/share/ca-certificates/pebble-ca.crt 2>/dev/null || echo 'NOT FOUND')"
|
||||
echo " step-ca root cert: $(ls -la /usr/local/share/ca-certificates/step-ca-root.crt 2>/dev/null || echo 'NOT FOUND')"
|
||||
echo " System CA count: $(ls /etc/ssl/certs/*.pem 2>/dev/null | wc -l) PEM files"
|
||||
echo "=== Starting certctl server ==="
|
||||
exec /app/server
|
||||
+20
-10
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
|
||||
### Design Principles
|
||||
|
||||
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
|
||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work. For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work and receive only jobs assigned to their targets (routed via `agent_id` on jobs or through target→agent relationships). For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
|
||||
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
|
||||
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
||||
@@ -80,15 +80,20 @@ flowchart TB
|
||||
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
||||
CA3["step-ca\n(/sign API)"]
|
||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||
CA6["Vault PKI\n(planned)"]
|
||||
CA6["Vault PKI\n(token auth, /sign API)"]
|
||||
CA7["DigiCert CertCentral\n(async order model)"]
|
||||
end
|
||||
|
||||
subgraph "Target Systems"
|
||||
T1["NGINX\n(file write + reload)"]
|
||||
T4["Apache httpd\n(file write + reload)"]
|
||||
T5["HAProxy\n(combined PEM + reload)"]
|
||||
T6["Traefik\n(file provider)"]
|
||||
T7["Caddy\n(admin API / file)"]
|
||||
T8["Envoy\n(file-based SDS)"]
|
||||
T9["Postfix/Dovecot\n(file + service reload)"]
|
||||
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
||||
T3["IIS\n(WinRM + local)"]
|
||||
end
|
||||
|
||||
DASH --> API
|
||||
@@ -96,7 +101,7 @@ flowchart TB
|
||||
SVC --> REPO
|
||||
REPO --> PG
|
||||
SCHED --> SVC
|
||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3
|
||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 & CA4 & CA6 & CA7
|
||||
|
||||
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
|
||||
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
|
||||
@@ -116,7 +121,7 @@ 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, 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.
|
||||
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, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS fully implemented; F5 BIG-IP interface stub only) 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.
|
||||
|
||||
@@ -414,7 +419,7 @@ The agent deploys certificates using target connectors. Each connector knows how
|
||||
- **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.
|
||||
- **IIS** (implemented, dual-mode): (1) Agent-local (recommended) — a Windows agent on the IIS box runs PowerShell `Import-PfxCertificate` + `Set-WebBinding` directly with PFX conversion and SHA-1 thumbprint computation. (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 and never initiates outbound connections to agents or targets (pull-only model).
|
||||
|
||||
@@ -506,7 +511,10 @@ flowchart TB
|
||||
II --> ACME["ACME v2"]
|
||||
II --> SC["step-ca"]
|
||||
II --> OC["OpenSSL / Custom CA"]
|
||||
II --> VP["Vault PKI (planned)"]
|
||||
II --> VP["Vault PKI"]
|
||||
II --> DC["DigiCert CertCentral"]
|
||||
II --> SG["Sectigo SCM"]
|
||||
II --> GC["Google CAS"]
|
||||
end
|
||||
|
||||
subgraph "Target Connectors"
|
||||
@@ -517,8 +525,10 @@ flowchart TB
|
||||
TI --> HP["HAProxy"]
|
||||
TI --> TF["Traefik"]
|
||||
TI --> CD["Caddy"]
|
||||
TI --> EV["Envoy"]
|
||||
TI --> PO["Postfix/Dovecot"]
|
||||
TI --> IIS["IIS"]
|
||||
TI --> F5["F5 BIG-IP (interface only)"]
|
||||
TI --> IIS["IIS (interface only)"]
|
||||
end
|
||||
|
||||
subgraph "Notifier Connectors"
|
||||
@@ -570,7 +580,7 @@ type Connector interface {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), and **DigiCert** (commercial CA via CertCentral REST API with async order processing). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||
|
||||
**ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
|
||||
|
||||
@@ -647,7 +657,7 @@ type ESTService interface {
|
||||
}
|
||||
```
|
||||
|
||||
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, OpenSSL, Vault, and DigiCert connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||
|
||||
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ Agents scan configured directories and report back all existing certs. In the da
|
||||
Set up the same issuer certctl uses for non-Kubernetes certs:
|
||||
- **ACME** (Let's Encrypt, for public certs)
|
||||
- **step-ca** (Smallstep, for internal certs)
|
||||
- **Vault PKI** (planned) (HashiCorp Vault, for enterprise PKI)
|
||||
- **Vault PKI** (HashiCorp Vault, for enterprise PKI)
|
||||
- **Private CA** (your own internal root CA)
|
||||
|
||||
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
|
||||
@@ -115,7 +115,7 @@ Certificates are linked to issuers and profiles when created or claimed from dis
|
||||
If cert-manager and certctl both use the same CA:
|
||||
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
|
||||
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
|
||||
- **Vault PKI** (planned): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||
- **Vault PKI**: cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||
|
||||
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
|
||||
|
||||
@@ -138,7 +138,7 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
|
||||
2. Explore [Architecture](./architecture.md#agents) for deployment architecture
|
||||
3. Read about [Discovery Scanning](./quickstart.md#certificate-discovery) to auto-find certs
|
||||
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||
1. Run through the [Quick Start](./quickstart.md) for a 5-minute demo
|
||||
2. Try the [Multi-Issuer example](../examples/multi-issuer/multi-issuer.md) — manages public and internal certs from one dashboard
|
||||
3. Explore [Architecture](./architecture.md#agents) for deployment patterns
|
||||
4. Check the [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||
|
||||
+2
-2
@@ -125,9 +125,9 @@ Agents also report **metadata** about themselves — their operating system, CPU
|
||||
|
||||
### Deployment Targets
|
||||
|
||||
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).
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, Apache httpd servers, HAProxy load balancers, Traefik reverse proxies, Caddy servers, Envoy gateways, Postfix/Dovecot mail servers, Microsoft IIS servers, and network appliances. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX or Apache config, building a combined PEM for HAProxy).
|
||||
|
||||
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.
|
||||
For targets where an agent runs directly on the machine (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, 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
|
||||
|
||||
|
||||
+222
-19
@@ -21,9 +21,11 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
- [Built-in: Apache httpd](#built-in-apache-httpd)
|
||||
- [Built-in: HAProxy](#built-in-haproxy)
|
||||
- [Built-in: Traefik](#built-in-traefik)
|
||||
- [Built-in: Envoy](#built-in-envoy)
|
||||
- [Built-in: Postfix / Dovecot](#built-in-postfix--dovecot)
|
||||
- [Built-in: Caddy](#built-in-caddy)
|
||||
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
||||
- [IIS (Interface Only, Dual-Mode)](#iis-interface-only-dual-mode)
|
||||
- [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode)
|
||||
4. [Notifier Connector](#notifier-connector)
|
||||
- [Interface](#interface-2)
|
||||
5. [Registering a Connector](#registering-a-connector)
|
||||
@@ -51,8 +53,8 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
|
||||
Three types of connectors:
|
||||
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA, Vault PKI, DigiCert implemented; additional CA integrations planned)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS implemented; F5 via proxy agent planned; 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. 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.
|
||||
@@ -312,12 +314,95 @@ The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used b
|
||||
|
||||
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||
|
||||
### Coming in V2.1
|
||||
### Built-in: Vault PKI
|
||||
|
||||
The following issuer connectors are planned for the v2.1.0 release:
|
||||
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
|
||||
|
||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine (`/v1/{mount}/sign/{role}` API) for organizations using Vault as their internal CA. Token auth, configurable mount and role.
|
||||
- **DigiCert** — Commercial CA integration via DigiCert CertCentral REST API. Async order model (submit → poll for completion). OV/EV certificate support.
|
||||
**Configuration:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_VAULT_ADDR` | — | Vault server address (e.g., `https://vault.internal:8200`) |
|
||||
| `CERTCTL_VAULT_TOKEN` | — | Vault auth token with permissions on the PKI mount |
|
||||
| `CERTCTL_VAULT_MOUNT` | `pki` | PKI secrets engine mount path |
|
||||
| `CERTCTL_VAULT_ROLE` | — | PKI role name for certificate signing |
|
||||
| `CERTCTL_VAULT_TTL` | `8760h` | Certificate validity period (TTL) |
|
||||
|
||||
The connector is registered in the issuer registry under `iss-vault`. Vault issues certificates synchronously via the `/v1/{mount}/sign/{role}` API with `X-Vault-Token` header authentication. The issued certificate is parsed to extract serial number, validity dates, and chain information.
|
||||
|
||||
**Note:** CRL and OCSP are managed by Vault itself. Clients should validate certificate status against Vault's own CRL/OCSP endpoints (`GET /v1/{mount}/crl` and Vault's OCSP responder). certctl does not generate local CRL/OCSP for Vault-issued certificates. Revocation is recorded locally but Vault is the authoritative source.
|
||||
|
||||
Location: `internal/connector/issuer/vault/vault.go`
|
||||
|
||||
### Built-in: DigiCert CertCentral
|
||||
|
||||
The DigiCert connector integrates with DigiCert's CertCentral REST API for ordering and managing certificates from DigiCert's commercial CA. It supports both Domain Validated (DV) and Organization/Extended Validated (OV/EV) certificates, with async order processing.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_DIGICERT_API_KEY` | — | DigiCert API key (X-DC-DEVKEY header) |
|
||||
| `CERTCTL_DIGICERT_ORG_ID` | — | DigiCert organization ID |
|
||||
| `CERTCTL_DIGICERT_PRODUCT_TYPE` | `ssl_basic` | Certificate product (e.g., `ssl_basic`, `ssl_plus`, `ssl_ev`) |
|
||||
| `CERTCTL_DIGICERT_BASE_URL` | `https://www.digicert.com/services/v2` | DigiCert API base URL |
|
||||
|
||||
The connector submits certificate orders to DigiCert's `/order/certificate/create` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by DigiCert) and poll-based completion. The connector periodically checks order status via `/order/certificate/{order_id}` until the certificate is available.
|
||||
|
||||
**Authentication:** API key passed via `X-DC-DEVKEY` header, with organization ID in request body.
|
||||
|
||||
**Note:** CRL and OCSP are managed by DigiCert. Clients should validate certificate status against DigiCert's infrastructure. certctl records the revocation locally but does not notify DigiCert for revocation — use DigiCert's dashboard for revocation management.
|
||||
|
||||
Location: `internal/connector/issuer/digicert/digicert.go`
|
||||
|
||||
### Built-in: Sectigo SCM
|
||||
|
||||
The Sectigo connector integrates with Sectigo Certificate Manager's REST API for ordering and managing DV, OV, and EV certificates. Like DigiCert, it uses an async order model: submit an enrollment, receive an sslId, then poll for completion.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SECTIGO_CUSTOMER_URI` | — | Sectigo customer URI (organization identifier) |
|
||||
| `CERTCTL_SECTIGO_LOGIN` | — | API account login |
|
||||
| `CERTCTL_SECTIGO_PASSWORD` | — | API account password |
|
||||
| `CERTCTL_SECTIGO_ORG_ID` | — | Organization ID (integer) |
|
||||
| `CERTCTL_SECTIGO_CERT_TYPE` | — | Certificate type ID (integer, from `/ssl/v1/types`) |
|
||||
| `CERTCTL_SECTIGO_TERM` | `365` | Certificate validity in days |
|
||||
| `CERTCTL_SECTIGO_BASE_URL` | `https://cert-manager.com/api` | Sectigo API base URL |
|
||||
|
||||
The connector submits certificate enrollments to Sectigo's `/ssl/v1/enroll` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by Sectigo) and poll-based completion. The connector periodically checks enrollment status via `/ssl/v1/{sslId}` and downloads the PEM bundle via `/ssl/v1/collect/{sslId}/pem` when issued.
|
||||
|
||||
**Authentication:** Three custom headers on every request — `customerUri`, `login`, and `password`.
|
||||
|
||||
**Note:** CRL and OCSP are managed by Sectigo. certctl records revocations locally and notifies Sectigo via `/ssl/v1/revoke/{sslId}`.
|
||||
|
||||
Location: `internal/connector/issuer/sectigo/sectigo.go`
|
||||
|
||||
### Built-in: Google CAS
|
||||
|
||||
Google Cloud Certificate Authority Service — managed private CA on GCP. Synchronous issuance via CAS REST API with OAuth2 service account auth.
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
| `CERTCTL_GOOGLE_CAS_PROJECT` | Yes | — | GCP project ID |
|
||||
| `CERTCTL_GOOGLE_CAS_LOCATION` | Yes | — | GCP region (e.g., `us-central1`) |
|
||||
| `CERTCTL_GOOGLE_CAS_CA_POOL` | Yes | — | CA pool name |
|
||||
| `CERTCTL_GOOGLE_CAS_CREDENTIALS` | Yes | — | Path to service account JSON |
|
||||
| `CERTCTL_GOOGLE_CAS_TTL` | No | `8760h` | Default certificate TTL |
|
||||
|
||||
**Authentication:** OAuth2 service account. The connector reads a service account JSON file, signs a JWT with the private key, and exchanges it for an access token at Google's token endpoint. Tokens are cached and refreshed automatically (5 min before expiry).
|
||||
|
||||
**Note:** CRL and OCSP are managed by Google CAS directly. certctl records revocations locally and notifies Google CAS via the revoke endpoint.
|
||||
|
||||
Location: `internal/connector/issuer/googlecas/googlecas.go`
|
||||
|
||||
### Coming in V2.2+
|
||||
|
||||
The following issuer connectors are planned for future releases:
|
||||
|
||||
- **Entrust** — Enterprise CA via Entrust API
|
||||
- **AWS ACM Private CA** — AWS-managed private CA
|
||||
|
||||
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.
|
||||
|
||||
@@ -547,6 +632,78 @@ When `mode` is `"api"`, the connector posts the certificate to the admin API end
|
||||
|
||||
Location: `internal/connector/target/caddy/caddy.go`
|
||||
|
||||
### Built-in: Envoy
|
||||
|
||||
The Envoy connector uses file-based certificate delivery — it writes certificate and key files to a directory that Envoy watches via its SDS (Secret Discovery Service) file-based configuration or static `filename` references in the bootstrap config. When files change, Envoy automatically picks up the new certificates without requiring a reload command.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"cert_dir": "/etc/envoy/certs",
|
||||
"cert_filename": "cert.pem",
|
||||
"key_filename": "key.pem",
|
||||
"chain_filename": "chain.pem",
|
||||
"sds_config": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `cert_dir` | string | (required) | Directory where Envoy watches for certificate files |
|
||||
| `cert_filename` | string | `cert.pem` | Filename for the certificate (leaf + chain unless `chain_filename` is set) |
|
||||
| `key_filename` | string | `key.pem` | Filename for the private key |
|
||||
| `chain_filename` | string | (empty) | If set, chain is written to a separate file instead of appended to the cert |
|
||||
| `sds_config` | bool | `false` | If true, writes an `sds.json` file for Envoy's file-based SDS provider |
|
||||
|
||||
When `sds_config` is `true`, the connector writes an SDS JSON file (`{cert_dir}/sds.json`) containing a `tls_certificate` resource that points to the cert and key file paths. Envoy's file-based SDS (`path_config_source`) watches this file for changes, providing automatic hot-reload of certificates. This is the recommended approach for production Envoy deployments using dynamic TLS configuration.
|
||||
|
||||
When `sds_config` is `false` (the default), the connector simply writes cert and key files. Use this mode when Envoy's bootstrap config references the cert/key files directly via static `filename` fields in the TLS context.
|
||||
|
||||
Location: `internal/connector/target/envoy/envoy.go`
|
||||
|
||||
### Built-in: Postfix / Dovecot
|
||||
|
||||
The Postfix/Dovecot connector is a dual-mode mail server TLS connector. It writes certificate, key, and chain files to configured paths and reloads the mail service. The `mode` field selects between Postfix MTA and Dovecot IMAP/POP3, which determines default file paths and reload commands.
|
||||
|
||||
This connector pairs with certctl's S/MIME certificate support (email protection EKU, email SAN routing) for a complete email infrastructure story — TLS for transport encryption, S/MIME for end-to-end message signing and encryption.
|
||||
|
||||
**Postfix configuration:**
|
||||
```json
|
||||
{
|
||||
"mode": "postfix",
|
||||
"cert_path": "/etc/postfix/certs/cert.pem",
|
||||
"key_path": "/etc/postfix/certs/key.pem",
|
||||
"chain_path": "/etc/postfix/certs/chain.pem",
|
||||
"reload_command": "postfix reload",
|
||||
"validate_command": "postfix check"
|
||||
}
|
||||
```
|
||||
|
||||
**Dovecot configuration:**
|
||||
```json
|
||||
{
|
||||
"mode": "dovecot",
|
||||
"cert_path": "/etc/dovecot/certs/cert.pem",
|
||||
"key_path": "/etc/dovecot/certs/key.pem",
|
||||
"chain_path": "/etc/dovecot/certs/chain.pem",
|
||||
"reload_command": "doveadm reload",
|
||||
"validate_command": "doveconf -n"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default (Postfix) | Default (Dovecot) | Description |
|
||||
|-------|------|-------------------|-------------------|-------------|
|
||||
| `mode` | string | `postfix` | `dovecot` | Service mode — determines defaults |
|
||||
| `cert_path` | string | `/etc/postfix/certs/cert.pem` | `/etc/dovecot/certs/cert.pem` | Path for certificate file |
|
||||
| `key_path` | string | `/etc/postfix/certs/key.pem` | `/etc/dovecot/certs/key.pem` | Path for private key (0600 permissions) |
|
||||
| `chain_path` | string | (empty) | (empty) | If set, chain written separately; otherwise appended to cert |
|
||||
| `reload_command` | string | `postfix reload` | `doveadm reload` | Command to reload the mail service |
|
||||
| `validate_command` | string | `postfix check` | `doveconf -n` | Optional config validation before reload |
|
||||
|
||||
All commands are validated against shell injection via `validation.ValidateShellCommand()`. File permissions: cert/chain 0644, key 0600.
|
||||
|
||||
Location: `internal/connector/target/postfix/postfix.go`
|
||||
|
||||
### F5 BIG-IP (Interface Only)
|
||||
|
||||
The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it.
|
||||
@@ -568,30 +725,76 @@ Note: F5 credentials are stored on the proxy agent, not on the control plane ser
|
||||
|
||||
Location: `internal/connector/target/f5/f5.go`
|
||||
|
||||
### IIS (Interface Only, Dual-Mode)
|
||||
### IIS (Implemented, Dual-Mode)
|
||||
|
||||
The IIS target connector supports two planned deployment modes:
|
||||
The IIS target connector supports two deployment modes — agent-local (recommended) and proxy agent WinRM for agentless targets.
|
||||
|
||||
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. This is the preferred approach: no remote access needed, no credential management, same pull-based model as NGINX/Apache/HAProxy.
|
||||
**Agent-local (recommended):** A Windows agent runs directly on the IIS server and deploys certificates using PowerShell — `Import-PfxCertificate` to install into the certificate store and `Set-WebBinding` to bind to the IIS site. The agent handles PEM-to-PFX conversion via `go-pkcs12`, computes SHA-1 thumbprint from the certificate, and executes parameterized PowerShell scripts for injection-safe binding management. 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.
|
||||
**Proxy agent WinRM (for agentless targets):** For Windows servers where you don't want to install an agent, a Linux or Windows proxy agent in the same network zone connects via WinRM (Windows Remote Management) and executes PowerShell commands remotely. The PFX bundle is base64-encoded, transferred inline in the WinRM session, decoded to a temp file on the remote host, imported, and the temp file is cleaned up in a `try/finally` block. WinRM credentials are configured on the target, not on the control plane. Uses the `masterzen/winrm` Go library with support for Basic, NTLM, and Kerberos authentication.
|
||||
|
||||
Configuration (defined, not yet functional):
|
||||
**Agent-local configuration:**
|
||||
```json
|
||||
{
|
||||
"mode": "local",
|
||||
"hostname": "iis-server.example.com",
|
||||
"site_name": "Default Web Site",
|
||||
"cert_store": "WebHosting",
|
||||
"winrm_host": "",
|
||||
"winrm_username": "",
|
||||
"winrm_password": "",
|
||||
"winrm_use_https": true
|
||||
"port": 443,
|
||||
"sni": true,
|
||||
"ip_address": "*",
|
||||
"binding_info": "www.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
**WinRM proxy configuration:**
|
||||
```json
|
||||
{
|
||||
"hostname": "iis-server.example.com",
|
||||
"site_name": "Default Web Site",
|
||||
"cert_store": "WebHosting",
|
||||
"port": 443,
|
||||
"sni": true,
|
||||
"ip_address": "*",
|
||||
"mode": "winrm",
|
||||
"winrm": {
|
||||
"winrm_host": "iis-server.example.com",
|
||||
"winrm_port": 5985,
|
||||
"winrm_username": "Administrator",
|
||||
"winrm_password": "...",
|
||||
"winrm_https": false,
|
||||
"winrm_insecure": false,
|
||||
"winrm_timeout": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Location: `internal/connector/target/iis/iis.go`
|
||||
**Configuration Fields:**
|
||||
- `hostname` (string, required): IIS server hostname or FQDN
|
||||
- `site_name` (string, required): IIS website name (e.g., "Default Web Site")
|
||||
- `cert_store` (string, required): Certificate store for import (e.g., "WebHosting", "My")
|
||||
- `port` (number, default 443): HTTPS binding port
|
||||
- `sni` (boolean, default false): Enable Server Name Indication (SNI)
|
||||
- `ip_address` (string, default "*"): Specific IP to bind to, or "*" for all IPs
|
||||
- `binding_info` (string, optional): Host header for SNI bindings
|
||||
- `mode` (string, default "local"): Deployment mode — `local` (agent-local PowerShell) or `winrm` (remote via WinRM)
|
||||
|
||||
**WinRM fields (required when `mode` is `winrm`):**
|
||||
- `winrm.winrm_host` (string, required): Remote Windows server hostname or IP
|
||||
- `winrm.winrm_port` (number, default 5985 HTTP / 5986 HTTPS): WinRM listener port
|
||||
- `winrm.winrm_username` (string, required): Windows account with admin privileges
|
||||
- `winrm.winrm_password` (string, required): Account password
|
||||
- `winrm.winrm_https` (boolean, default false): Use HTTPS transport
|
||||
- `winrm.winrm_insecure` (boolean, default false): Skip TLS certificate verification
|
||||
- `winrm.winrm_timeout` (number, default 60): Operation timeout in seconds
|
||||
|
||||
**Security Model:**
|
||||
- PFX files are transient — generated with random passwords, deleted after import
|
||||
- In WinRM mode, PFX data is base64-encoded and transferred inline (no SMB/file share needed), with remote temp file cleanup in `try/finally`
|
||||
- PowerShell commands use parameterized values — IIS names and cert stores are regex-validated before script execution
|
||||
- Field names are validated against `^[a-zA-Z0-9 _\-\.]+$` to prevent PowerShell injection
|
||||
- Certificate thumbprints computed via SHA-1 for IIS binding lookups
|
||||
|
||||
Location: `internal/connector/target/iis/iis.go`, `internal/connector/target/iis/winrm.go`
|
||||
|
||||
## Notifier Connector
|
||||
|
||||
|
||||
@@ -307,8 +307,8 @@ flowchart TD
|
||||
A --> F["ACME\n(Let's Encrypt)"]
|
||||
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 --> J["DigiCert API\n(implemented)"]
|
||||
A --> K["Vault PKI\n(implemented)"]
|
||||
A --> L["Entrust / GlobalSign\n(planned)"]
|
||||
A --> M["Google CAS / EJBCA\n(planned)"]
|
||||
```
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Deployment Examples
|
||||
|
||||
Five turnkey docker-compose scenarios, each runnable in under 5 minutes. Pick the one closest to your setup.
|
||||
|
||||
## Which Example Should I Use?
|
||||
|
||||
| I need to... | Example | Issuer | Target |
|
||||
|--------------|---------|--------|--------|
|
||||
| Get Let's Encrypt certs for NGINX on a public server | [ACME + NGINX](#acme--nginx) | ACME (HTTP-01) | NGINX |
|
||||
| Issue wildcard certs without opening port 80 | [Wildcard DNS-01](#wildcard-dns-01) | ACME (DNS-01) | Any |
|
||||
| Run an internal CA for services behind a firewall | [Private CA + Traefik](#private-ca--traefik) | Local CA | Traefik |
|
||||
| Use Smallstep step-ca as my PKI backend | [step-ca + HAProxy](#step-ca--haproxy) | step-ca | HAProxy |
|
||||
| Manage both public and internal certs from one dashboard | [Multi-Issuer](#multi-issuer) | ACME + Local CA | Mixed |
|
||||
|
||||
**Already using another tool?** See the migration sections below each example for Certbot, acme.sh, and cert-manager users.
|
||||
|
||||
---
|
||||
|
||||
## ACME + NGINX
|
||||
|
||||
**Scenario:** You have one or more public-facing domains, NGINX as the reverse proxy, and want automated Let's Encrypt certificates with HTTP-01 challenges.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent + NGINX, all on one Docker network. The agent generates keys locally (ECDSA P-256), submits CSRs to the server, receives signed certs from Let's Encrypt, and deploys them to NGINX with automatic reload.
|
||||
|
||||
**Prerequisites:** A domain pointing to your server, ports 80 and 443 open, Docker Compose v20.10+.
|
||||
|
||||
```bash
|
||||
cd examples/acme-nginx
|
||||
cp .env.example .env # Edit with your domain and email
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including how HTTP-01 challenges work, adding multiple domains, switching to staging for testing, and a production checklist — is in the [example README](../examples/acme-nginx/acme-nginx.md).
|
||||
|
||||
**Migrating from Certbot?** certctl discovers your existing `/etc/letsencrypt/live/` certificates automatically. You keep your ACME account, disable the Certbot cron, and certctl takes over renewal with centralized visibility and deployment verification. The step-by-step process is in [Migrating from Certbot](migrate-from-certbot.md).
|
||||
|
||||
---
|
||||
|
||||
## Wildcard DNS-01
|
||||
|
||||
**Scenario:** You need wildcard certificates (`*.example.com`) or your servers aren't reachable from the internet (no port 80). DNS-01 validates ownership by creating a TXT record at your DNS provider.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent. Includes a Cloudflare DNS hook script as a working reference — swap in your own DNS provider (Route53, Azure DNS, Google Cloud DNS, or any provider with an API).
|
||||
|
||||
**Prerequisites:** A domain, API credentials for your DNS provider, Docker Compose.
|
||||
|
||||
```bash
|
||||
cd examples/acme-wildcard-dns01
|
||||
cp .env.example .env # Edit with domain, email, DNS provider credentials
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including DNS-PERSIST-01 (set a TXT record once, never touch DNS again on renewals), adapting scripts for other providers, and propagation troubleshooting — is in the [example README](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md).
|
||||
|
||||
**Migrating from acme.sh?** Your existing `dns_*` hook scripts are compatible with certctl's DNS-01 — they use the same pattern (shell scripts creating TXT records). The migration guide covers script adaptation, discovery of existing acme.sh certificates, and phasing out the acme.sh cron. See [Migrating from acme.sh](migrate-from-acmesh.md).
|
||||
|
||||
---
|
||||
|
||||
## Private CA + Traefik
|
||||
|
||||
**Scenario:** Internal services that don't need public CA validation. You run your own certificate authority — either a self-signed root for development, or a subordinate CA chained to your enterprise root (e.g., Active Directory Certificate Services).
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent + Traefik. The Local CA issuer signs certificates directly. Traefik watches a cert directory and auto-reloads when new files appear.
|
||||
|
||||
**Prerequisites:** Docker Compose. For sub-CA mode, you'll need a CA certificate and key signed by your enterprise root.
|
||||
|
||||
```bash
|
||||
cd examples/private-ca-traefik
|
||||
docker compose up -d # Self-signed mode (no .env needed for demo)
|
||||
```
|
||||
|
||||
The full walkthrough — including sub-CA setup with `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH`, creating certificates via the API, monitoring deployments, and production hardening — is in the [example README](../examples/private-ca-traefik/private-ca-traefik.md).
|
||||
|
||||
---
|
||||
|
||||
## step-ca + HAProxy
|
||||
|
||||
**Scenario:** You use Smallstep's step-ca as your private PKI and want automated lifecycle management for certificates deployed to HAProxy load balancers.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent + step-ca (with JWK provisioner) + HAProxy. certctl issues certs via step-ca's native `/sign` API, combines them into HAProxy's expected PEM format (cert + chain + key in one file), and reloads HAProxy.
|
||||
|
||||
**Prerequisites:** Docker Compose.
|
||||
|
||||
```bash
|
||||
cd examples/step-ca-haproxy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including step-ca provisioner configuration, integrating with an existing step-ca instance, HAProxy PEM format details, and advanced features (approval workflows, policy-based renewal, multi-instance HAProxy) — is in the [example README](../examples/step-ca-haproxy/step-ca-haproxy.md).
|
||||
|
||||
---
|
||||
|
||||
## Multi-Issuer
|
||||
|
||||
**Scenario:** You manage both public-facing services (needing Let's Encrypt or another public CA) and internal services (using a private CA) and want a single dashboard for everything.
|
||||
|
||||
**What it deploys:** certctl server + PostgreSQL + certctl agent configured with both an ACME issuer and a Local CA issuer. Demonstrates issuer assignment via profiles — public services get ACME certs, internal services get Local CA certs, all visible in one inventory.
|
||||
|
||||
**Prerequisites:** Docker Compose. For real ACME certs, a public domain and port 80 access.
|
||||
|
||||
```bash
|
||||
cd examples/multi-issuer
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including profile-based issuer assignment, testing with ACME staging, Local CA enterprise sub-CA mode, and scaling beyond Docker Compose — is in the [example README](../examples/multi-issuer/multi-issuer.md).
|
||||
|
||||
**Using cert-manager for Kubernetes?** certctl complements cert-manager — cert-manager handles in-cluster certs, certctl handles everything outside: VMs, bare metal, network appliances, Windows servers. They can share the same CA (ACME, step-ca, Vault PKI). See [certctl for cert-manager Users](certctl-for-cert-manager-users.md).
|
||||
|
||||
---
|
||||
|
||||
## Beyond These Examples
|
||||
|
||||
These 5 scenarios cover the most common deployment patterns, but certctl supports 7 issuer backends and 10 target connectors. Once you have the basics running, you can mix and match:
|
||||
|
||||
**Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon).
|
||||
|
||||
**Targets:** NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell or WinRM proxy), Postfix, Dovecot, F5 BIG-IP (coming soon).
|
||||
|
||||
See [Connector Reference](connectors.md) for configuration details on every issuer and target.
|
||||
+9
-9
@@ -1286,11 +1286,11 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
||||
- **Docker Tags** — `:latest`, `:v{version}` (`shankar0123.docker.scarf.sh/certctl-server`, `shankar0123.docker.scarf.sh/certctl-agent`)
|
||||
|
||||
### Test Suite
|
||||
- **Unit Tests** — 625+ test functions across service, handler, middleware, domain layers
|
||||
- **Unit Tests** — 1,088+ test functions across service, handler, middleware, domain layers
|
||||
- **Integration Tests** — End-to-end workflows (issuance→renewal→deployment)
|
||||
- **Negative Tests** — Malformed input, nonexistent resources, error conditions
|
||||
- **Frontend Tests** — 86 Vitest tests (API client, utilities, stats/metrics, full endpoint coverage)
|
||||
- **Total Coverage** — 900+ tests (Go + frontend combined)
|
||||
- **Frontend Tests** — 211 Vitest tests (API client, utilities, stats/metrics, full endpoint coverage)
|
||||
- **Total Coverage** — 1,554+ tests (Go + frontend combined)
|
||||
|
||||
### Licensing
|
||||
- **License** — Business Source License 1.1 (BSL 1.1)
|
||||
@@ -1469,8 +1469,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
||||
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
||||
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
||||
| **DigiCert issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
||||
| **Vault PKI issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
||||
| **DigiCert issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||
| **Vault PKI issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1478,10 +1478,10 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| **API Endpoints** | 95 (under /api/v1/ + /.well-known/est/) |
|
||||
| **API Endpoints** | 97 (under /api/v1/ + /.well-known/est/) |
|
||||
| **Dashboard** | Full web GUI |
|
||||
| **Issuer Connectors** | 4 (Local CA, ACME, step-ca, OpenSSL) |
|
||||
| **Target Connectors** | 5 (3 impl: NGINX, Apache, HAProxy; 2 stubs: F5, IIS) |
|
||||
| **Issuer Connectors** | 6 (Local CA, ACME, step-ca, OpenSSL, Vault PKI, DigiCert) |
|
||||
| **Target Connectors** | 10 (9 impl: NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS, Postfix, Dovecot; 1 stub: F5) |
|
||||
| **Notifier Channels** | 6 (Email, Webhook, Slack, Teams, PagerDuty, OpsGenie) |
|
||||
| **Job Types** | 4 (Issuance, Renewal, Deployment, Validation) |
|
||||
| **Job States** | 7 (Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled) |
|
||||
@@ -1492,6 +1492,6 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
| **MCP Tools** | 76 (16 resource domains) |
|
||||
| **CLI Subcommands** | 10 |
|
||||
| **Database Tables** | 19 |
|
||||
| **Test Suite** | 900+ tests (Go backend + frontend) |
|
||||
| **Test Suite** | 1,554+ tests (Go backend + frontend) |
|
||||
| **Environment Variables** | 41+ configuration options |
|
||||
|
||||
|
||||
@@ -267,8 +267,9 @@ export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||
|
||||
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
||||
|
||||
## Support
|
||||
## Next Steps
|
||||
|
||||
See [Connector Configuration](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts).
|
||||
|
||||
See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale.
|
||||
- Try the [Wildcard DNS-01 example](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md) — a working docker-compose with Cloudflare hooks you can adapt for your DNS provider
|
||||
- See [Connector Reference](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts)
|
||||
- See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale
|
||||
- See all [Deployment Examples](./examples.md) for other scenarios (ACME+NGINX, private CA, step-ca, multi-issuer)
|
||||
|
||||
@@ -166,6 +166,7 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Try the [ACME + NGINX example](../examples/acme-nginx/acme-nginx.md) — a working docker-compose you can run locally before deploying to production
|
||||
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
||||
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
||||
- Set up [Kubernetes cert-manager integration](./certctl-for-cert-manager-users.md) if you manage in-cluster certs too
|
||||
- See all [Deployment Examples](./examples.md) for other scenarios (wildcard DNS-01, private CA, step-ca, multi-issuer)
|
||||
|
||||
+4
-1
@@ -461,7 +461,10 @@ The `-v` flag removes the PostgreSQL data volume for a clean slate.
|
||||
|
||||
## What's Next
|
||||
|
||||
**Ready to deploy with your stack?** The [Deployment Examples](examples.md) page has 5 turnkey docker-compose scenarios — pick the one closest to your setup and have it running in minutes. It also covers migration paths from Certbot, acme.sh, and cert-manager.
|
||||
|
||||
- **[Deployment Examples](examples.md)** — ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer
|
||||
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA end-to-end
|
||||
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together
|
||||
- **[Connector Guide](connectors.md)** — Build custom connectors for your infrastructure
|
||||
- **[Connector Reference](connectors.md)** — Configuration for all 7 issuers and 10 targets
|
||||
- **[Concepts Guide](concepts.md)** — TLS certificates, CAs, and private keys explained from scratch
|
||||
|
||||
+1068
File diff suppressed because it is too large
Load Diff
+886
-6
@@ -39,6 +39,14 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
|
||||
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
||||
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
||||
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
||||
- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration)
|
||||
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
|
||||
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
||||
- [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
|
||||
- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37)
|
||||
- [Part 40: Issuer Catalog Page (M33)](#part-40-issuer-catalog-page-m33)
|
||||
- [Part 41: Frontend Audit Fixes](#part-41-frontend-audit-fixes)
|
||||
- [Part 42: IIS Target Connector (M39)](#part-42-iis-target-connector-m39)
|
||||
- [Release Sign-Off](#release-sign-off)
|
||||
|
||||
---
|
||||
@@ -1592,7 +1600,7 @@ curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
|
||||
|
||||
---
|
||||
|
||||
**Test 7.1.6 — Create IIS target (stub)**
|
||||
**Test 7.1.6 — Create IIS target**
|
||||
|
||||
```bash
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
|
||||
@@ -5069,6 +5077,618 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
||||
|
||||
---
|
||||
|
||||
## Part 35: ARI (RFC 9702) Scheduler Integration
|
||||
|
||||
Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates.
|
||||
|
||||
### 35.1 ARI Defers Renewal When CA Says "Not Yet"
|
||||
|
||||
**Prerequisite:** ACME issuer configured with `CERTCTL_ACME_ARI_ENABLED=true`, connected to a CA that supports ARI (e.g., Let's Encrypt staging). Certificate within the 30-day expiry window but the CA's `suggestedWindow.start` is in the future.
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI deferral
|
||||
docker logs certctl-server 2>&1 | grep "ARI: renewal not yet suggested"
|
||||
```
|
||||
|
||||
**Expected:** Log line showing `ARI: renewal not yet suggested by CA` with `cert_id`, `suggested_start`, `suggested_end`. No renewal job created for that cert.
|
||||
**PASS if** the scheduler skips renewal job creation when ARI says the window hasn't opened.
|
||||
|
||||
### 35.2 ARI Triggers Renewal When CA Says "Now"
|
||||
|
||||
**Prerequisite:** Same setup as 35.1, but the certificate's ARI `suggestedWindow.start` is in the past (CA is actively suggesting renewal).
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI-triggered renewal
|
||||
docker logs certctl-server 2>&1 | grep "ARI: CA suggests renewal now"
|
||||
|
||||
# Verify renewal job was created
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/jobs?type=renewal" | jq '.data[] | select(.certificate_id == "<cert-id>")'
|
||||
```
|
||||
|
||||
**Expected:** Log line showing `ARI: CA suggests renewal now`. Renewal job created with `renewal_trigger: ari` in the audit trail.
|
||||
**PASS if** a renewal job is created when ARI indicates the renewal window is open.
|
||||
|
||||
### 35.3 ARI Fallback on Error
|
||||
|
||||
**Prerequisite:** ACME issuer with `CERTCTL_ACME_ARI_ENABLED=true`, but the ARI endpoint is unreachable or returns an error (e.g., network issue, 500 from CA).
|
||||
|
||||
```bash
|
||||
# Check scheduler logs for ARI fallback
|
||||
docker logs certctl-server 2>&1 | grep "ARI check failed, falling back"
|
||||
```
|
||||
|
||||
**Expected:** Warning log `ARI check failed, falling back to threshold-based renewal`. Renewal proceeds normally using the configured expiration thresholds.
|
||||
**PASS if** renewal still works when ARI is unavailable, using threshold-based logic as fallback.
|
||||
|
||||
---
|
||||
|
||||
## Part 36: Agent Work Routing (M31)
|
||||
|
||||
Tests that `GetPendingWork()` returns only jobs scoped to the requesting agent, and that deployment jobs have `agent_id` populated at creation time.
|
||||
|
||||
### 36.1 Multi-Agent Routing
|
||||
|
||||
**Prerequisite:** Two agents registered (`agent-web-01`, `agent-lb-01`), two targets (one per agent), one certificate mapped to both targets. Trigger renewal to create deployment jobs.
|
||||
|
||||
```bash
|
||||
# Poll as agent-web-01 — should only see its deployment job
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-web-01/work" | jq '.[] | .target_id'
|
||||
|
||||
# Poll as agent-lb-01 — should only see its deployment job
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-lb-01/work" | jq '.[] | .target_id'
|
||||
```
|
||||
|
||||
**Expected:** Each agent receives only the deployment job for its assigned target. Agent-web-01 does NOT see agent-lb-01's job and vice versa.
|
||||
**PASS if** each agent's work response contains only jobs for targets it owns.
|
||||
|
||||
### 36.2 Agent With No Targets Gets Empty Work
|
||||
|
||||
**Prerequisite:** Register a new agent with no target assignments.
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/agents/agent-no-targets/work" | jq 'length'
|
||||
```
|
||||
|
||||
**Expected:** Empty array (0 jobs).
|
||||
**PASS if** the response is an empty list.
|
||||
|
||||
### 36.3 Deployment Jobs Have agent_id Populated
|
||||
|
||||
**Prerequisite:** Deployment jobs created via renewal or manual trigger.
|
||||
|
||||
```bash
|
||||
# Check that deployment jobs in the system have agent_id set
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"http://localhost:8443/api/v1/jobs" | jq '[.data[] | select(.type == "Deployment") | .agent_id] | map(select(. != null)) | length'
|
||||
```
|
||||
|
||||
**Expected:** All deployment jobs for targets with agent assignments have `agent_id` populated.
|
||||
**PASS if** deployment jobs have non-null `agent_id` values.
|
||||
|
||||
---
|
||||
|
||||
## Part 38: Vault PKI Connector (M32)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Vault server running with PKI secrets engine enabled at `pki` mount
|
||||
- PKI role created with appropriate certificate generation policy
|
||||
- Vault token with read/sign permissions on the PKI path
|
||||
- Environment variables configured:
|
||||
```bash
|
||||
export CERTCTL_VAULT_ADDR="https://vault.internal:8200"
|
||||
export CERTCTL_VAULT_TOKEN="s.xxxxxxxxxxxxxxxx"
|
||||
export CERTCTL_VAULT_MOUNT="pki"
|
||||
export CERTCTL_VAULT_ROLE="certctl-role"
|
||||
export CERTCTL_VAULT_TTL="8760h"
|
||||
```
|
||||
|
||||
### 38.1 Register Vault PKI Issuer
|
||||
|
||||
**Test:** Register a Vault PKI issuer via the API.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/issuers" \
|
||||
-d '{
|
||||
"id": "iss-vault-prod",
|
||||
"name": "Vault PKI Production",
|
||||
"type": "VaultPKI",
|
||||
"config": {
|
||||
"vault_addr": "'"$CERTCTL_VAULT_ADDR"'",
|
||||
"vault_token": "'"$CERTCTL_VAULT_TOKEN"'",
|
||||
"vault_mount": "'"$CERTCTL_VAULT_MOUNT"'",
|
||||
"vault_role": "'"$CERTCTL_VAULT_ROLE"'",
|
||||
"vault_ttl": "'"$CERTCTL_VAULT_TTL"'"
|
||||
}
|
||||
}' | jq '.id'
|
||||
```
|
||||
|
||||
**Expected:** Returns issuer ID `iss-vault-prod`.
|
||||
**PASS if** issuer is registered and appears in `GET /api/v1/issuers`.
|
||||
|
||||
### 38.2 Issue Certificate via Vault PKI
|
||||
|
||||
**Test:** Create a certificate and issue it through Vault PKI.
|
||||
|
||||
```bash
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "vault-test.example.com",
|
||||
"issuer_id": "iss-vault-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq '.job_id'
|
||||
```
|
||||
|
||||
**Expected:** Renewal job created and eventually moves to Completed status.
|
||||
**PASS if** certificate is issued by Vault with valid serial number and chain.
|
||||
|
||||
### 38.3 Verify Certificate Serial and Subject
|
||||
|
||||
**Test:** Check that the issued certificate has correct Vault metadata.
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID" | jq '.versions[0] | {serial, subject_dn, not_before, not_after}'
|
||||
```
|
||||
|
||||
**Expected:** Serial, DN, and validity dates from Vault PKI.
|
||||
**PASS if** certificate metadata is populated from Vault's response.
|
||||
|
||||
### 38.4 Revocation Records Locally
|
||||
|
||||
**Test:** Revoke the certificate and verify local recording.
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/revoke" \
|
||||
-d '{"reason": "superseded"}' | jq '.revoked_at'
|
||||
```
|
||||
|
||||
**Expected:** Returns `revoked_at` timestamp.
|
||||
**PASS if** revocation is recorded locally in the audit trail but not propagated to Vault (Vault is authoritative for its own revocation).
|
||||
|
||||
---
|
||||
|
||||
## Part 39: DigiCert Connector (M37)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- DigiCert CertCentral account with API access
|
||||
- API key and organization ID from DigiCert
|
||||
- Environment variables configured:
|
||||
```bash
|
||||
export CERTCTL_DIGICERT_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
export CERTCTL_DIGICERT_ORG_ID="123456"
|
||||
export CERTCTL_DIGICERT_PRODUCT_TYPE="ssl_basic"
|
||||
export CERTCTL_DIGICERT_BASE_URL="https://www.digicert.com/services/v2"
|
||||
```
|
||||
|
||||
### 39.1 Register DigiCert Issuer
|
||||
|
||||
**Test:** Register a DigiCert CertCentral issuer via the API.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/issuers" \
|
||||
-d '{
|
||||
"id": "iss-digicert-prod",
|
||||
"name": "DigiCert CertCentral",
|
||||
"type": "DigiCert",
|
||||
"config": {
|
||||
"api_key": "'"$CERTCTL_DIGICERT_API_KEY"'",
|
||||
"org_id": "'"$CERTCTL_DIGICERT_ORG_ID"'",
|
||||
"product_type": "'"$CERTCTL_DIGICERT_PRODUCT_TYPE"'",
|
||||
"base_url": "'"$CERTCTL_DIGICERT_BASE_URL"'"
|
||||
}
|
||||
}' | jq '.id'
|
||||
```
|
||||
|
||||
**Expected:** Returns issuer ID `iss-digicert-prod`.
|
||||
**PASS if** issuer is registered and appears in `GET /api/v1/issuers`.
|
||||
|
||||
### 39.2 Issue DV Certificate via DigiCert
|
||||
|
||||
**Test:** Create a DV certificate order and track it to completion.
|
||||
|
||||
```bash
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "dv-test.example.com",
|
||||
"issuer_id": "iss-digicert-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
JOB_ID=$(curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id')
|
||||
|
||||
# Poll for job completion (DV certs may issue immediately)
|
||||
for i in {1..30}; do
|
||||
STATUS=$(curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/jobs/$JOB_ID" | jq -r '.status')
|
||||
echo "Job status: $STATUS"
|
||||
[ "$STATUS" = "Completed" ] && break
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
|
||||
**Expected:** Job eventually reaches Completed status with certificate issued.
|
||||
**PASS if** certificate has DigiCert serial number and chain.
|
||||
|
||||
### 39.3 Verify Order ID Tracking
|
||||
|
||||
**Test:** Check that the job record includes the DigiCert order ID for auditing.
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/jobs/$JOB_ID" | jq '.metadata'
|
||||
```
|
||||
|
||||
**Expected:** Metadata includes `order_id` from DigiCert for order tracking.
|
||||
**PASS if** audit trail shows the DigiCert order lifecycle.
|
||||
|
||||
### 39.4 Async Poll Behavior
|
||||
|
||||
**Test:** Verify the connector polls for certificate completion (OV certs take longer).
|
||||
|
||||
```bash
|
||||
# Submit OV certificate order (requires validation)
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "ov-test.example.com",
|
||||
"issuer_id": "iss-digicert-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
JOB_ID=$(curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id')
|
||||
|
||||
# Check job status transitions
|
||||
curl -s -H "$AUTH" "$SERVER/api/v1/jobs/$JOB_ID" | jq '.status'
|
||||
```
|
||||
|
||||
**Expected:** Job status transitions through pending states as DigiCert validates.
|
||||
**PASS if** polling mechanism works and job reaches completion once DigiCert issues the certificate.
|
||||
|
||||
### 39.5 Revocation Records Locally
|
||||
|
||||
**Test:** Revoke a DigiCert-issued certificate.
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/revoke" \
|
||||
-d '{"reason": "cessationOfOperation"}' | jq '.revoked_at'
|
||||
```
|
||||
|
||||
**Expected:** Returns `revoked_at` timestamp.
|
||||
**PASS if** revocation is recorded locally; operator manages revocation in DigiCert CertCentral dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Part 40: Issuer Catalog Page (M33)
|
||||
|
||||
Frontend-only milestone. No backend changes. All tests are automated via `qa-smoke-test.sh` and `vitest`.
|
||||
|
||||
### 40.1 Shared Issuer Type Config
|
||||
|
||||
**Test:** Verify shared config file exists with all 6 supported types + 2 coming soon stubs.
|
||||
|
||||
```bash
|
||||
test -f web/src/config/issuerTypes.ts
|
||||
grep -c 'VaultPKI' web/src/config/issuerTypes.ts # >= 1
|
||||
grep -c 'DigiCert' web/src/config/issuerTypes.ts # >= 1
|
||||
grep -cE 'eab_kid|eab_hmac' web/src/config/issuerTypes.ts # >= 1
|
||||
grep -c 'sensitive' web/src/config/issuerTypes.ts # >= 1
|
||||
```
|
||||
|
||||
**PASS if** file exists, all types present, EAB fields and sensitive flags included.
|
||||
|
||||
### 40.2 Composable Wizard Components
|
||||
|
||||
**Test:** Verify reusable components exist.
|
||||
|
||||
```bash
|
||||
test -f web/src/components/issuer/TypeSelector.tsx
|
||||
test -f web/src/components/issuer/ConfigForm.tsx
|
||||
test -f web/src/components/issuer/ConfigDetailModal.tsx
|
||||
```
|
||||
|
||||
**PASS if** all 3 component files exist.
|
||||
|
||||
### 40.3 Frontend Build
|
||||
|
||||
**Test:** Verify frontend builds with zero errors.
|
||||
|
||||
```bash
|
||||
cd web && npm run build 2>&1 | tail -1 | grep -q 'built in'
|
||||
```
|
||||
|
||||
**PASS if** build succeeds.
|
||||
|
||||
### 40.4 Frontend Tests
|
||||
|
||||
**Test:** Verify all Vitest tests pass including new VaultPKI/DigiCert create tests.
|
||||
|
||||
```bash
|
||||
cd web && npx vitest run 2>&1 | grep -qE 'Tests.*passed'
|
||||
```
|
||||
|
||||
**PASS if** all tests pass.
|
||||
|
||||
### 40.5 (Manual) Create VaultPKI Issuer via Wizard
|
||||
|
||||
**Test:** Open Issuers page, click "Configure" on Vault PKI card, fill in form (addr, token, mount, role, ttl), submit.
|
||||
**PASS if** issuer appears in configured issuers table.
|
||||
|
||||
### 40.6 (Manual) Create DigiCert Issuer via Wizard
|
||||
|
||||
**Test:** Open Issuers page, click "Configure" on DigiCert card, fill in form (api_key, org_id, product_type), submit.
|
||||
**PASS if** issuer appears in configured issuers table.
|
||||
|
||||
### 40.7 (Manual) Create ACME Issuer with EAB Fields
|
||||
|
||||
**Test:** Open create wizard, select ACME, verify EAB Key ID and EAB HMAC Key fields are visible.
|
||||
**PASS if** EAB fields render and accept input.
|
||||
|
||||
### 40.8 (Manual) Catalog Cards Show Correct Status
|
||||
|
||||
**Test:** Verify catalog cards show "Connected" (green, count) for types with configured issuers, "Available" (blue) for unconfigured types, and "Coming Soon" (grey) for Sectigo/Entrust.
|
||||
**PASS if** all 8 cards render with correct status.
|
||||
|
||||
### 40.9 (Manual) Config Detail Modal Shows Full Redacted Config
|
||||
|
||||
**Test:** Click "View Config" on a configured issuer row. Verify modal shows full config JSON with sensitive fields (token, key, hmac, password, private, secret) redacted as `********`.
|
||||
**PASS if** modal opens, full config visible, sensitive fields redacted.
|
||||
|
||||
### 40.10 (Manual) Issuer Type Filter Works
|
||||
|
||||
**Test:** Use the type filter dropdown above the configured issuers table. Select a specific type.
|
||||
**PASS if** table filters to show only issuers of the selected type.
|
||||
|
||||
---
|
||||
|
||||
## Part 41: Frontend Audit Fixes
|
||||
|
||||
Comprehensive frontend coverage audit closed 60 gaps between backend capabilities and GUI surfaces. This part validates the critical fixes.
|
||||
|
||||
### Automated Tests (qa-smoke-test.sh Part 41)
|
||||
|
||||
| # | Test | Assertion |
|
||||
|---|------|-----------|
|
||||
| 41.1 | Certificate TS type has lifecycle fields | `types.ts` contains `last_renewal_at`, `last_deployment_at`, `target_ids` |
|
||||
| 41.2 | API client has new endpoint functions | `client.ts` exports `updateIssuer`, `updateTarget`, `getCertificateDeployments`, `getCRL`, `getOCSPStatus`, `getPolicy` |
|
||||
| 41.3 | CertificatesPage has filter dropdowns | Contains `issuerFilter`, `ownerFilter`, `profileFilter` state vars |
|
||||
| 41.4 | CertificatesPage shows last_renewal_at | Column renders `last_renewal_at` field |
|
||||
| 41.5 | JobsPage shows error_message | Error column displays first 80 chars for failed jobs |
|
||||
| 41.6 | ProfilesPage has key algorithm fields | Create form includes `allowed_key_algorithms` with add/remove rows |
|
||||
| 41.7 | ProfilesPage has EKU checkboxes | Create form includes `allowed_ekus` checkbox group |
|
||||
| 41.8 | DiscoveryPage shows is_ca badge | CA badge renders for discovered CA certificates |
|
||||
| 41.9 | TargetDetailPage has Edit functionality | Edit button wired to `updateTarget` API call |
|
||||
| 41.10 | CertificatesPage has tags field | Create form includes tags input (key=value pairs) |
|
||||
| 41.11 | AgentFleetPage maps darwin to macOS | OS display mapping applied to pie chart and platform headers |
|
||||
| 41.12 | Frontend builds after audit fixes | `npm run build` succeeds |
|
||||
|
||||
### Manual Tests
|
||||
|
||||
**41.M1: Profile Create Form — Key Algorithm Configuration**
|
||||
|
||||
1. Navigate to Profiles page, click "+ New Profile"
|
||||
2. Verify default algorithms shown: ECDSA 256+, RSA 2048+
|
||||
3. Click "Remove" on RSA row — verify it disappears
|
||||
4. Click "+ Add" — verify Ed25519 appears (with "fixed" instead of size dropdown)
|
||||
5. Submit form, verify profile created with correct `allowed_key_algorithms` array
|
||||
|
||||
**PASS if** algorithms are configurable and persisted correctly.
|
||||
|
||||
**41.M2: Profile Create Form — EKU Selection**
|
||||
|
||||
1. In Create Profile modal, verify EKU checkboxes visible (serverAuth checked by default)
|
||||
2. Check "Email Protection (S/MIME)" and "Client Authentication"
|
||||
3. Submit, verify profile has `allowed_ekus: ["serverAuth", "emailProtection", "clientAuth"]`
|
||||
|
||||
**PASS if** EKUs are selectable and sent to backend.
|
||||
|
||||
**41.M3: Certificate Create Form — Tags**
|
||||
|
||||
1. Navigate to Certificates page, click "+ New Certificate"
|
||||
2. Enter tags: `env=prod, team=platform, app=api`
|
||||
3. Submit, verify certificate created with `tags: {"env": "prod", "team": "platform", "app": "api"}`
|
||||
|
||||
**PASS if** tags are parsed and persisted as key-value pairs.
|
||||
|
||||
**41.M4: Jobs Table — Error Message Column**
|
||||
|
||||
1. Navigate to Jobs page, filter to "Failed" status
|
||||
2. Verify "Error" column shows truncated error message (max 80 chars with "...")
|
||||
3. Hover over truncated message, verify full text in tooltip
|
||||
|
||||
**PASS if** error messages visible for failed jobs.
|
||||
|
||||
**41.M5: Certificates Table — Lifecycle Columns**
|
||||
|
||||
1. Navigate to Certificates page
|
||||
2. Verify "Last Renewal" and "Last Deploy" columns visible
|
||||
3. Verify dates shown for certs with data, "—" for certs without
|
||||
|
||||
**PASS if** lifecycle timestamps displayed.
|
||||
|
||||
**41.M6: Certificate Filters — Issuer/Owner/Profile Dropdowns**
|
||||
|
||||
1. Navigate to Certificates page
|
||||
2. Verify Issuer, Owner, Profile dropdown filters visible
|
||||
3. Select an issuer — verify table filters to matching certificates
|
||||
4. Clear filter, select a profile — verify filtering works
|
||||
|
||||
**PASS if** all three filter dropdowns functional.
|
||||
|
||||
**41.M7: Target Detail — Edit Button**
|
||||
|
||||
1. Navigate to a target detail page
|
||||
2. Click "Edit" button
|
||||
3. Modify name, click "Save"
|
||||
4. Verify name updated on the page
|
||||
|
||||
**PASS if** target edit persists via API.
|
||||
|
||||
**41.M8: Discovery Table — CA Badge**
|
||||
|
||||
1. Navigate to Discovery page
|
||||
2. Verify "Key" column shows algorithm + key size
|
||||
3. For CA certificates, verify purple "CA" badge displayed
|
||||
|
||||
**PASS if** CA certificates visually distinguished.
|
||||
|
||||
**41.M9: Fleet Overview — macOS Display**
|
||||
|
||||
1. Navigate to Fleet Overview page
|
||||
2. Verify OS pie chart shows "macOS" instead of "darwin"
|
||||
3. Verify platform section headers show "macOS / amd64" (not "darwin / amd64")
|
||||
|
||||
**PASS if** darwin correctly mapped to macOS in all locations.
|
||||
|
||||
---
|
||||
|
||||
## Part 42: IIS Target Connector (M39)
|
||||
|
||||
The IIS target connector (M39) brings Windows infrastructure lifecycle management to certctl. Dual-mode implementation: agent-local PowerShell (primary) for servers with certctl agent, proxy agent WinRM for agentless Windows targets. Full test suite (28 tests) with mock executor pattern for cross-platform testing. Supports PEM-to-PFX conversion, SHA-1 thumbprint computation, and parameterized PowerShell execution.
|
||||
|
||||
### Test Suite Coverage
|
||||
|
||||
| Layer | Test Count | Focus | Cross-Platform |
|
||||
|-------|-----------|-------|-----------------|
|
||||
| ValidateConfig | 9 | Field validation, defaults, regex enforcement | Yes |
|
||||
| DeployCertificate | 7 | PFX conversion, script execution, error handling | Yes |
|
||||
| ValidateDeployment | 5 | Thumbprint verification, binding checks | Mock executor |
|
||||
| PFX Conversion | 4 | Certificate chain handling, password generation | Yes |
|
||||
| Helpers | 3 | Thumbprint computation, Windows time conversion | Yes |
|
||||
| **Total** | **28** | | **26 pass, 2 skip on non-Windows** |
|
||||
|
||||
### Automated Tests (qa-smoke-test.sh Part 42)
|
||||
|
||||
| # | Test | Assertion |
|
||||
|---|------|-----------|
|
||||
| 42.1 | IIS connector imports without error | `internal/connector/target/iis/` builds cleanly |
|
||||
| 42.2 | ValidateConfig rejects missing hostname | Validation fails when `hostname` absent |
|
||||
| 42.3 | ValidateConfig rejects missing site_name | Validation fails when `site_name` absent |
|
||||
| 42.4 | ValidateConfig applies defaults | `port` defaults to 443, `ip_address` to "*" |
|
||||
| 42.5 | ValidateConfig validates field regex | Rejects field names with invalid characters |
|
||||
| 42.6 | PEM-to-PFX conversion succeeds | PKCS#12 bundle created with random password |
|
||||
| 42.7 | SHA-1 thumbprint computed correctly | Matches Go crypto/sha1 output, hex-encoded |
|
||||
| 42.8 | PowerShell script is parameterized | No unescaped interpolation in generated commands |
|
||||
| 42.9 | Mock executor pattern works cross-platform | Tests pass on Linux/macOS via mock executor |
|
||||
| 42.10 | DeployCertificate calls Import-PfxCertificate | PowerShell command includes correct cert store |
|
||||
| 42.11 | DeployCertificate calls Set-WebBinding | PowerShell command includes site name + thumbprint |
|
||||
| 42.12 | ValidateDeployment executes Get-IISSiteBinding | Thumbprint comparison happens post-deployment |
|
||||
| 42.13 | Error cases logged and propagated | TLS verify failure, script timeout errors handled |
|
||||
| 42.14 | Windows time conversion helpers work | FileTime ↔ time.Time round-trip accurate |
|
||||
|
||||
### Manual Tests (Windows Only)
|
||||
|
||||
These tests require a real Windows Server 2019+ environment with IIS 10+. Skip on non-Windows platforms.
|
||||
|
||||
**42.M1: Agent-Local Deployment — Happy Path**
|
||||
|
||||
1. Provision a Windows Server 2019+ VM with IIS installed
|
||||
2. Download and install certctl-agent binary for windows-amd64
|
||||
3. Register agent with certctl server via heartbeat endpoint
|
||||
4. Create IIS target in certctl dashboard:
|
||||
```json
|
||||
{
|
||||
"hostname": "iis-server.local",
|
||||
"site_name": "Default Web Site",
|
||||
"cert_store": "WebHosting",
|
||||
"port": 443,
|
||||
"sni": true,
|
||||
"ip_address": "*"
|
||||
}
|
||||
```
|
||||
5. Issue a certificate (e.g., via Local CA)
|
||||
6. Create deployment job targeting the IIS target
|
||||
7. Agent polls work endpoint, executes PowerShell
|
||||
8. Verify on IIS: `Get-IISSiteBinding` shows new binding with correct thumbprint
|
||||
9. Verify in dashboard: Deployment job shows status=Completed, verified_at timestamp present
|
||||
|
||||
**PASS if** certificate deployed to IIS binding with matching thumbprint, deployment job shows Completed with verification success.
|
||||
|
||||
**42.M2: Agent-Local Deployment — Renewal**
|
||||
|
||||
1. On the same IIS target, trigger renewal of the certificate
|
||||
2. Verify old certificate remains bound during renewal (until new one succeeds)
|
||||
3. Verify new certificate is imported and bound after deployment
|
||||
4. Verify old binding removed or updated in IIS
|
||||
|
||||
**PASS if** renewal completes without downtime, old binding replaced with new.
|
||||
|
||||
**42.M3: PFX Import to WebHosting Store**
|
||||
|
||||
1. Manually generate a test PKCS#12 certificate
|
||||
2. Via certctl-agent on Windows, verify PowerShell can import to WebHosting store:
|
||||
```powershell
|
||||
$pfx = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
|
||||
$pfx.Import([System.IO.File]::ReadAllBytes("C:\temp\test.pfx"), $password, "Exportable")
|
||||
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("WebHosting", "LocalMachine")
|
||||
$store.Open("MaxAllowed")
|
||||
$store.Add($pfx)
|
||||
```
|
||||
3. Verify certificate appears in IIS Certificate Manager
|
||||
|
||||
**PASS if** certificate imports to WebHosting store successfully.
|
||||
|
||||
**42.M4: Binding Verification — Thumbprint Match**
|
||||
|
||||
1. Deploy a certificate to an IIS site via certctl
|
||||
2. Manually run on IIS server:
|
||||
```powershell
|
||||
Get-IISSiteBinding -Name "Default Web Site" | Select-Object Thumbprint
|
||||
```
|
||||
3. Verify thumbprint matches certificate's SHA-1 hash (as shown in certctl GUI)
|
||||
|
||||
**PASS if** thumbprints match exactly (hex-encoded, no colons).
|
||||
|
||||
**42.M5: Error Handling — Invalid Site Name**
|
||||
|
||||
1. Create IIS target with non-existent site name (e.g., "NonExistentSite")
|
||||
2. Trigger deployment
|
||||
3. Verify job fails with error message about invalid site
|
||||
4. Verify error is logged in agent and audit trail
|
||||
|
||||
**PASS if** error handled gracefully, job marked Failed with reason.
|
||||
|
||||
**42.M6: Field Validation — Config Injection Attempt**
|
||||
|
||||
1. Try to create IIS target with site_name containing PowerShell metacharacters:
|
||||
```json
|
||||
{
|
||||
"site_name": "Default Web Site'; Get-Process; #"
|
||||
}
|
||||
```
|
||||
2. Verify regex validation rejects this (field validation error, not API error)
|
||||
3. Verify no PowerShell execution occurs
|
||||
|
||||
**PASS if** injection attempt blocked by field validation.
|
||||
|
||||
**42.M7: SNI vs Non-SNI Binding**
|
||||
|
||||
1. Create two IIS targets: one with `sni: true`, one with `sni: false`
|
||||
2. Deploy certificates to both
|
||||
3. Verify Set-WebBinding with `-SslFlags 1` (SNI) for first target
|
||||
4. Verify Set-WebBinding without SslFlags (no SNI) for second target
|
||||
5. Test TLS connection to both sites, verify SNI-enabled site handles multiple domains correctly
|
||||
|
||||
**PASS if** SNI bindings configured correctly per target config.
|
||||
|
||||
---
|
||||
|
||||
## Release Sign-Off
|
||||
|
||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||
@@ -5082,7 +5702,7 @@ These must be green before starting manual QA:
|
||||
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
||||
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
||||
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
|
||||
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 121 pass, 0 fail, 5 skip |
|
||||
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 124 pass, 0 fail, 5 skip |
|
||||
|
||||
### Part 1: Infrastructure & Deployment
|
||||
|
||||
@@ -5213,7 +5833,7 @@ These must be green before starting manual QA:
|
||||
| 7.1.3 | Create Apache target | Manual | ☐ | | |
|
||||
| 7.1.4 | Create HAProxy target | Manual | ☐ | | |
|
||||
| 7.1.5 | Create F5 BIG-IP target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.6 | Create IIS target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.6 | Create IIS target | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.7 | Get target verifies type-specific config stored | Manual | ☐ | | |
|
||||
| 7.1.8 | Update target config | Manual | ☐ | | |
|
||||
| 7.1.9 | Delete target returns 204 | Auto | ☑ | 2026-03-30 | |
|
||||
@@ -5574,14 +6194,274 @@ These must be green before starting manual QA:
|
||||
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
||||
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
||||
|
||||
### Part 35: ARI (RFC 9702) Scheduler Integration
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 35.a1 | ARI nil fallback — renewal jobs still created | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.a2 | No ARI errors with Local CA issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.a3 | Server healthy after ARI wiring (metrics) | Auto | ☑ | 2026-03-30 | |
|
||||
| 35.1 | ARI defers renewal when CA says "not yet" (requires ACME+ARI) | Manual | ☐ | | |
|
||||
| 35.2 | ARI triggers renewal when CA says "now" (requires ACME+ARI) | Manual | ☐ | | |
|
||||
| 35.3 | ARI fallback on error — threshold-based (requires ACME+ARI) | Manual | ☐ | | |
|
||||
|
||||
### Part 36: Agent Work Routing (M31)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 36.a1 | Agent receives only its deployment jobs | Auto | ☐ | | |
|
||||
| 36.a2 | Agent with no targets gets empty work list | Auto | ☐ | | |
|
||||
| 36.a3 | Deployment jobs have agent_id populated | Auto | ☐ | | |
|
||||
| 36.1 | Multi-agent routing with 2 agents, 2 targets | Manual | ☐ | | |
|
||||
| 36.2 | Agent with no assigned targets gets empty work | Manual | ☐ | | |
|
||||
| 36.3 | Database agent_id populated on deployment jobs | Manual | ☐ | | |
|
||||
|
||||
### Part 37: GUI Completeness (Pre-2.1.0-E)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 37.1 | DigestPage renders preview iframe | Manual | ☐ | | |
|
||||
| 37.2 | DigestPage send button with confirmation modal | Manual | ☐ | | |
|
||||
| 37.3 | ObservabilityPage shows metrics gauges | Manual | ☐ | | |
|
||||
| 37.4 | ObservabilityPage Prometheus config block | Manual | ☐ | | |
|
||||
| 37.5 | ObservabilityPage live Prometheus output | Manual | ☐ | | |
|
||||
| 37.6 | JobDetailPage displays job info and timeline | Manual | ☐ | | |
|
||||
| 37.7 | JobDetailPage verification section for deployment jobs | Manual | ☐ | | |
|
||||
| 37.8 | IssuerDetailPage shows redacted config | Manual | ☐ | | |
|
||||
| 37.9 | IssuerDetailPage test connection button | Manual | ☐ | | |
|
||||
| 37.10 | IssuerDetailPage issued certificates list | Manual | ☐ | | |
|
||||
| 37.11 | TargetDetailPage shows config and agent link | Manual | ☐ | | |
|
||||
| 37.12 | TargetDetailPage deployment history table | Manual | ☐ | | |
|
||||
| 37.13 | JobsPage — job IDs clickable to /jobs/:id | Manual | ☐ | | |
|
||||
| 37.14 | JobsPage — verification column for deployment jobs | Manual | ☐ | | |
|
||||
| 37.15 | IssuersPage — issuer names clickable to /issuers/:id | Manual | ☐ | | |
|
||||
| 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | |
|
||||
| 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | |
|
||||
|
||||
### Part 38: Vault PKI Connector (M32)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 38.s1 | Vault PKI issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.1 |
|
||||
| 38.s2 | Vault issuer type is VaultPKI | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.2 |
|
||||
| 38.s3 | Vault issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.3 |
|
||||
| 38.s4 | Vault connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.4 |
|
||||
| 38.s5 | Vault connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.5 |
|
||||
| 38.s6 | OpenAPI spec includes VaultPKI type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.6 |
|
||||
| 38.1 | Register Vault PKI issuer | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.2 | Issue certificate via Vault PKI | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.3 | Verify certificate serial and subject | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.4 | Revocation records locally | Manual | ☐ | | Requires live Vault server |
|
||||
|
||||
### Part 39: DigiCert Connector (M37)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 39.s1 | DigiCert issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.1 |
|
||||
| 39.s2 | DigiCert issuer type is DigiCert | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.2 |
|
||||
| 39.s3 | DigiCert issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.3 |
|
||||
| 39.s4 | DigiCert connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.4 |
|
||||
| 39.s5 | DigiCert connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.5 |
|
||||
| 39.s6 | OpenAPI spec includes DigiCert type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.6 |
|
||||
| 39.1 | Register DigiCert issuer | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.2 | Issue DV certificate via DigiCert | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.3 | Verify order ID tracking | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.5 | Revocation records locally | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
|
||||
### Part 40: Issuer Catalog Page (M33)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 40.s1 | Shared issuerTypes config exists | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.1 |
|
||||
| 40.s2 | VaultPKI in issuerTypes config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.2 |
|
||||
| 40.s3 | DigiCert in issuerTypes config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.3 |
|
||||
| 40.s4 | ACME EAB fields in config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.4 |
|
||||
| 40.s5 | Sensitive field flag in config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.5 |
|
||||
| 40.s6 | ConfigDetailModal component exists | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.6 |
|
||||
| 40.s7 | Frontend build succeeds | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.7 |
|
||||
| 40.s8 | Frontend tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.8 |
|
||||
| 40.m1 | Create VaultPKI issuer via wizard | Manual | ☐ | | |
|
||||
| 40.m2 | Create DigiCert issuer via wizard | Manual | ☐ | | |
|
||||
| 40.m3 | Create ACME issuer with EAB fields | Manual | ☐ | | |
|
||||
| 40.m4 | Catalog cards show correct status | Manual | ☐ | | |
|
||||
| 40.m5 | Config detail modal shows full redacted config | Manual | ☐ | | |
|
||||
| 40.m6 | Issuer type filter works | Manual | ☐ | | |
|
||||
|
||||
### Part 41: Frontend Audit Fixes
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 41.s1 | Certificate TS type has lifecycle fields | Auto | ☐ | | qa-smoke-test.sh 41.1 |
|
||||
| 41.s2 | API client has new endpoint functions | Auto | ☐ | | qa-smoke-test.sh 41.2 |
|
||||
| 41.s3 | CertificatesPage has filter dropdowns | Auto | ☐ | | qa-smoke-test.sh 41.3 |
|
||||
| 41.s4 | CertificatesPage shows last_renewal_at | Auto | ☐ | | qa-smoke-test.sh 41.4 |
|
||||
| 41.s5 | JobsPage shows error_message | Auto | ☐ | | qa-smoke-test.sh 41.5 |
|
||||
| 41.s6 | ProfilesPage has key algorithm fields | Auto | ☐ | | qa-smoke-test.sh 41.6 |
|
||||
| 41.s7 | ProfilesPage has EKU checkboxes | Auto | ☐ | | qa-smoke-test.sh 41.7 |
|
||||
| 41.s8 | DiscoveryPage shows is_ca badge | Auto | ☐ | | qa-smoke-test.sh 41.8 |
|
||||
| 41.s9 | TargetDetailPage has Edit functionality | Auto | ☐ | | qa-smoke-test.sh 41.9 |
|
||||
| 41.s10 | CertificatesPage has tags field | Auto | ☐ | | qa-smoke-test.sh 41.10 |
|
||||
| 41.s11 | AgentFleetPage maps darwin to macOS | Auto | ☐ | | qa-smoke-test.sh 41.11 |
|
||||
| 41.s12 | Frontend builds after audit fixes | Auto | ☐ | | qa-smoke-test.sh 41.12 |
|
||||
| 41.m1 | Profile create form — key algorithm config | Manual | ☐ | | |
|
||||
| 41.m2 | Profile create form — EKU selection | Manual | ☐ | | |
|
||||
| 41.m3 | Certificate create form — tags | Manual | ☐ | | |
|
||||
| 41.m4 | Jobs table — error message column | Manual | ☐ | | |
|
||||
| 41.m5 | Certificates table — lifecycle columns | Manual | ☐ | | |
|
||||
| 41.m6 | Certificate filters — issuer/owner/profile | Manual | ☐ | | |
|
||||
| 41.m7 | Target detail — edit button | Manual | ☐ | | |
|
||||
| 41.m8 | Discovery table — CA badge | Manual | ☐ | | |
|
||||
| 41.m9 | Fleet overview — macOS display | Manual | ☐ | | |
|
||||
|
||||
### Part 43: Sectigo SCM Connector (M43)
|
||||
|
||||
**Prerequisites:** Sectigo SCM account with API access, valid customerUri + login + password credentials, at least one cert type available in `/ssl/v1/types`.
|
||||
|
||||
#### Automated Tests
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 43.s1 | `IssuerTypeSectigo` constant exists in domain | Auto | ☐ | | `grep 'Sectigo' internal/domain/connector.go` |
|
||||
| 43.s2 | `SectigoConfig` struct exists in config | Auto | ☐ | | `grep 'SectigoConfig' internal/config/config.go` |
|
||||
| 43.s3 | `iss-sectigo` in seed_demo.sql | Auto | ☐ | | `grep 'iss-sectigo' migrations/seed_demo.sql` |
|
||||
| 43.s4 | Sectigo in OpenAPI IssuerType enum | Auto | ☐ | | `grep 'Sectigo' api/openapi.yaml` |
|
||||
| 43.s5 | Sectigo connector tests pass | Auto | ☐ | | `go test ./internal/connector/issuer/sectigo/... -v` |
|
||||
| 43.s6 | Sectigo in issuerTypes.ts | Auto | ☐ | | `grep 'Sectigo' web/src/config/issuerTypes.ts` |
|
||||
| 43.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
|
||||
| 43.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
|
||||
|
||||
#### Manual Tests
|
||||
|
||||
**43.M1: Validate Sectigo Credentials**
|
||||
|
||||
1. Configure env vars: `CERTCTL_SECTIGO_CUSTOMER_URI`, `CERTCTL_SECTIGO_LOGIN`, `CERTCTL_SECTIGO_PASSWORD`, `CERTCTL_SECTIGO_ORG_ID`
|
||||
2. Start certctl server — verify log line: `Sectigo SCM issuer registered`
|
||||
3. Call `GET /api/v1/issuers` — verify `iss-sectigo` appears in the list
|
||||
|
||||
**PASS if** `iss-sectigo` registered and visible in API.
|
||||
|
||||
**43.M2: Enroll DV Certificate**
|
||||
|
||||
1. Create a certificate with `issuer_id: iss-sectigo`
|
||||
2. Trigger issuance — verify enrollment submitted (job enters Pending or AwaitingCSR)
|
||||
3. If DV, check for immediate issuance or poll via GetOrderStatus
|
||||
4. Verify `sslId` tracked in job's order_id field
|
||||
|
||||
**PASS if** enrollment submits successfully, sslId returned, job state machine progresses.
|
||||
|
||||
**43.M3: Async Polling — OV Certificate**
|
||||
|
||||
1. Submit OV certificate enrollment (requires org validation)
|
||||
2. Verify job enters Pending state with sslId in order_id
|
||||
3. Wait for Sectigo to process (or mock status check)
|
||||
4. Verify GetOrderStatus returns "pending" → "completed" transition
|
||||
5. Verify PEM bundle downloaded and parsed (leaf + chain)
|
||||
|
||||
**PASS if** async flow works end-to-end with correct status transitions.
|
||||
|
||||
**43.M4: Collect Not Ready (400/-183 Handling)**
|
||||
|
||||
1. If possible, catch the window where status is "Issued" but cert not yet generated
|
||||
2. Verify collect endpoint returns 400 with code -183
|
||||
3. Verify GetOrderStatus treats this as "pending" (not error)
|
||||
4. Verify next poll succeeds when cert is generated
|
||||
|
||||
**PASS if** 400/-183 handled gracefully as pending, not as error.
|
||||
|
||||
**43.M5: Revocation**
|
||||
|
||||
1. Revoke an issued Sectigo certificate via `POST /api/v1/certificates/{id}/revoke`
|
||||
2. Verify Sectigo revoke endpoint called (`POST /ssl/v1/revoke/{sslId}`)
|
||||
3. Verify audit trail records revocation
|
||||
|
||||
**PASS if** revocation recorded in certctl and sent to Sectigo.
|
||||
|
||||
**43.M6: Auth Header Verification**
|
||||
|
||||
1. Inspect network requests to Sectigo API (via proxy or logs)
|
||||
2. Verify all 3 headers present: `customerUri`, `login`, `password`
|
||||
3. Verify no `X-DC-DEVKEY` header (DigiCert auth should not leak)
|
||||
|
||||
**PASS if** correct 3-header auth on all requests.
|
||||
|
||||
### Part 44: Google CAS Issuer Connector (M44)
|
||||
|
||||
**Prerequisites:** GCP project with Certificate Authority Service enabled, CA pool created, service account with `roles/privateca.certificateManager`, service account JSON key file.
|
||||
|
||||
#### Automated Tests
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 44.s1 | `IssuerTypeGoogleCAS` constant exists in domain | Auto | ☐ | | `grep 'GoogleCAS' internal/domain/connector.go` |
|
||||
| 44.s2 | `GoogleCASConfig` struct exists in config | Auto | ☐ | | `grep 'GoogleCASConfig' internal/config/config.go` |
|
||||
| 44.s3 | `iss-googlecas` in seed_demo.sql | Auto | ☐ | | `grep 'iss-googlecas' migrations/seed_demo.sql` |
|
||||
| 44.s4 | GoogleCAS in OpenAPI IssuerType enum | Auto | ☐ | | `grep 'GoogleCAS' api/openapi.yaml` |
|
||||
| 44.s5 | Google CAS connector tests pass | Auto | ☐ | | `go test ./internal/connector/issuer/googlecas/... -v` |
|
||||
| 44.s6 | GoogleCAS in issuerTypes.ts | Auto | ☐ | | `grep 'GoogleCAS' web/src/config/issuerTypes.ts` |
|
||||
| 44.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
|
||||
| 44.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
|
||||
|
||||
#### Manual Tests
|
||||
|
||||
**44.M1: Validate Google CAS Credentials**
|
||||
|
||||
1. Configure env vars: `CERTCTL_GOOGLE_CAS_PROJECT`, `CERTCTL_GOOGLE_CAS_LOCATION`, `CERTCTL_GOOGLE_CAS_CA_POOL`, `CERTCTL_GOOGLE_CAS_CREDENTIALS`
|
||||
2. Start certctl server — verify log line: `Google CAS issuer registered`
|
||||
3. Call `GET /api/v1/issuers` — verify `iss-googlecas` appears in the list
|
||||
|
||||
**PASS if** `iss-googlecas` registered and visible in API.
|
||||
|
||||
**44.M2: Issue Certificate via Google CAS**
|
||||
|
||||
1. Create a certificate with `issuer_id: iss-googlecas`
|
||||
2. Trigger issuance — verify synchronous issuance (no async polling needed)
|
||||
3. Verify PEM cert returned with correct CN and SANs
|
||||
4. Verify certificate resource name stored in order_id field
|
||||
|
||||
**PASS if** certificate issued synchronously, PEM valid, resource name tracked.
|
||||
|
||||
**44.M3: Renewal via Google CAS**
|
||||
|
||||
1. Trigger renewal on a Google CAS-issued certificate
|
||||
2. Verify new certificate issued (delegates to IssueCertificate)
|
||||
3. Verify new serial number, updated validity dates
|
||||
|
||||
**PASS if** renewal produces new cert with new serial.
|
||||
|
||||
**44.M4: Revocation via Google CAS**
|
||||
|
||||
1. Revoke a Google CAS-issued certificate via `POST /api/v1/certificates/{id}/revoke`
|
||||
2. Verify Google CAS revoke endpoint called (`POST {name}:revoke`)
|
||||
3. Verify revocation reason mapped correctly (RFC 5280 → Google CAS enum)
|
||||
4. Verify audit trail records revocation
|
||||
|
||||
**PASS if** revocation recorded in certctl and sent to Google CAS.
|
||||
|
||||
**44.M5: OAuth2 Token Caching**
|
||||
|
||||
1. Issue multiple certificates in quick succession
|
||||
2. Verify token is cached (not re-fetched for every request)
|
||||
3. Verify token refresh after expiry
|
||||
|
||||
**PASS if** token reuse observed, refresh works after expiry.
|
||||
|
||||
**44.M6: CA Certificate Retrieval**
|
||||
|
||||
1. Call EST cacerts endpoint with Google CAS as issuer
|
||||
2. Verify CA certificate chain returned from Google CAS fetchCaCerts API
|
||||
|
||||
**PASS if** CA cert PEM returned successfully.
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 121 |
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
||||
| ☐ Auto (not yet run) | 28 |
|
||||
| — Skipped (preconditions not met in demo) | 5 |
|
||||
| ☐ Manual (requires hands-on verification) | 194 |
|
||||
| **Total** | **320** |
|
||||
| ☐ Manual (requires hands-on verification) | 253 |
|
||||
| **Total** | **430** |
|
||||
|
||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||
|
||||
|
||||
+75
-40
@@ -1,82 +1,117 @@
|
||||
# Why certctl?
|
||||
|
||||
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi."
|
||||
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi." certctl fills that gap: a self-hosted platform that automates the entire certificate lifecycle, works with any CA, deploys to any server, and keeps private keys on your infrastructure. It's free, source-available, and you own everything.
|
||||
|
||||
If you run a personal blog, Certbot works fine. If your company spends $200K/year on Keyfactor, you're covered. But if you're an ops engineer managing 20-500 certificates across NGINX, Apache, HAProxy, and maybe a private CA — the tools available today either don't do enough or cost too much.
|
||||
## The Math That Forces the Decision
|
||||
|
||||
certctl fills that gap.
|
||||
The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: **200 days** as of March 2026, **100 days** by March 2027, and **47 days** by March 2029.
|
||||
|
||||
## The Problem
|
||||
At 47-day lifespans, a team managing 100 certificates is processing **7+ renewals per week**, every week, forever. At 200 certificates, it's two per day. Manual processes, calendar reminders, and certbot cron jobs don't scale to this — a single missed renewal becomes a production outage at 3 AM. Certificate lifecycle automation is no longer optional; the only question is what tool runs it.
|
||||
|
||||
The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) in April 2025, mandating a phased reduction in TLS certificate lifetimes: 200 days as of March 2026, 100 days by March 2027, and 47 days by March 2029. That means every organization needs automated certificate renewal — not eventually, but now.
|
||||
## The Landscape Today
|
||||
|
||||
The existing options for automation are:
|
||||
If you're evaluating your options, here's what you'll find:
|
||||
|
||||
- **ACME clients** (Certbot, Lego, CertWarden): Handle issuance and renewal for ACME-compatible CAs, but don't manage deployment to target servers, don't provide inventory visibility, don't support non-ACME CAs, and don't offer audit trails or policy enforcement.
|
||||
- **Kubernetes-native** (cert-manager): Works well inside Kubernetes, but if your infrastructure includes bare-metal servers, VMs, or network appliances alongside Kubernetes, you need a separate solution for everything cert-manager can't reach.
|
||||
- **Commercial SaaS** (CertKit, Sectigo CLM): Handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate — costs scale linearly with your infrastructure.
|
||||
- **Enterprise platforms** (Venafi, Keyfactor, AppViewX): Comprehensive but start at $75K/year and require dedicated teams to operate.
|
||||
**ACME clients** (certbot, lego, acme.sh) handle issuance and renewal for Let's Encrypt and similar CAs, but they don't deploy to target servers, don't track inventory, don't support private CAs, and give you no audit trail or policy enforcement. You end up writing glue scripts and hoping they don't break.
|
||||
|
||||
**Kubernetes-native tools** (cert-manager) work well inside the cluster, but most organizations run mixed infrastructure — NGINX on VMs, HAProxy at the edge, IIS on Windows, maybe an F5. You need a separate solution for everything outside Kubernetes.
|
||||
|
||||
**Commercial SaaS platforms** handle more of the lifecycle but are proprietary, cloud-dependent, and priced per certificate. At 100 certs and 20 agents, SaaS pricing runs $3,000-5,000/year and scales linearly. You're paying rent on your own infrastructure's security.
|
||||
|
||||
**Enterprise platforms** (Venafi, Keyfactor, AppViewX) are comprehensive but start at $75K/year and require dedicated teams to operate. If you have a 50-server environment, the licensing costs more than the servers.
|
||||
|
||||
## What certctl Does Differently
|
||||
|
||||
certctl is a self-hosted certificate lifecycle platform. It handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
||||
certctl handles issuance, renewal, deployment, revocation, discovery, and monitoring — with three design decisions that no other tool at any price point combines:
|
||||
|
||||
### 1. Private Keys Never Leave Your Infrastructure
|
||||
|
||||
certctl agents generate private keys locally using ECDSA P-256. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions.
|
||||
certctl agents generate ECDSA P-256 private keys locally. The agent creates a CSR and submits it to the control plane. The signed certificate comes back. The private key stays on the agent's filesystem with 0600 permissions — it never crosses the network.
|
||||
|
||||
This isn't a premium feature — it's the default behavior in the free tier. Most competitors either generate keys server-side (creating a single point of compromise) or gate key isolation behind paid tiers.
|
||||
This isn't a premium feature. It's the default behavior, free. Most alternatives either generate keys on the server (creating a single point of compromise) or gate key isolation behind paid tiers.
|
||||
|
||||
### 2. CA-Agnostic Issuer Architecture
|
||||
|
||||
certctl works with any certificate authority, not just ACME providers:
|
||||
certctl works with any certificate authority, not just ACME providers. Seven issuer connectors ship today, all free:
|
||||
|
||||
- **ACME** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01 and DNS-01 challenges, DNS-PERSIST-01 for zero-touch renewals, External Account Binding
|
||||
- **step-ca** (Smallstep) — native /sign API with JWK provisioner authentication
|
||||
- **Local CA** — self-signed or sub-CA mode (chain to your enterprise root CA, e.g. ADCS)
|
||||
- **OpenSSL / Custom CA** — delegate signing to any shell script with configurable timeout
|
||||
- **EST enrollment** (RFC 7030) — device certificate enrollment for WiFi/802.1X, MDM, and IoT
|
||||
- **ACME v2** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01, DNS-01, DNS-PERSIST-01 challenges, External Account Binding, ACME Renewal Information (RFC 9702)
|
||||
- **HashiCorp Vault PKI** — `/v1/{mount}/sign/{role}` API, token auth
|
||||
- **DigiCert CertCentral** — async order model, OV/EV support
|
||||
- **step-ca** (Smallstep) — native /sign API with JWK provisioner auth
|
||||
- **Local CA** — self-signed or sub-CA mode (chain to ADCS or any enterprise root)
|
||||
- **OpenSSL / Custom CA** — delegate signing to any shell script
|
||||
- **EST enrollment** (RFC 7030) — device certs for WiFi/802.1X, MDM, IoT
|
||||
|
||||
Every issuer connector implements the same interface. Switching CAs or running multiple CAs in parallel requires zero code changes — just configuration.
|
||||
Every connector implements the same interface. Running multiple CAs in parallel — Let's Encrypt for public certs, Vault for internal services, your enterprise CA for legacy systems — is configuration, not code.
|
||||
|
||||
### 3. Post-Deployment Verification
|
||||
|
||||
Every other tool in this space stops at "the deployment command succeeded." certctl goes further: after deploying a certificate to a target, the agent connects back to the target's TLS endpoint and verifies the served certificate matches what was deployed, using SHA-256 fingerprint comparison.
|
||||
Every other tool in this space stops at "the deployment command succeeded." certctl goes further: after deploying a certificate, the agent connects back to the live TLS endpoint and compares the SHA-256 fingerprint of the served certificate against what was deployed.
|
||||
|
||||
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl catches this.
|
||||
A reload command can exit 0 while the certificate doesn't take effect — wrong virtual host, stale cache, config that validates but doesn't apply. certctl catches this automatically.
|
||||
|
||||
## What Else Ships Free
|
||||
|
||||
The three differentiators above get the headlines, but the feature surface is wider than most paid platforms:
|
||||
|
||||
**10 deployment targets** — NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell + remote WinRM), Postfix, and Dovecot. All use a pluggable connector model. The control plane never initiates outbound connections — agents poll for work, meaning certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
**Network certificate discovery** — active TLS scanning of CIDR ranges finds certificates you didn't know existed. Agents also scan local filesystems for PEM/DER files. Everything feeds into a triage workflow where you claim, dismiss, or import discovered certs into management.
|
||||
|
||||
**Immutable audit trail** — every API call recorded (method, path, actor, body hash, status, latency). Every certificate lifecycle event tracked. Append-only, no update or delete. Mapped to SOC 2, PCI-DSS 4.0, and NIST SP 800-57 compliance frameworks with published evidence guides.
|
||||
|
||||
**Policy engine** — 5 rule types (allowed issuers, allowed domains, required metadata, allowed environments, renewal lead time) with violation tracking and severity levels.
|
||||
|
||||
**PKI compliance** — DER-encoded X.509 CRL signed by issuing CA, embedded OCSP responder, RFC 5280 revocation with all reason codes, short-lived certificate exemption.
|
||||
|
||||
**Prometheus metrics** — `/api/v1/metrics/prometheus` in standard exposition format. Works with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics.
|
||||
|
||||
**MCP server** — 80 tools exposing the entire API surface for AI-assisted certificate management via Claude, Cursor, or any MCP-compatible client. No other certificate platform offers this.
|
||||
|
||||
**Full REST API** — 97 OpenAPI 3.1-documented operations. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
|
||||
|
||||
**1,554 tests** — Go backend with race detection, static analysis (golangci-lint), and vulnerability scanning (govulncheck) on every commit. Frontend test suite. CI runs on every push.
|
||||
|
||||
## How certctl Compares
|
||||
|
||||
### vs. CertKit
|
||||
### vs. ACME Clients
|
||||
|
||||
Closest competitor architecturally — agent-based, private key isolation (Keystore), multi-platform. certctl leads on issuer coverage (ACME + step-ca + Local CA + OpenSSL + EST vs. ACME-only), PKI compliance (CRL, OCSP, RFC 5280 revocation, immutable audit trail — all missing from CertKit today), policy engine (5 rule types vs. none), and network discovery (CIDR TLS scanning vs. none). certctl is source-available (BSL 1.1 → Apache 2.0) with no cert limit; CertKit is proprietary SaaS with a 3-cert free tier. Where CertKit leads: more deployment targets today (adds LiteSpeed, IIS, auto-detection), Windows support, Kubernetes, and polished SaaS onboarding.
|
||||
ACME clients solve one slice of the problem — issuance and renewal from ACME CAs. certctl replaces the ACME client, adds 6 more CA integrations, deploys the cert to the right server, verifies it's live, tracks it in an inventory, alerts on expiry, logs everything to an audit trail, and enforces policy. If you're currently running certbot behind a cron job and a prayer, certctl replaces all of it.
|
||||
|
||||
### vs. KeyTalk
|
||||
### vs. Agent-Based SaaS
|
||||
|
||||
Commercial (proprietary) PKI platform from a Dutch company — on-prem appliance, cloud, or managed service. Broader cert type coverage (TLS, S/MIME, device auth, VPN) and DigiCert + SCEP integrations. No public documentation on policy engine, API surface, or audit capabilities. No free tier, no public pricing. certctl trades breadth of cert types for full transparency — source-available, public API spec, free community edition with no limits.
|
||||
The closest architectural competitors use the same agent model — local key generation, CSR submission, push-based deployment. Where certctl differs: it supports 7 issuer types (not just ACME), provides CRL/OCSP/revocation infrastructure (not just issuance), includes a policy engine and network discovery, and is source-available with no certificate limit. SaaS alternatives are typically proprietary, priced per certificate ($2+/cert/month), and cap their free tiers at 3-5 certificates. certctl is free for any number of certificates, forever.
|
||||
|
||||
### vs. Enterprise Platforms (Venafi, Keyfactor)
|
||||
### vs. Commercial PKI Platforms
|
||||
|
||||
Comprehensive solutions with decades of features — at $75K-$250K+/yr. certctl targets organizations that need 80% of those capabilities at 1% of the cost. The trade-off: no SSO/RBAC yet (coming in certctl Pro), no F5/IIS target connectors yet, no SLA-backed support.
|
||||
On-prem or hosted commercial platforms offer broader cert type coverage (VPN certs, device auth, SCEP) and deeper CA integrations. The trade-off: no free tier, opaque pricing (often €13K+/year for 1,500 certs), proprietary codebases, and no public API documentation. certctl trades breadth of exotic cert types for full transparency — source-available code, 97-operation OpenAPI spec, and a free community edition with no artificial limits.
|
||||
|
||||
## Getting Started
|
||||
### vs. Enterprise Platforms
|
||||
|
||||
Venafi and Keyfactor offer decades of features at $75K-$250K+/year. certctl targets organizations that need 80% of those capabilities at a fraction of the cost. What certctl doesn't have yet: SSO/RBAC (coming in certctl Pro), vendor SLA-backed support. What certctl does have that enterprise platforms don't: an MCP server for AI-assisted management, ACME ARI (RFC 9702) for CA-directed renewal timing, and a deployment model that works in 5 minutes instead of 5 months.
|
||||
|
||||
## Who Should Look Elsewhere
|
||||
|
||||
certctl isn't the right tool for everyone:
|
||||
|
||||
- **Single-domain sites** — if you have one certificate on one server, certbot is fine. certctl is designed for managing tens to hundreds of certificates across multiple servers and CAs.
|
||||
- **Pure Kubernetes environments** — if every workload runs in-cluster and you're happy with cert-manager, there's no reason to add another tool. certctl shines when your infrastructure extends beyond Kubernetes.
|
||||
- **Organizations that need a vendor SLA today** — certctl is source-available software maintained by a small team. If you need contractual uptime guarantees and a support hotline, an enterprise platform is the right choice (for now).
|
||||
|
||||
## See It Running
|
||||
|
||||
The demo seeds 32 certificates across 7 issuers, 8 agents, 6 deployment targets, and 180 days of realistic history — jobs, audit events, discovery scans, approval workflows — so you can explore every feature immediately.
|
||||
|
||||
```bash
|
||||
# Clone and start with Docker Compose (includes demo data)
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl/deploy
|
||||
docker compose up -d
|
||||
|
||||
# Open the dashboard
|
||||
open http://localhost:8443
|
||||
cd certctl/deploy && docker compose up -d
|
||||
# Dashboard at http://localhost:8443
|
||||
```
|
||||
|
||||
The demo seeds 35 certificates across 5 issuers, 8 agents, 8 deployment targets, 90 days of job history, discovery scan data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
||||
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough, or explore the [5 turnkey examples](../examples/) for specific scenarios (ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer).
|
||||
|
||||
## License
|
||||
|
||||
certctl is licensed under the [Business Source License 1.1](../LICENSE). The licensed work is free to use for any purpose other than offering a competing managed service. The license converts to Apache 2.0 on March 1, 2033.
|
||||
certctl is source-available under the [Business Source License 1.1](../LICENSE). Free for any use except offering a competing managed service. Converts to Apache 2.0 on March 1, 2033.
|
||||
|
||||
The source is available, auditable, and self-hostable. You own your data, your keys, and your deployment.
|
||||
You own your data, your keys, and your deployment.
|
||||
|
||||
@@ -13,16 +13,18 @@ This example demonstrates certctl's core use case: **automatically manage TLS ce
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Your Domain (example.com)
|
||||
↓ [HTTP-01 validation, port 80]
|
||||
Let's Encrypt ACME
|
||||
↓ [CSR submission]
|
||||
certctl Server (control plane)
|
||||
↓ [API polling]
|
||||
certctl Agent (on NGINX server)
|
||||
↓ [deploy cert+key]
|
||||
NGINX Reverse Proxy
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Your Domain (example.com)"]
|
||||
B["Let's Encrypt ACME"]
|
||||
C["certctl Server (control plane)"]
|
||||
D["certctl Agent (on NGINX server)"]
|
||||
E["NGINX Reverse Proxy"]
|
||||
|
||||
A -->|HTTP-01 validation<br/>port 80| B
|
||||
B -->|CSR submission| C
|
||||
C -->|API polling| D
|
||||
D -->|deploy cert+key| E
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
container_name: certctl-server-acme-nginx
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
container_name: certctl-server-dns01
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -113,7 +113,7 @@ services:
|
||||
- certctl-network
|
||||
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
container_name: certctl-server-multi-issuer
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -13,27 +13,29 @@ With certctl, both issuer types are configured and available. You assign each ce
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ certctl Server (Control Plane) │
|
||||
│ - Let's Encrypt ACME issuer (HTTP-01 challenges) │
|
||||
│ - Local CA issuer (self-signed or sub-CA mode) │
|
||||
│ - PostgreSQL database (cert inventory, audit, jobs) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ API polling
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ certctl Agent │
|
||||
│ - Discovers existing certs in /etc/nginx/ssl and /etc/app/ssl │
|
||||
│ - Polls server for renewal/issuance/deployment jobs │
|
||||
│ - Generates keys locally (agent-side crypto) │
|
||||
│ - Deploys certs to NGINX and app service directories │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
NGINX (public TLS) App Services (internal TLS)
|
||||
(Let's Encrypt certs) (Local CA certs)
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Server ["certctl Server (Control Plane)"]
|
||||
A["Let's Encrypt ACME issuer<br/>(HTTP-01 challenges)"]
|
||||
B["Local CA issuer<br/>(self-signed or sub-CA mode)"]
|
||||
C["PostgreSQL database<br/>(cert inventory, audit, jobs)"]
|
||||
end
|
||||
|
||||
subgraph Agent ["certctl Agent"]
|
||||
D["Discovers existing certs<br/>(/etc/nginx/ssl, /etc/app/ssl)"]
|
||||
E["Polls server for<br/>renewal/issuance/deployment jobs"]
|
||||
F["Generates keys locally<br/>(agent-side crypto)"]
|
||||
G["Deploys certs to NGINX<br/>and app service directories"]
|
||||
end
|
||||
|
||||
subgraph Targets ["Target Services"]
|
||||
H["NGINX (public TLS)<br/>(Let's Encrypt certs)"]
|
||||
I["App Services (internal TLS)<br/>(Local CA certs)"]
|
||||
end
|
||||
|
||||
Server -->|API polling| Agent
|
||||
Agent -->|Deploy| H
|
||||
Agent -->|Deploy| I
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
@@ -212,7 +214,7 @@ Each agent independently manages its local cert inventory and deployments. The s
|
||||
- For ACME, ensure ports 80/443 are open and your domain resolves
|
||||
|
||||
### Agent can't reach server
|
||||
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health`
|
||||
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/health`
|
||||
- Verify `CERTCTL_SERVER_URL` environment variable
|
||||
|
||||
### No issuers showing up
|
||||
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
container_name: certctl-server-private-ca
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -77,7 +77,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -17,29 +17,16 @@ This example demonstrates certctl managing certificates for **internal services
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ certctl-server │ (Local CA issuer)
|
||||
│ (control │
|
||||
│ plane) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
│ REST API (job polling)
|
||||
│
|
||||
┌────────▼──────────┐
|
||||
│ certctl-agent │ (certificate deployer)
|
||||
└────────┬──────────┘
|
||||
│
|
||||
│ Write cert/key files
|
||||
│
|
||||
┌────────▼──────────────────────┐
|
||||
│ Traefik │
|
||||
│ (watches cert directory) │
|
||||
└────────────────────────────────┘
|
||||
│
|
||||
│ TLS handshakes
|
||||
│
|
||||
[Internal Services]
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["certctl-server<br/>(control plane)<br/>(Local CA issuer)"]
|
||||
B["certctl-agent<br/>(certificate deployer)"]
|
||||
C["Traefik<br/>(watches cert directory)"]
|
||||
D["[Internal Services]"]
|
||||
|
||||
A -->|REST API<br/>job polling| B
|
||||
B -->|Write cert/key files| C
|
||||
C -->|TLS handshakes| D
|
||||
```
|
||||
|
||||
## Quick Start (Self-Signed CA)
|
||||
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
container_name: certctl-server-stepca-haproxy
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server settings
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
@@ -119,7 +119,7 @@ services:
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -315,7 +315,7 @@ Common issues:
|
||||
Verify network:
|
||||
|
||||
```bash
|
||||
docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health
|
||||
docker compose exec certctl-agent curl http://certctl-server:8443/health
|
||||
```
|
||||
|
||||
### HAProxy config validation fails
|
||||
|
||||
@@ -9,12 +9,19 @@ require (
|
||||
github.com/testcontainers/testcontainers-go v0.35.0
|
||||
)
|
||||
|
||||
require golang.org/x/crypto v0.31.0
|
||||
require (
|
||||
golang.org/x/crypto v0.31.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
github.com/containerd/containerd v1.7.18 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
@@ -29,12 +36,23 @@ require (
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.5.0 // indirect
|
||||
@@ -52,6 +70,7 @@ require (
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
@@ -60,8 +79,9 @@ require (
|
||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,8 +4,16 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns=
|
||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE=
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b/go.mod h1:Ram6ngyPDmP+0t6+4T2rymv0w0BS9N8Ch5vvUJccw5o=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
|
||||
@@ -39,6 +47,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
@@ -52,8 +62,27 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc
|
||||
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/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
@@ -68,6 +97,10 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 h1:2ZKn+w/BJeL43sCxI2jhPLRv73oVVOjEKZjKkflyqxg=
|
||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc=
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 h1:AKIJL2PfBX2uie0Mn5pxtG1+zut3hAVMZbRfoXecFzI=
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321/go.mod h1:JajVhkiG2bYSNYYPYuWG7WZHr42CTjMTcCjfInRNCqc=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
@@ -111,14 +144,18 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
||||
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
|
||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
|
||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde/go.mod h1:MvrEmduDUz4ST5pGZ7CABCnOU5f3ZiOAZzT6b1A6nX8=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -127,6 +164,7 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
@@ -148,14 +186,22 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
@@ -163,22 +209,33 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||
@@ -187,6 +244,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -205,6 +263,7 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
-- =============================================================================
|
||||
-- Comprehensive Referential Integrity Check for seed_demo.sql
|
||||
-- Run AFTER migrations and seed data are loaded
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. Verify certificate_versions.certificate_id references valid managed_certificates.id
|
||||
SELECT 'FK VIOLATION: certificate_versions.certificate_id' AS issue, cv.id, cv.certificate_id
|
||||
FROM certificate_versions cv
|
||||
WHERE cv.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY cv.id;
|
||||
|
||||
-- 2. Verify certificate_target_mappings references valid IDs
|
||||
SELECT 'FK VIOLATION: certificate_target_mappings.certificate_id' AS issue, ctm.certificate_id
|
||||
FROM certificate_target_mappings ctm
|
||||
WHERE ctm.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY ctm.certificate_id;
|
||||
|
||||
SELECT 'FK VIOLATION: certificate_target_mappings.target_id' AS issue, ctm.target_id
|
||||
FROM certificate_target_mappings ctm
|
||||
WHERE ctm.target_id NOT IN (SELECT id FROM deployment_targets)
|
||||
ORDER BY ctm.target_id;
|
||||
|
||||
-- 3. Verify jobs references valid IDs
|
||||
SELECT 'FK VIOLATION: jobs.certificate_id' AS issue, j.id, j.certificate_id
|
||||
FROM jobs j
|
||||
WHERE j.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY j.id;
|
||||
|
||||
SELECT 'FK VIOLATION: jobs.target_id' AS issue, j.id, j.target_id
|
||||
FROM jobs j
|
||||
WHERE j.target_id IS NOT NULL AND j.target_id NOT IN (SELECT id FROM deployment_targets)
|
||||
ORDER BY j.id;
|
||||
|
||||
SELECT 'FK VIOLATION: jobs.agent_id' AS issue, j.id, j.agent_id
|
||||
FROM jobs j
|
||||
WHERE j.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY j.id;
|
||||
|
||||
-- 4. Verify discovered_certificates references valid IDs
|
||||
SELECT 'FK VIOLATION: discovered_certificates.agent_id' AS issue, dc.id, dc.agent_id
|
||||
FROM discovered_certificates dc
|
||||
WHERE dc.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY dc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id' AS issue, dc.id, dc.discovery_scan_id
|
||||
FROM discovered_certificates dc
|
||||
WHERE dc.discovery_scan_id IS NOT NULL AND dc.discovery_scan_id NOT IN (SELECT id FROM discovery_scans)
|
||||
ORDER BY dc.id;
|
||||
|
||||
-- 5. Verify notification_events references valid certificate_id
|
||||
SELECT 'FK VIOLATION: notification_events.certificate_id' AS issue, ne.id, ne.certificate_id
|
||||
FROM notification_events ne
|
||||
WHERE ne.certificate_id IS NOT NULL AND ne.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY ne.id;
|
||||
|
||||
-- 6. Verify policy_violations references valid certificate_id
|
||||
SELECT 'FK VIOLATION: policy_violations.certificate_id' AS issue, pv.id, pv.certificate_id
|
||||
FROM policy_violations pv
|
||||
WHERE pv.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY pv.id;
|
||||
|
||||
-- 7. Verify certificate_revocations references valid IDs
|
||||
SELECT 'FK VIOLATION: certificate_revocations.certificate_id' AS issue, cr.id, cr.certificate_id
|
||||
FROM certificate_revocations cr
|
||||
WHERE cr.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||
ORDER BY cr.id;
|
||||
|
||||
SELECT 'FK VIOLATION: certificate_revocations.issuer_id' AS issue, cr.id, cr.issuer_id
|
||||
FROM certificate_revocations cr
|
||||
WHERE cr.issuer_id NOT IN (SELECT id FROM issuers)
|
||||
ORDER BY cr.id;
|
||||
|
||||
-- 8. Verify agent_group_members references valid IDs
|
||||
SELECT 'FK VIOLATION: agent_group_members.agent_group_id' AS issue, agm.agent_group_id
|
||||
FROM agent_group_members agm
|
||||
WHERE agm.agent_group_id NOT IN (SELECT id FROM agent_groups)
|
||||
ORDER BY agm.agent_group_id;
|
||||
|
||||
SELECT 'FK VIOLATION: agent_group_members.agent_id' AS issue, agm.agent_id
|
||||
FROM agent_group_members agm
|
||||
WHERE agm.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY agm.agent_id;
|
||||
|
||||
-- 9. Verify owners.team_id references valid teams.id
|
||||
SELECT 'FK VIOLATION: owners.team_id' AS issue, o.id, o.team_id
|
||||
FROM owners o
|
||||
WHERE o.team_id IS NOT NULL AND o.team_id NOT IN (SELECT id FROM teams)
|
||||
ORDER BY o.id;
|
||||
|
||||
-- 10. Verify deployment_targets.agent_id references valid agents.id
|
||||
SELECT 'FK VIOLATION: deployment_targets.agent_id' AS issue, dt.id, dt.agent_id
|
||||
FROM deployment_targets dt
|
||||
WHERE dt.agent_id NOT IN (SELECT id FROM agents)
|
||||
ORDER BY dt.id;
|
||||
|
||||
-- 11. Verify managed_certificates FK columns
|
||||
SELECT 'FK VIOLATION: managed_certificates.owner_id' AS issue, mc.id, mc.owner_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.owner_id IS NOT NULL AND mc.owner_id NOT IN (SELECT id FROM owners)
|
||||
ORDER BY mc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: managed_certificates.team_id' AS issue, mc.id, mc.team_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.team_id IS NOT NULL AND mc.team_id NOT IN (SELECT id FROM teams)
|
||||
ORDER BY mc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: managed_certificates.issuer_id' AS issue, mc.id, mc.issuer_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.issuer_id NOT IN (SELECT id FROM issuers)
|
||||
ORDER BY mc.id;
|
||||
|
||||
SELECT 'FK VIOLATION: managed_certificates.renewal_policy_id' AS issue, mc.id, mc.renewal_policy_id
|
||||
FROM managed_certificates mc
|
||||
WHERE mc.renewal_policy_id IS NOT NULL AND mc.renewal_policy_id NOT IN (SELECT id FROM renewal_policies)
|
||||
ORDER BY mc.id;
|
||||
|
||||
-- 12. Check for duplicate primary keys
|
||||
SELECT 'DUPLICATE PK: teams' AS issue, id, COUNT(*) as count
|
||||
FROM teams GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: owners' AS issue, id, COUNT(*) as count
|
||||
FROM owners GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: agents' AS issue, id, COUNT(*) as count
|
||||
FROM agents GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: deployment_targets' AS issue, id, COUNT(*) as count
|
||||
FROM deployment_targets GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: managed_certificates' AS issue, id, COUNT(*) as count
|
||||
FROM managed_certificates GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: certificate_versions' AS issue, id, COUNT(*) as count
|
||||
FROM certificate_versions GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: issuers' AS issue, id, COUNT(*) as count
|
||||
FROM issuers GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: renewal_policies' AS issue, id, COUNT(*) as count
|
||||
FROM renewal_policies GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: jobs' AS issue, id, COUNT(*) as count
|
||||
FROM jobs GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: certificate_profiles' AS issue, id, COUNT(*) as count
|
||||
FROM certificate_profiles GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
SELECT 'DUPLICATE PK: certificate_revocations' AS issue, id, COUNT(*) as count
|
||||
FROM certificate_revocations GROUP BY id HAVING COUNT(*) > 1;
|
||||
|
||||
-- 13. Check fingerprint_sha256 uniqueness in certificate_versions
|
||||
SELECT 'DUPLICATE FINGERPRINT: certificate_versions' AS issue, fingerprint_sha256, COUNT(*) as count
|
||||
FROM certificate_versions
|
||||
WHERE fingerprint_sha256 IS NOT NULL
|
||||
GROUP BY fingerprint_sha256
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 14. Check serial number uniqueness in certificate_versions
|
||||
SELECT 'DUPLICATE SERIAL: certificate_versions' AS issue, serial_number, COUNT(*) as count
|
||||
FROM certificate_versions
|
||||
WHERE serial_number IS NOT NULL
|
||||
GROUP BY serial_number
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 15. Verify discovery_scan_id references are valid
|
||||
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id references' AS issue,
|
||||
dc.id, dc.discovery_scan_id, ds.id
|
||||
FROM discovered_certificates dc
|
||||
LEFT JOIN discovery_scans ds ON dc.discovery_scan_id = ds.id
|
||||
WHERE dc.discovery_scan_id IS NOT NULL AND ds.id IS NULL;
|
||||
|
||||
-- Summary: Count total records
|
||||
SELECT 'SUMMARY: teams' AS table_name, COUNT(*) as count FROM teams UNION ALL
|
||||
SELECT 'SUMMARY: owners', COUNT(*) FROM owners UNION ALL
|
||||
SELECT 'SUMMARY: agents', COUNT(*) FROM agents UNION ALL
|
||||
SELECT 'SUMMARY: deployment_targets', COUNT(*) FROM deployment_targets UNION ALL
|
||||
SELECT 'SUMMARY: managed_certificates', COUNT(*) FROM managed_certificates UNION ALL
|
||||
SELECT 'SUMMARY: certificate_versions', COUNT(*) FROM certificate_versions UNION ALL
|
||||
SELECT 'SUMMARY: certificate_target_mappings', COUNT(*) FROM certificate_target_mappings UNION ALL
|
||||
SELECT 'SUMMARY: issuers', COUNT(*) FROM issuers UNION ALL
|
||||
SELECT 'SUMMARY: renewal_policies', COUNT(*) FROM renewal_policies UNION ALL
|
||||
SELECT 'SUMMARY: jobs', COUNT(*) FROM jobs UNION ALL
|
||||
SELECT 'SUMMARY: certificate_profiles', COUNT(*) FROM certificate_profiles UNION ALL
|
||||
SELECT 'SUMMARY: certificate_revocations', COUNT(*) FROM certificate_revocations UNION ALL
|
||||
SELECT 'SUMMARY: audit_events', COUNT(*) FROM audit_events UNION ALL
|
||||
SELECT 'SUMMARY: discovery_scans', COUNT(*) FROM discovery_scans UNION ALL
|
||||
SELECT 'SUMMARY: discovered_certificates', COUNT(*) FROM discovered_certificates;
|
||||
@@ -252,6 +252,7 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("CSR submission failed", "agent_id", agentID, "certificate_id", req.CertificateID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to submit CSR", requestID)
|
||||
return
|
||||
}
|
||||
@@ -274,9 +275,10 @@ func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Requ
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract agent ID and certificate ID from path /api/v1/agents/{id}/certificates/{cert_id}
|
||||
// After TrimPrefix, path is "{id}/certificates/{cert_id}" → split gives [id, "certificates", cert_id]
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 4 || parts[0] == "" || parts[2] == "" {
|
||||
if len(parts) < 3 || parts[0] == "" || parts[2] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID and Certificate ID are required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -243,6 +243,7 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
created, err := h.svc.CreateCertificate(cert)
|
||||
if err != nil {
|
||||
slog.Error("failed to create certificate", "error", err, "request_id", requestID, "common_name", cert.CommonName, "name", cert.Name)
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -13,6 +14,7 @@ type ValidationError struct {
|
||||
}
|
||||
|
||||
// ValidateCommonName validates a certificate common name.
|
||||
// Accepts hostnames (TLS), IP addresses, and email addresses (S/MIME).
|
||||
func ValidateCommonName(cn string) error {
|
||||
if cn == "" {
|
||||
return ValidationError{Field: "common_name", Message: "common_name is required"}
|
||||
@@ -20,6 +22,13 @@ func ValidateCommonName(cn string) error {
|
||||
if len(cn) > 253 {
|
||||
return ValidationError{Field: "common_name", Message: "common_name must be 253 characters or fewer"}
|
||||
}
|
||||
// If CN contains @, validate as email address (S/MIME certificates)
|
||||
if strings.Contains(cn, "@") {
|
||||
if _, err := mail.ParseAddress(cn); err != nil {
|
||||
return ValidationError{Field: "common_name", Message: fmt.Sprintf("invalid email format for S/MIME common name: %v", err)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Basic hostname validation: allow alphanumeric, dots, hyphens
|
||||
if err := isValidHostname(cn); err != nil {
|
||||
return ValidationError{Field: "common_name", Message: fmt.Sprintf("invalid hostname format: %v", err)}
|
||||
|
||||
@@ -25,6 +25,10 @@ type Config struct {
|
||||
EST ESTConfig
|
||||
Verification VerificationConfig
|
||||
ACME ACMEConfig
|
||||
Vault VaultConfig
|
||||
DigiCert DigiCertConfig
|
||||
Sectigo SectigoConfig
|
||||
GoogleCAS GoogleCASConfig
|
||||
Digest DigestConfig
|
||||
}
|
||||
|
||||
@@ -141,6 +145,122 @@ type StepCAConfig struct {
|
||||
ProvisionerPassword string
|
||||
}
|
||||
|
||||
// VaultConfig contains HashiCorp Vault PKI issuer connector configuration.
|
||||
type VaultConfig struct {
|
||||
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
|
||||
// Required for Vault PKI integration.
|
||||
// Setting: CERTCTL_VAULT_ADDR environment variable.
|
||||
Addr string
|
||||
|
||||
// Token is the Vault token for authentication.
|
||||
// Required for Vault PKI integration.
|
||||
// Setting: CERTCTL_VAULT_TOKEN environment variable.
|
||||
Token string
|
||||
|
||||
// Mount is the PKI secrets engine mount path.
|
||||
// Default: "pki".
|
||||
// Setting: CERTCTL_VAULT_MOUNT environment variable.
|
||||
Mount string
|
||||
|
||||
// Role is the PKI role name used for signing certificates.
|
||||
// Required for Vault PKI integration.
|
||||
// Setting: CERTCTL_VAULT_ROLE environment variable.
|
||||
Role string
|
||||
|
||||
// TTL is the requested certificate time-to-live.
|
||||
// Default: "8760h" (1 year).
|
||||
// Setting: CERTCTL_VAULT_TTL environment variable.
|
||||
TTL string
|
||||
}
|
||||
|
||||
// DigiCertConfig contains DigiCert CertCentral issuer connector configuration.
|
||||
type DigiCertConfig struct {
|
||||
// APIKey is the CertCentral API key for authentication.
|
||||
// Required for DigiCert integration.
|
||||
// Setting: CERTCTL_DIGICERT_API_KEY environment variable.
|
||||
APIKey string
|
||||
|
||||
// OrgID is the DigiCert organization ID for certificate orders.
|
||||
// Required for DigiCert integration.
|
||||
// Setting: CERTCTL_DIGICERT_ORG_ID environment variable.
|
||||
OrgID string
|
||||
|
||||
// ProductType is the DigiCert product type for certificate orders.
|
||||
// Default: "ssl_basic". Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic".
|
||||
// Setting: CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
|
||||
ProductType string
|
||||
|
||||
// BaseURL is the DigiCert CertCentral API base URL.
|
||||
// Default: "https://www.digicert.com/services/v2".
|
||||
// Setting: CERTCTL_DIGICERT_BASE_URL environment variable.
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// SectigoConfig contains Sectigo Certificate Manager issuer connector configuration.
|
||||
type SectigoConfig struct {
|
||||
// CustomerURI is the Sectigo customer URI (organization identifier).
|
||||
// Required for Sectigo integration.
|
||||
// Setting: CERTCTL_SECTIGO_CUSTOMER_URI environment variable.
|
||||
CustomerURI string
|
||||
|
||||
// Login is the Sectigo API account login.
|
||||
// Required for Sectigo integration.
|
||||
// Setting: CERTCTL_SECTIGO_LOGIN environment variable.
|
||||
Login string
|
||||
|
||||
// Password is the Sectigo API account password or API key.
|
||||
// Required for Sectigo integration.
|
||||
// Setting: CERTCTL_SECTIGO_PASSWORD environment variable.
|
||||
Password string
|
||||
|
||||
// OrgID is the Sectigo organization ID for certificate enrollments.
|
||||
// Required for Sectigo integration.
|
||||
// Setting: CERTCTL_SECTIGO_ORG_ID environment variable.
|
||||
OrgID int
|
||||
|
||||
// CertType is the Sectigo certificate type ID (from GET /ssl/v1/types).
|
||||
// Required for enrollment. Set via CERTCTL_SECTIGO_CERT_TYPE environment variable.
|
||||
CertType int
|
||||
|
||||
// Term is the certificate validity in days (e.g., 365, 730).
|
||||
// Default: 365.
|
||||
// Setting: CERTCTL_SECTIGO_TERM environment variable.
|
||||
Term int
|
||||
|
||||
// BaseURL is the Sectigo SCM API base URL.
|
||||
// Default: "https://cert-manager.com/api".
|
||||
// Setting: CERTCTL_SECTIGO_BASE_URL environment variable.
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// GoogleCASConfig contains Google Cloud Certificate Authority Service configuration.
|
||||
type GoogleCASConfig struct {
|
||||
// Project is the GCP project ID.
|
||||
// Required for Google CAS integration.
|
||||
// Setting: CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||
Project string
|
||||
|
||||
// Location is the GCP region (e.g., "us-central1").
|
||||
// Required for Google CAS integration.
|
||||
// Setting: CERTCTL_GOOGLE_CAS_LOCATION environment variable.
|
||||
Location string
|
||||
|
||||
// CAPool is the Certificate Authority pool name.
|
||||
// Required for Google CAS integration.
|
||||
// Setting: CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
|
||||
CAPool string
|
||||
|
||||
// Credentials is the path to the service account JSON credentials file.
|
||||
// Required for Google CAS integration.
|
||||
// Setting: CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
|
||||
Credentials string
|
||||
|
||||
// TTL is the default certificate time-to-live.
|
||||
// Default: "8760h" (1 year).
|
||||
// Setting: CERTCTL_GOOGLE_CAS_TTL environment variable.
|
||||
TTL string
|
||||
}
|
||||
|
||||
// DigestConfig controls the scheduled certificate digest email feature.
|
||||
type DigestConfig struct {
|
||||
// Enabled controls whether periodic digest emails are generated and sent.
|
||||
@@ -203,6 +323,11 @@ type ACMEConfig struct {
|
||||
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
|
||||
// Setting: CERTCTL_ACME_ARI_ENABLED environment variable.
|
||||
ARIEnabled bool
|
||||
|
||||
// Insecure skips TLS certificate verification when connecting to the ACME directory.
|
||||
// Only use for testing with self-signed ACME servers like Pebble. Never in production.
|
||||
// Setting: CERTCTL_ACME_INSECURE environment variable.
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
||||
@@ -429,6 +554,35 @@ func Load() (*Config, error) {
|
||||
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
||||
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second),
|
||||
},
|
||||
Vault: VaultConfig{
|
||||
Addr: getEnv("CERTCTL_VAULT_ADDR", ""),
|
||||
Token: getEnv("CERTCTL_VAULT_TOKEN", ""),
|
||||
Mount: getEnv("CERTCTL_VAULT_MOUNT", "pki"),
|
||||
Role: getEnv("CERTCTL_VAULT_ROLE", ""),
|
||||
TTL: getEnv("CERTCTL_VAULT_TTL", "8760h"),
|
||||
},
|
||||
DigiCert: DigiCertConfig{
|
||||
APIKey: getEnv("CERTCTL_DIGICERT_API_KEY", ""),
|
||||
OrgID: getEnv("CERTCTL_DIGICERT_ORG_ID", ""),
|
||||
ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
||||
BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
||||
},
|
||||
Sectigo: SectigoConfig{
|
||||
CustomerURI: getEnv("CERTCTL_SECTIGO_CUSTOMER_URI", ""),
|
||||
Login: getEnv("CERTCTL_SECTIGO_LOGIN", ""),
|
||||
Password: getEnv("CERTCTL_SECTIGO_PASSWORD", ""),
|
||||
OrgID: getEnvInt("CERTCTL_SECTIGO_ORG_ID", 0),
|
||||
CertType: getEnvInt("CERTCTL_SECTIGO_CERT_TYPE", 0),
|
||||
Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365),
|
||||
BaseURL: getEnv("CERTCTL_SECTIGO_BASE_URL", "https://cert-manager.com/api"),
|
||||
},
|
||||
GoogleCAS: GoogleCASConfig{
|
||||
Project: getEnv("CERTCTL_GOOGLE_CAS_PROJECT", ""),
|
||||
Location: getEnv("CERTCTL_GOOGLE_CAS_LOCATION", ""),
|
||||
CAPool: getEnv("CERTCTL_GOOGLE_CAS_CA_POOL", ""),
|
||||
Credentials: getEnv("CERTCTL_GOOGLE_CAS_CREDENTIALS", ""),
|
||||
TTL: getEnv("CERTCTL_GOOGLE_CAS_TTL", "8760h"),
|
||||
},
|
||||
ACME: ACMEConfig{
|
||||
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
||||
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
||||
@@ -437,6 +591,7 @@ func Load() (*Config, error) {
|
||||
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
|
||||
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
|
||||
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
|
||||
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", false),
|
||||
},
|
||||
Digest: DigestConfig{
|
||||
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -58,6 +59,10 @@ type Config struct {
|
||||
// ARIEnabled enables ACME Renewal Information (RFC 9702) support per CERTCTL_ACME_ARI_ENABLED.
|
||||
// When enabled, the connector queries the CA's ARI endpoint to get CA-directed renewal timing.
|
||||
ARIEnabled bool `json:"ari_enabled,omitempty"`
|
||||
|
||||
// Insecure skips TLS certificate verification when connecting to the ACME directory.
|
||||
// Only use for testing with self-signed ACME servers like Pebble.
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||
@@ -114,6 +119,18 @@ func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return c
|
||||
}
|
||||
|
||||
// httpClient returns an HTTP client configured for the ACME connector.
|
||||
// When Insecure is true (e.g., for Pebble test servers), TLS verification is skipped.
|
||||
func (c *Connector) httpClient() *http.Client {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
if c.config != nil && c.config.Insecure {
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // Intentional for test ACME servers (Pebble)
|
||||
}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the ACME directory URL is reachable and valid.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
@@ -129,10 +146,16 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
return fmt.Errorf("ACME email is required")
|
||||
}
|
||||
|
||||
c.logger.Info("validating ACME configuration", "directory_url", cfg.DirectoryURL)
|
||||
c.logger.Info("validating ACME configuration", "directory_url", cfg.DirectoryURL, "insecure", cfg.Insecure)
|
||||
|
||||
// Apply config so httpClient() can use it for the directory probe.
|
||||
// This persists across the function — if validation fails early, the config
|
||||
// will still be set, but that's fine since a failed ValidateConfig means
|
||||
// the connector won't be used.
|
||||
c.config = &cfg
|
||||
|
||||
// Verify that the directory URL is reachable
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
httpClient := c.httpClient()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.DirectoryURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -203,6 +226,7 @@ func (c *Connector) ensureClient(ctx context.Context) error {
|
||||
c.client = &acme.Client{
|
||||
Key: key,
|
||||
DirectoryURL: c.config.DirectoryURL,
|
||||
HTTPClient: c.httpClient(),
|
||||
}
|
||||
|
||||
// Register or retrieve the ACME account
|
||||
@@ -338,6 +362,12 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
}
|
||||
c.logger.Info("ACME order created", "order_url", order.URI, "status", order.Status)
|
||||
|
||||
// Save FinalizeURL and URI before WaitOrder — WaitOrder returns a new Order
|
||||
// object that may have empty FinalizeURL and URI fields (Go's crypto/acme
|
||||
// WaitOrder doesn't populate Order.URI on the returned struct).
|
||||
finalizeURL := order.FinalizeURL
|
||||
orderURI := order.URI
|
||||
|
||||
// Step 2: Solve authorizations (HTTP-01 challenges)
|
||||
if order.Status == acme.StatusPending {
|
||||
if err := c.solveAuthorizations(ctx, order.AuthzURLs); err != nil {
|
||||
@@ -345,10 +375,18 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
}
|
||||
|
||||
// Wait for the order to be ready
|
||||
order, err = c.client.WaitOrder(ctx, order.URI)
|
||||
order, err = c.client.WaitOrder(ctx, orderURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("order failed after challenge: %w", err)
|
||||
}
|
||||
// Update finalizeURL from the waited order if it has one
|
||||
if order.FinalizeURL != "" {
|
||||
finalizeURL = order.FinalizeURL
|
||||
}
|
||||
// Preserve orderURI — WaitOrder doesn't populate Order.URI
|
||||
if order.URI != "" {
|
||||
orderURI = order.URI
|
||||
}
|
||||
}
|
||||
|
||||
if order.Status != acme.StatusReady {
|
||||
@@ -361,9 +399,39 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
||||
}
|
||||
|
||||
derChain, _, err := c.client.CreateOrderCert(ctx, order.FinalizeURL, csrDER, true)
|
||||
if finalizeURL == "" {
|
||||
return nil, fmt.Errorf("ACME order has no finalize URL (order URI: %s, status: %s)", order.URI, order.Status)
|
||||
}
|
||||
|
||||
// Step 3b: Finalize the order and fetch the certificate.
|
||||
// CreateOrderCert POSTs the CSR to the finalize URL and attempts to retrieve
|
||||
// the certificate. Some ACME servers (notably Pebble) return the order object
|
||||
// per RFC 8555 rather than redirecting to the cert, which can cause
|
||||
// CreateOrderCert's internal cert URL resolution to fail. In that case, we
|
||||
// fall back to WaitOrder (to get the CertURL) + FetchCert.
|
||||
derChain, _, err := c.client.CreateOrderCert(ctx, finalizeURL, csrDER, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to finalize order: %w", err)
|
||||
c.logger.Warn("CreateOrderCert failed, attempting manual certificate fetch",
|
||||
"error", err, "order_uri", orderURI)
|
||||
|
||||
// The finalize POST likely succeeded (the CA issued the cert) but cert
|
||||
// retrieval failed. WaitOrder returns the order in "valid" state with
|
||||
// CertURL populated.
|
||||
validOrder, waitErr := c.client.WaitOrder(ctx, orderURI)
|
||||
if waitErr != nil {
|
||||
return nil, fmt.Errorf("failed to finalize order: %w (wait fallback: %v)", err, waitErr)
|
||||
}
|
||||
|
||||
if validOrder.CertURL == "" {
|
||||
return nil, fmt.Errorf("order finalized but no certificate URL returned (original error: %w)", err)
|
||||
}
|
||||
|
||||
c.logger.Info("fetching certificate via fallback", "cert_url", validOrder.CertURL)
|
||||
fetchedChain, fetchErr := c.client.FetchCert(ctx, validOrder.CertURL, true)
|
||||
if fetchErr != nil {
|
||||
return nil, fmt.Errorf("failed to fetch certificate: %w (original finalize error: %v)", fetchErr, err)
|
||||
}
|
||||
derChain = fetchedChain
|
||||
}
|
||||
|
||||
if len(derChain) == 0 {
|
||||
@@ -387,7 +455,7 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
Serial: serial,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
OrderID: order.URI,
|
||||
OrderID: orderURI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
// Package digicert implements the issuer.Connector interface for DigiCert CertCentral.
|
||||
//
|
||||
// DigiCert CertCentral is an enterprise certificate authority offering DV, OV, and EV
|
||||
// certificates. Unlike synchronous issuers (Vault, step-ca), DigiCert uses an
|
||||
// asynchronous order model: submit an order, receive an order ID, then poll for
|
||||
// completion. OV/EV certificates require organization validation which may take hours
|
||||
// or days; DV certificates may be issued immediately.
|
||||
//
|
||||
// This connector maps to certctl's existing job state machine:
|
||||
// - IssueCertificate submits the order; if status is "issued", returns cert immediately.
|
||||
// If status is "pending", returns OrderID with empty CertPEM — the job system polls
|
||||
// via GetOrderStatus.
|
||||
// - GetOrderStatus polls the order; when status becomes "issued", downloads and
|
||||
// parses the PEM bundle.
|
||||
//
|
||||
// Authentication: API key via X-DC-DEVKEY header.
|
||||
//
|
||||
// DigiCert CertCentral API used:
|
||||
//
|
||||
// POST /order/certificate/{product_type} - Submit certificate order
|
||||
// GET /order/certificate/{order_id} - Check order status
|
||||
// GET /certificate/{certificate_id}/download/format/pem_all - Download cert bundle
|
||||
// PUT /certificate/{certificate_id}/revoke - Revoke certificate
|
||||
// GET /user/me - Validate API credentials
|
||||
package digicert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the DigiCert CertCentral issuer connector configuration.
|
||||
type Config struct {
|
||||
// APIKey is the CertCentral API key for authentication.
|
||||
// Required. Set via CERTCTL_DIGICERT_API_KEY environment variable.
|
||||
APIKey string `json:"api_key"`
|
||||
|
||||
// OrgID is the DigiCert organization ID for certificate orders.
|
||||
// Required. Set via CERTCTL_DIGICERT_ORG_ID environment variable.
|
||||
OrgID string `json:"org_id"`
|
||||
|
||||
// ProductType is the DigiCert product type for certificate orders.
|
||||
// Default: "ssl_basic". Set via CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
|
||||
// Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic", "ssl_plus", "ssl_multi_domain".
|
||||
ProductType string `json:"product_type"`
|
||||
|
||||
// BaseURL is the DigiCert CertCentral API base URL.
|
||||
// Default: "https://www.digicert.com/services/v2".
|
||||
// Set via CERTCTL_DIGICERT_BASE_URL environment variable.
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for DigiCert CertCentral.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new DigiCert CertCentral connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil {
|
||||
if config.ProductType == "" {
|
||||
config.ProductType = "ssl_basic"
|
||||
}
|
||||
if config.BaseURL == "" {
|
||||
config.BaseURL = "https://www.digicert.com/services/v2"
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// orderRequest is the JSON body for DigiCert certificate order submission.
|
||||
type orderRequest struct {
|
||||
Certificate orderCert `json:"certificate"`
|
||||
Organization orderOrg `json:"organization"`
|
||||
ValidityYears int `json:"validity_years"`
|
||||
}
|
||||
|
||||
type orderCert struct {
|
||||
CommonName string `json:"common_name"`
|
||||
CSR string `json:"csr"`
|
||||
DNSNames []string `json:"dns_names,omitempty"`
|
||||
}
|
||||
|
||||
type orderOrg struct {
|
||||
ID json.Number `json:"id"`
|
||||
}
|
||||
|
||||
// orderResponse is the JSON response from a certificate order submission.
|
||||
type orderResponse struct {
|
||||
ID int `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CertificateID int `json:"certificate_id,omitempty"`
|
||||
}
|
||||
|
||||
// orderStatusResponse is the JSON response from an order status check.
|
||||
type orderStatusResponse struct {
|
||||
ID int `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Certificate struct {
|
||||
ID int `json:"id"`
|
||||
CommonName string `json:"common_name"`
|
||||
} `json:"certificate"`
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the DigiCert configuration is valid and API access works.
|
||||
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 DigiCert config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.APIKey == "" {
|
||||
return fmt.Errorf("DigiCert api_key is required")
|
||||
}
|
||||
|
||||
if cfg.OrgID == "" {
|
||||
return fmt.Errorf("DigiCert org_id is required")
|
||||
}
|
||||
|
||||
if cfg.ProductType == "" {
|
||||
cfg.ProductType = "ssl_basic"
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://www.digicert.com/services/v2"
|
||||
}
|
||||
|
||||
// Test API access via /user/me
|
||||
meURL := cfg.BaseURL + "/user/me"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create API test request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-DC-DEVKEY", cfg.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DigiCert API not reachable at %s: %w", cfg.BaseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("DigiCert API key is invalid (status %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("DigiCert API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("DigiCert CertCentral configuration validated",
|
||||
"base_url", cfg.BaseURL,
|
||||
"product_type", cfg.ProductType)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate submits a certificate order to DigiCert CertCentral.
|
||||
// If the certificate is issued immediately (DV certs), returns the cert.
|
||||
// If pending (OV/EV certs), returns OrderID with empty CertPEM for polling.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing DigiCert issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs),
|
||||
"product_type", c.config.ProductType)
|
||||
|
||||
orderReq := orderRequest{
|
||||
Certificate: orderCert{
|
||||
CommonName: request.CommonName,
|
||||
CSR: request.CSRPEM,
|
||||
DNSNames: request.SANs,
|
||||
},
|
||||
Organization: orderOrg{
|
||||
ID: json.Number(c.config.OrgID),
|
||||
},
|
||||
ValidityYears: 1,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(orderReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal order request: %w", err)
|
||||
}
|
||||
|
||||
orderURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, c.config.ProductType)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, orderURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create order request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DigiCert order request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read order response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("DigiCert order returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var orderResp orderResponse
|
||||
if err := json.Unmarshal(respBody, &orderResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
orderID := fmt.Sprintf("%d", orderResp.ID)
|
||||
|
||||
c.logger.Info("DigiCert order submitted",
|
||||
"order_id", orderID,
|
||||
"status", orderResp.Status)
|
||||
|
||||
// If issued immediately (DV certs), download the certificate
|
||||
if orderResp.Status == "issued" && orderResp.CertificateID > 0 {
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, orderResp.CertificateID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download certificate: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("DigiCert certificate issued immediately",
|
||||
"order_id", orderID,
|
||||
"serial", serial)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Pending — return OrderID for polling via GetOrderStatus
|
||||
c.logger.Info("DigiCert order pending validation",
|
||||
"order_id", orderID,
|
||||
"status", orderResp.Status)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews a certificate by submitting a new order.
|
||||
// DigiCert uses reissue for renewal, but for simplicity we submit a new order
|
||||
// (reissue requires the original order ID which may not be available).
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing DigiCert 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,
|
||||
EKUs: request.EKUs,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at DigiCert CertCentral.
|
||||
// DigiCert revocation uses certificate_id, so we extract it from the serial
|
||||
// by looking up the order. For simplicity, we use the serial as the cert ID
|
||||
// (the caller should provide the DigiCert certificate ID).
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing DigiCert revocation request", "serial", request.Serial)
|
||||
|
||||
reason := "unspecified"
|
||||
if request.Reason != nil {
|
||||
reason = *request.Reason
|
||||
}
|
||||
|
||||
revokeBody := map[string]interface{}{
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(revokeBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||
}
|
||||
|
||||
// DigiCert uses certificate_id in the URL path for revocation
|
||||
revokeURL := fmt.Sprintf("%s/certificate/%s/revoke", c.config.BaseURL, request.Serial)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create revoke request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DigiCert revoke request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// DigiCert returns 204 No Content on successful revocation
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("DigiCert revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
c.logger.Info("DigiCert certificate revoked", "serial", request.Serial, "reason", reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus checks the status of a DigiCert certificate order.
|
||||
// If the order is "issued", downloads the certificate and returns it.
|
||||
// If still "pending", returns pending status for continued polling.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
c.logger.Debug("checking DigiCert order status", "order_id", orderID)
|
||||
|
||||
statusURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, orderID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create status request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DigiCert status request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read status response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DigiCert order status returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var statusResp orderStatusResponse
|
||||
if err := json.Unmarshal(respBody, &statusResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse status response: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch statusResp.Status {
|
||||
case "issued":
|
||||
if statusResp.Certificate.ID == 0 {
|
||||
return nil, fmt.Errorf("order is issued but certificate_id is missing")
|
||||
}
|
||||
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, statusResp.Certificate.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download certificate: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("DigiCert order completed",
|
||||
"order_id", orderID,
|
||||
"serial", serial)
|
||||
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "completed",
|
||||
CertPEM: &certPEM,
|
||||
ChainPEM: &chainPEM,
|
||||
Serial: &serial,
|
||||
NotBefore: ¬Before,
|
||||
NotAfter: ¬After,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
case "pending", "processing":
|
||||
msg := fmt.Sprintf("order %s is %s", orderID, statusResp.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "pending",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
case "rejected", "denied":
|
||||
msg := fmt.Sprintf("order %s was %s", orderID, statusResp.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "failed",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
msg := fmt.Sprintf("unknown order status: %s", statusResp.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "pending",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// downloadCertificate downloads the PEM bundle for a DigiCert certificate.
|
||||
func (c *Connector) downloadCertificate(ctx context.Context, certificateID int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
downloadURL := fmt.Sprintf("%s/certificate/%d/download/format/pem_all", c.config.BaseURL, certificateID)
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if reqErr != nil {
|
||||
err = fmt.Errorf("failed to create download request: %w", reqErr)
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||
|
||||
resp, doErr := c.httpClient.Do(req)
|
||||
if doErr != nil {
|
||||
err = fmt.Errorf("DigiCert download request failed: %w", doErr)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = fmt.Errorf("DigiCert download returned status %d: %s", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("failed to read download response: %w", readErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the PEM bundle: first cert is the leaf, rest are intermediates
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body))
|
||||
return
|
||||
}
|
||||
|
||||
// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata.
|
||||
func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
var certs []string
|
||||
remaining := bundle
|
||||
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest := pem.Decode([]byte(remaining))
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
certs = append(certs, string(pem.EncodeToMemory(block)))
|
||||
}
|
||||
remaining = string(rest)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
err = fmt.Errorf("no certificates found in PEM bundle")
|
||||
return
|
||||
}
|
||||
|
||||
certPEM = certs[0]
|
||||
if len(certs) > 1 {
|
||||
chainPEM = strings.Join(certs[1:], "")
|
||||
}
|
||||
|
||||
// Parse leaf cert for metadata
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
err = fmt.Errorf("failed to decode leaf certificate PEM")
|
||||
return
|
||||
}
|
||||
|
||||
cert, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil {
|
||||
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
||||
return
|
||||
}
|
||||
|
||||
serial = cert.SerialNumber.String()
|
||||
notBefore = cert.NotBefore
|
||||
notAfter = cert.NotAfter
|
||||
return
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported because DigiCert manages CRL distribution.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("DigiCert manages CRL distribution; use DigiCert's CRL endpoints")
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported because DigiCert manages OCSP.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("DigiCert manages OCSP; use DigiCert's OCSP responder")
|
||||
}
|
||||
|
||||
// GetCACertPEM is not directly supported. DigiCert intermediate certificates
|
||||
// come with each certificate issuance as part of the PEM bundle.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("DigiCert intermediate certificates are included with each issued certificate")
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as DigiCert does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,591 @@
|
||||
package digicert_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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||
)
|
||||
|
||||
func TestDigiCertConnector(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) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/user/me" {
|
||||
if r.Header.Get("X-DC-DEVKEY") == "dc-test-api-key" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":12345,"first_name":"Test","last_name":"User"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := digicert.Config{
|
||||
APIKey: "dc-test-api-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) {
|
||||
config := digicert.Config{
|
||||
OrgID: "12345",
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing api_key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "api_key is required") {
|
||||
t.Errorf("Expected api_key required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) {
|
||||
config := digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing org_id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "org_id is required") {
|
||||
t.Errorf("Expected org_id required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidKey", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/user/me" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := digicert.Config{
|
||||
APIKey: "dc-bad-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
|
||||
connector := digicert.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid API key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
testChainPEM, _ := generateTestCert(t)
|
||||
pemBundle := testCertPEM + testChainPEM
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_basic"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99001,"status":"issued","certificate_id":88001}`))
|
||||
case r.URL.Path == "/certificate/88001/download/format/pem_all":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(pemBundle))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.example.com",
|
||||
SANs: []string{"app.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Error("CertPEM should not be empty for immediate issuance")
|
||||
}
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial should not be empty for immediate issuance")
|
||||
}
|
||||
if result.OrderID != "99001" {
|
||||
t.Errorf("Expected OrderID '99001', got '%s'", result.OrderID)
|
||||
}
|
||||
t.Logf("DigiCert issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Pending", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_ev_basic"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99002,"status":"pending"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_ev_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "secure.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "secure.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.OrderID != "99002" {
|
||||
t.Errorf("Expected OrderID '99002', got '%s'", result.OrderID)
|
||||
}
|
||||
if result.CertPEM != "" {
|
||||
t.Error("CertPEM should be empty for pending order")
|
||||
}
|
||||
if result.Serial != "" {
|
||||
t.Error("Serial should be empty for pending order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":[{"code":"invalid_csr","message":"CSR is malformed"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: "invalid-csr",
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
testChainPEM, _ := generateTestCert(t)
|
||||
pemBundle := testCertPEM + testChainPEM
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/order/certificate/99001":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99001,"status":"issued","certificate":{"id":88001,"common_name":"app.example.com"}}`))
|
||||
case "/certificate/88001/download/format/pem_all":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(pemBundle))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "99001")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
if status.CertPEM == nil || *status.CertPEM == "" {
|
||||
t.Error("CertPEM should not be empty for issued order")
|
||||
}
|
||||
if status.Serial == nil || *status.Serial == "" {
|
||||
t.Error("Serial should not be empty for issued order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/order/certificate/99002" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99002,"status":"pending","certificate":{"id":0}}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "99002")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "pending" {
|
||||
t.Errorf("Expected status 'pending', got '%s'", status.Status)
|
||||
}
|
||||
if status.CertPEM != nil {
|
||||
t.Error("CertPEM should be nil for pending order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Rejected", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/order/certificate/99003" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99003,"status":"rejected","certificate":{"id":0}}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "99003")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "failed" {
|
||||
t.Errorf("Expected status 'failed', got '%s'", status.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate_NewOrder", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/order/certificate/"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99010,"status":"pending"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
ProductType: "ssl_basic",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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.OrderID == "" {
|
||||
t.Error("OrderID should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
|
||||
if r.Header.Get("X-DC-DEVKEY") == "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "88001",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":[{"code":"certificate_not_found"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "00000",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for revocation of nonexistent cert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_DownloadError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/order/certificate/99004":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":99004,"status":"issued","certificate":{"id":88004}}`))
|
||||
case "/certificate/88004/download/format/pem_all":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"errors":["internal server error"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
_, err := connector.GetOrderStatus(ctx, "99004")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when download fails")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "download") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
BaseURL: "https://api.digicert.com",
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatal("GetRenewalInfo should return nil for DigiCert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DefaultProductType", func(t *testing.T) {
|
||||
config := &digicert.Config{
|
||||
APIKey: "dc-test-key",
|
||||
OrgID: "12345",
|
||||
// ProductType intentionally left empty
|
||||
}
|
||||
connector := digicert.New(config, logger)
|
||||
|
||||
// Verify the connector was created (the default is set in New())
|
||||
if connector == nil {
|
||||
t.Fatal("Connector should not be nil")
|
||||
}
|
||||
|
||||
// Verify via a request that uses the product type
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify the path includes the default product type
|
||||
if strings.Contains(r.URL.Path, "ssl_basic") {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"id":99099,"status":"pending"}`))
|
||||
return
|
||||
}
|
||||
t.Errorf("Expected path to contain 'ssl_basic', got: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Reconfigure with test server URL
|
||||
config.BaseURL = srv.URL
|
||||
connector = digicert.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate with default product type failed: %v", err)
|
||||
}
|
||||
if result.OrderID == "" {
|
||||
t.Error("OrderID should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
// generateTestCSR creates a test CSR for the given common name.
|
||||
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", 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 {
|
||||
t.Fatalf("Failed to create CSR: %v", err)
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
}))
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse CSR: %v", err)
|
||||
}
|
||||
|
||||
return csr, csrPEM
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
// Package googlecas implements the issuer.Connector interface for
|
||||
// Google Cloud Certificate Authority Service (CAS).
|
||||
//
|
||||
// Google CAS is a managed private CA service on GCP. This connector
|
||||
// uses the CAS REST API (privateca.googleapis.com/v1) with OAuth2
|
||||
// service account authentication. Certificates are issued synchronously.
|
||||
//
|
||||
// Authentication: OAuth2 service account via JWT → access token exchange.
|
||||
// No Google SDK dependency — uses stdlib crypto/rsa + net/http.
|
||||
//
|
||||
// API endpoints used:
|
||||
//
|
||||
// POST /v1/{parent}/certificates - Issue certificate
|
||||
// POST /v1/{name}:revoke - Revoke certificate
|
||||
// POST /v1/{caPool}:fetchCaCerts - Get CA certificate chain
|
||||
package googlecas
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the Google CAS issuer connector configuration.
|
||||
type Config struct {
|
||||
// Project is the GCP project ID.
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||
Project string `json:"project"`
|
||||
|
||||
// Location is the GCP region (e.g., "us-central1").
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_LOCATION environment variable.
|
||||
Location string `json:"location"`
|
||||
|
||||
// CAPool is the Certificate Authority pool name.
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
|
||||
CAPool string `json:"ca_pool"`
|
||||
|
||||
// Credentials is the path to the service account JSON credentials file.
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
|
||||
Credentials string `json:"credentials"`
|
||||
|
||||
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
|
||||
// Default: "8760h". Set via CERTCTL_GOOGLE_CAS_TTL environment variable.
|
||||
TTL string `json:"ttl"`
|
||||
|
||||
// BaseURL overrides the Google CAS API base URL (for testing).
|
||||
// Default: "https://privateca.googleapis.com/v1".
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
|
||||
// TokenURL overrides the OAuth2 token endpoint (for testing).
|
||||
// Default: "https://oauth2.googleapis.com/token".
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
}
|
||||
|
||||
// serviceAccountKey represents the relevant fields from a Google service account JSON file.
|
||||
type serviceAccountKey struct {
|
||||
Type string `json:"type"`
|
||||
ProjectID string `json:"project_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
ClientEmail string `json:"client_email"`
|
||||
TokenURI string `json:"token_uri"`
|
||||
}
|
||||
|
||||
// cachedToken holds an OAuth2 access token and its expiry.
|
||||
type cachedToken struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for Google CAS.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
|
||||
// OAuth2 token caching
|
||||
mu sync.Mutex
|
||||
tokenCache *cachedToken
|
||||
saKey *serviceAccountKey
|
||||
rsaKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// New creates a new Google CAS connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil {
|
||||
if config.TTL == "" {
|
||||
config.TTL = "8760h"
|
||||
}
|
||||
if config.BaseURL == "" {
|
||||
config.BaseURL = "https://privateca.googleapis.com/v1"
|
||||
}
|
||||
if config.TokenURL == "" {
|
||||
config.TokenURL = "https://oauth2.googleapis.com/token"
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parentPath returns the CAS resource parent path.
|
||||
func (c *Connector) parentPath() string {
|
||||
return fmt.Sprintf("projects/%s/locations/%s/caPools/%s",
|
||||
c.config.Project, c.config.Location, c.config.CAPool)
|
||||
}
|
||||
|
||||
// certificateCreateResponse represents the Google CAS create certificate response.
|
||||
type certificateCreateResponse struct {
|
||||
Name string `json:"name"`
|
||||
PEMCertificate string `json:"pemCertificate"`
|
||||
PEMCertificateChain []string `json:"pemCertificateChain"`
|
||||
}
|
||||
|
||||
// fetchCACertsResponse represents the Google CAS fetchCaCerts response.
|
||||
type fetchCACertsResponse struct {
|
||||
CACerts []caCertChain `json:"caCerts"`
|
||||
}
|
||||
|
||||
type caCertChain struct {
|
||||
Certificates []string `json:"certificates"`
|
||||
}
|
||||
|
||||
// googleAPIError represents a Google API error response.
|
||||
type googleAPIError struct {
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the Google CAS configuration is valid.
|
||||
// Verifies required fields and that the credentials file is parseable.
|
||||
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 Google CAS config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Project == "" {
|
||||
return fmt.Errorf("Google CAS project is required")
|
||||
}
|
||||
if cfg.Location == "" {
|
||||
return fmt.Errorf("Google CAS location is required")
|
||||
}
|
||||
if cfg.CAPool == "" {
|
||||
return fmt.Errorf("Google CAS CA pool is required")
|
||||
}
|
||||
if cfg.Credentials == "" {
|
||||
return fmt.Errorf("Google CAS credentials path is required")
|
||||
}
|
||||
|
||||
// Verify credentials file exists and is valid
|
||||
saKey, _, err := loadServiceAccountKey(cfg.Credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Google CAS credentials invalid: %w", err)
|
||||
}
|
||||
|
||||
if saKey.ClientEmail == "" {
|
||||
return fmt.Errorf("Google CAS credentials missing client_email")
|
||||
}
|
||||
if saKey.PrivateKey == "" {
|
||||
return fmt.Errorf("Google CAS credentials missing private_key")
|
||||
}
|
||||
|
||||
if cfg.TTL == "" {
|
||||
cfg.TTL = "8760h"
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://privateca.googleapis.com/v1"
|
||||
}
|
||||
if cfg.TokenURL == "" {
|
||||
cfg.TokenURL = "https://oauth2.googleapis.com/token"
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Google CAS configuration validated",
|
||||
"project", cfg.Project,
|
||||
"location", cfg.Location,
|
||||
"ca_pool", cfg.CAPool)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadServiceAccountKey reads and parses a service account JSON file.
|
||||
func loadServiceAccountKey(path string) (*serviceAccountKey, *rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot read credentials file: %w", err)
|
||||
}
|
||||
|
||||
var saKey serviceAccountKey
|
||||
if err := json.Unmarshal(data, &saKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot parse credentials JSON: %w", err)
|
||||
}
|
||||
|
||||
if saKey.PrivateKey == "" {
|
||||
return &saKey, nil, nil
|
||||
}
|
||||
|
||||
// Parse the RSA private key
|
||||
block, _ := pem.Decode([]byte(saKey.PrivateKey))
|
||||
if block == nil {
|
||||
return nil, nil, fmt.Errorf("cannot decode private key PEM")
|
||||
}
|
||||
|
||||
// Try PKCS#8 first, then PKCS#1
|
||||
var rsaKey *rsa.PrivateKey
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
var ok bool
|
||||
rsaKey, ok = key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("private key is not RSA")
|
||||
}
|
||||
} else if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
rsaKey = key
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("cannot parse private key: not PKCS#8 or PKCS#1")
|
||||
}
|
||||
|
||||
return &saKey, rsaKey, nil
|
||||
}
|
||||
|
||||
// getAccessToken returns a valid OAuth2 access token, refreshing if needed.
|
||||
func (c *Connector) getAccessToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Return cached token if still valid (5 min buffer)
|
||||
if c.tokenCache != nil && time.Now().Add(5*time.Minute).Before(c.tokenCache.expiresAt) {
|
||||
return c.tokenCache.token, nil
|
||||
}
|
||||
|
||||
// Load credentials if not cached
|
||||
if c.saKey == nil || c.rsaKey == nil {
|
||||
saKey, rsaKey, err := loadServiceAccountKey(c.config.Credentials)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load credentials: %w", err)
|
||||
}
|
||||
c.saKey = saKey
|
||||
c.rsaKey = rsaKey
|
||||
}
|
||||
|
||||
// Build JWT
|
||||
now := time.Now()
|
||||
header := base64URLEncode([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||
|
||||
claims, err := json.Marshal(map[string]interface{}{
|
||||
"iss": c.saKey.ClientEmail,
|
||||
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||||
"aud": c.config.TokenURL,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal JWT claims: %w", err)
|
||||
}
|
||||
payload := base64URLEncode(claims)
|
||||
|
||||
// Sign
|
||||
signingInput := header + "." + payload
|
||||
hash := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, c.rsaKey, crypto.SHA256, hash[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
jwt := signingInput + "." + base64URLEncode(sig)
|
||||
|
||||
// Exchange JWT for access token
|
||||
form := url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
||||
"assertion": {jwt},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.TokenURL,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token exchange failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token exchange returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return "", fmt.Errorf("empty access token in response")
|
||||
}
|
||||
|
||||
// Cache token
|
||||
c.tokenCache = &cachedToken{
|
||||
token: tokenResp.AccessToken,
|
||||
expiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
|
||||
}
|
||||
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// doAuthenticatedRequest performs an HTTP request with OAuth2 bearer token.
|
||||
func (c *Connector) doAuthenticatedRequest(ctx context.Context, method, urlStr string, body interface{}) ([]byte, int, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return respBody, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// extractAPIError extracts an error message from a Google API error response.
|
||||
func extractAPIError(body []byte) string {
|
||||
var apiErr googleAPIError
|
||||
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Error.Message != "" {
|
||||
return fmt.Sprintf("%s (%s)", apiErr.Error.Message, apiErr.Error.Status)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// IssueCertificate issues a new certificate via Google CAS.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing Google CAS issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// Convert TTL to seconds string
|
||||
ttlDuration, err := time.ParseDuration(c.config.TTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid TTL %q: %w", c.config.TTL, err)
|
||||
}
|
||||
lifetimeSeconds := fmt.Sprintf("%ds", int(ttlDuration.Seconds()))
|
||||
|
||||
// Generate unique certificate ID
|
||||
certID := fmt.Sprintf("certctl-%d-%s", time.Now().Unix(), randomHex(4))
|
||||
|
||||
// Build request
|
||||
createURL := fmt.Sprintf("%s/%s/certificates?certificateId=%s",
|
||||
c.config.BaseURL, c.parentPath(), certID)
|
||||
|
||||
createBody := map[string]interface{}{
|
||||
"lifetime": lifetimeSeconds,
|
||||
"pemCsr": request.CSRPEM,
|
||||
}
|
||||
|
||||
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, createURL, createBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Google CAS create certificate failed: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Google CAS create certificate returned status %d: %s",
|
||||
statusCode, extractAPIError(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var certResp certificateCreateResponse
|
||||
if err := json.Unmarshal(respBody, &certResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Google CAS response: %w", err)
|
||||
}
|
||||
|
||||
if certResp.PEMCertificate == "" {
|
||||
return nil, fmt.Errorf("no certificate in Google CAS response")
|
||||
}
|
||||
|
||||
// Parse leaf cert to extract metadata
|
||||
block, _ := pem.Decode([]byte(certResp.PEMCertificate))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM from Google CAS")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
// Build chain PEM
|
||||
chainPEM := strings.Join(certResp.PEMCertificateChain, "\n")
|
||||
|
||||
serial := formatSerial(cert.SerialNumber)
|
||||
|
||||
// Store full resource name as OrderID for revocation lookup
|
||||
orderID := certResp.Name
|
||||
|
||||
c.logger.Info("Google CAS certificate issued",
|
||||
"common_name", request.CommonName,
|
||||
"serial", serial,
|
||||
"name", certResp.Name,
|
||||
"not_after", cert.NotAfter)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: certResp.PEMCertificate,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews a certificate by creating a new one.
|
||||
// For Google CAS, renewal is functionally identical to issuance.
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing Google CAS 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,
|
||||
EKUs: request.EKUs,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at Google CAS.
|
||||
// The serial field should contain the full certificate resource name (set as OrderID at issuance).
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing Google CAS revocation request", "serial", request.Serial)
|
||||
|
||||
// Determine the certificate resource name.
|
||||
// If serial starts with "projects/", it's a full resource name (from OrderID).
|
||||
// Otherwise, construct a best-effort path.
|
||||
var certName string
|
||||
if strings.HasPrefix(request.Serial, "projects/") {
|
||||
certName = request.Serial
|
||||
} else {
|
||||
certName = fmt.Sprintf("%s/certificates/%s", c.parentPath(), request.Serial)
|
||||
}
|
||||
|
||||
reason := mapRevocationReason(request.Reason)
|
||||
|
||||
revokeURL := fmt.Sprintf("%s/%s:revoke", c.config.BaseURL, certName)
|
||||
revokeBody := map[string]interface{}{
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, revokeURL, revokeBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Google CAS revoke failed: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return fmt.Errorf("Google CAS revoke returned status %d: %s",
|
||||
statusCode, extractAPIError(respBody))
|
||||
}
|
||||
|
||||
c.logger.Info("Google CAS certificate revoked", "name", certName, "reason", reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus returns the status of a Google CAS order.
|
||||
// Google CAS 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
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported because Google CAS manages CRL directly.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("Google CAS manages CRL directly; not supported via certctl")
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported because Google CAS manages OCSP directly.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("Google CAS manages OCSP directly; not supported via certctl")
|
||||
}
|
||||
|
||||
// GetCACertPEM retrieves the CA certificate chain from Google CAS.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
fetchURL := fmt.Sprintf("%s/%s:fetchCaCerts", c.config.BaseURL, c.parentPath())
|
||||
|
||||
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, fetchURL, map[string]interface{}{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Google CAS fetchCaCerts failed: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Google CAS fetchCaCerts returned status %d: %s",
|
||||
statusCode, extractAPIError(respBody))
|
||||
}
|
||||
|
||||
var resp fetchCACertsResponse
|
||||
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse fetchCaCerts response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.CACerts) == 0 || len(resp.CACerts[0].Certificates) == 0 {
|
||||
return "", fmt.Errorf("no CA certificates in response")
|
||||
}
|
||||
|
||||
// Join all certificates from the first CA cert chain
|
||||
return strings.Join(resp.CACerts[0].Certificates, "\n"), nil
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as Google CAS does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mapRevocationReason maps certctl RFC 5280 reason strings to Google CAS enum values.
|
||||
func mapRevocationReason(reason *string) string {
|
||||
if reason == nil {
|
||||
return "REVOCATION_REASON_UNSPECIFIED"
|
||||
}
|
||||
|
||||
switch strings.ToLower(*reason) {
|
||||
case "keycompromise":
|
||||
return "KEY_COMPROMISE"
|
||||
case "cacompromise":
|
||||
return "CERTIFICATE_AUTHORITY_COMPROMISE"
|
||||
case "affiliationchanged":
|
||||
return "AFFILIATION_CHANGED"
|
||||
case "superseded":
|
||||
return "SUPERSEDED"
|
||||
case "cessationofoperation":
|
||||
return "CESSATION_OF_OPERATION"
|
||||
case "certificatehold":
|
||||
return "CERTIFICATE_HOLD"
|
||||
case "privilegewithdrawn":
|
||||
return "PRIVILEGE_WITHDRAWN"
|
||||
default:
|
||||
return "REVOCATION_REASON_UNSPECIFIED"
|
||||
}
|
||||
}
|
||||
|
||||
// formatSerial converts a *big.Int serial number to a hex string.
|
||||
func formatSerial(serial *big.Int) string {
|
||||
return serial.Text(16)
|
||||
}
|
||||
|
||||
// randomHex generates n random bytes and returns them as a hex string.
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// base64URLEncode encodes data using base64url without padding.
|
||||
func base64URLEncode(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,826 @@
|
||||
package googlecas_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"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||
)
|
||||
|
||||
func TestGoogleCASConnector(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) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
config := googlecas.Config{
|
||||
Project: "my-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "my-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
}
|
||||
|
||||
connector := googlecas.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingProject", func(t *testing.T) {
|
||||
config := googlecas.Config{
|
||||
Location: "us-central1",
|
||||
CAPool: "my-pool",
|
||||
Credentials: "/tmp/creds.json",
|
||||
}
|
||||
|
||||
connector := googlecas.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing project")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "project is required") {
|
||||
t.Errorf("Expected project required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingLocation", func(t *testing.T) {
|
||||
config := googlecas.Config{
|
||||
Project: "my-project",
|
||||
CAPool: "my-pool",
|
||||
Credentials: "/tmp/creds.json",
|
||||
}
|
||||
|
||||
connector := googlecas.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing location")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "location is required") {
|
||||
t.Errorf("Expected location required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingCAPool", func(t *testing.T) {
|
||||
config := googlecas.Config{
|
||||
Project: "my-project",
|
||||
Location: "us-central1",
|
||||
Credentials: "/tmp/creds.json",
|
||||
}
|
||||
|
||||
connector := googlecas.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing CA pool")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CA pool is required") {
|
||||
t.Errorf("Expected CA pool required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingCredentials", func(t *testing.T) {
|
||||
config := googlecas.Config{
|
||||
Project: "my-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "my-pool",
|
||||
}
|
||||
|
||||
connector := googlecas.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing credentials")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "credentials path is required") {
|
||||
t.Errorf("Expected credentials required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidCredentialsFile", func(t *testing.T) {
|
||||
config := googlecas.Config{
|
||||
Project: "my-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "my-pool",
|
||||
Credentials: "/nonexistent/path/credentials.json",
|
||||
}
|
||||
|
||||
connector := googlecas.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid credentials file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "credentials invalid") {
|
||||
t.Errorf("Expected credentials invalid error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MalformedCredentialsJSON", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
badFile := filepath.Join(tmpDir, "bad-creds.json")
|
||||
if err := os.WriteFile(badFile, []byte("not json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := googlecas.Config{
|
||||
Project: "my-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "my-pool",
|
||||
Credentials: badFile,
|
||||
}
|
||||
|
||||
connector := googlecas.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for malformed credentials JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "credentials invalid") {
|
||||
t.Errorf("Expected credentials invalid error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token-12345","expires_in":3600,"token_type":"Bearer"}`))
|
||||
|
||||
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
|
||||
!strings.Contains(r.URL.Path, ":revoke") && !strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||
// Verify auth header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-token-12345" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
|
||||
return
|
||||
}
|
||||
// Verify certificateId query param
|
||||
certID := r.URL.Query().Get("certificateId")
|
||||
if certID == "" {
|
||||
t.Error("Missing certificateId query parameter")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
chainCert, _ := generateTestCert(t)
|
||||
resp := fmt.Sprintf(`{
|
||||
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/%s",
|
||||
"pemCertificate": %q,
|
||||
"pemCertificateChain": [%q]
|
||||
}`, certID, testCertPEM, chainCert)
|
||||
w.Write([]byte(resp))
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.example.com",
|
||||
SANs: []string{"app.example.com", "www.example.com"},
|
||||
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")
|
||||
}
|
||||
if !strings.HasPrefix(result.OrderID, "projects/") {
|
||||
t.Errorf("Expected OrderID to be full resource name, got '%s'", result.OrderID)
|
||||
}
|
||||
if result.ChainPEM == "" {
|
||||
t.Error("ChainPEM is empty")
|
||||
}
|
||||
if result.NotBefore.IsZero() {
|
||||
t.Error("NotBefore is zero")
|
||||
}
|
||||
if result.NotAfter.IsZero() {
|
||||
t.Error("NotAfter is zero")
|
||||
}
|
||||
t.Logf("Google CAS issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, "/certificates"):
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":{"code":400,"message":"Invalid CSR","status":"INVALID_ARGUMENT"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Invalid CSR") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_InvalidResponse", func(t *testing.T) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, "/certificates"):
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`not-json`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_AlwaysCompleted", func(t *testing.T) {
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
TTL: "8760h",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "projects/p/locations/l/caPools/cp/certificates/cert-123")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
if status.OrderID != "projects/p/locations/l/caPools/cp/certificates/cert-123" {
|
||||
t.Errorf("Expected OrderID preserved, got '%s'", status.OrderID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate_NewCert", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
|
||||
!strings.Contains(r.URL.Path, ":revoke"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := fmt.Sprintf(`{
|
||||
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/certctl-renew",
|
||||
"pemCertificate": %q,
|
||||
"pemCertificateChain": []
|
||||
}`, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
var receivedReason string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, ":revoke"):
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
receivedReason = body["reason"].(string)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"name":"projects/p/locations/l/caPools/cp/certificates/cert-123"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/cert-123",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if receivedReason != "KEY_COMPROMISE" {
|
||||
t.Errorf("Expected reason 'KEY_COMPROMISE', got '%s'", receivedReason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, ":revoke"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"error":{"code":404,"message":"Certificate not found","status":"NOT_FOUND"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/nonexistent",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for revoke of nonexistent certificate")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Certificate not found") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevocationReasonMapping", func(t *testing.T) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
expected string
|
||||
}{
|
||||
{"keyCompromise", "keyCompromise", "KEY_COMPROMISE"},
|
||||
{"caCompromise", "caCompromise", "CERTIFICATE_AUTHORITY_COMPROMISE"},
|
||||
{"affiliationChanged", "affiliationChanged", "AFFILIATION_CHANGED"},
|
||||
{"superseded", "superseded", "SUPERSEDED"},
|
||||
{"cessationOfOperation", "cessationOfOperation", "CESSATION_OF_OPERATION"},
|
||||
{"certificateHold", "certificateHold", "CERTIFICATE_HOLD"},
|
||||
{"privilegeWithdrawn", "privilegeWithdrawn", "PRIVILEGE_WITHDRAWN"},
|
||||
{"unspecified", "unspecified", "REVOCATION_REASON_UNSPECIFIED"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var receivedReason string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, ":revoke"):
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
receivedReason = body["reason"].(string)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
reason := tc.reason
|
||||
err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{
|
||||
Serial: "projects/p/locations/l/caPools/cp/certificates/cert-1",
|
||||
Reason: &reason,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if receivedReason != tc.expected {
|
||||
t.Errorf("Expected reason '%s', got '%s'", tc.expected, receivedReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACertPEM_Success", func(t *testing.T) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
caCertPEM, _ := generateTestCert(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := fmt.Sprintf(`{"caCerts":[{"certificates":[%q]}]}`, caCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
caPEM, err := connector.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(caPEM, "BEGIN CERTIFICATE") {
|
||||
t.Errorf("Expected CA PEM to contain certificate, got: %s", caPEM[:50])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACertPEM_Error", func(t *testing.T) {
|
||||
credPath := createTestCredentialsFile(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
_, err := connector.GetCACertPEM(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for permission denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatal("GetRenewalInfo should return nil for Google CAS")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AuthHeader_BearerToken", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
credPath := createTestCredentialsFile(t)
|
||||
var authHeader string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"verified-token-abc","expires_in":3600,"token_type":"Bearer"}`))
|
||||
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost:
|
||||
authHeader = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := fmt.Sprintf(`{
|
||||
"name": "projects/p/locations/l/caPools/cp/certificates/c1",
|
||||
"pemCertificate": %q,
|
||||
"pemCertificateChain": []
|
||||
}`, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &googlecas.Config{
|
||||
Project: "test-project",
|
||||
Location: "us-central1",
|
||||
CAPool: "test-pool",
|
||||
Credentials: credPath,
|
||||
TTL: "8760h",
|
||||
BaseURL: srv.URL,
|
||||
TokenURL: srv.URL + "/token",
|
||||
}
|
||||
connector := googlecas.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "auth-test.example.com")
|
||||
_, err := connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "auth-test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if authHeader != "Bearer verified-token-abc" {
|
||||
t.Errorf("Expected 'Bearer verified-token-abc', got '%s'", authHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createTestCredentialsFile generates a temporary service account JSON file with a test RSA key.
|
||||
func createTestCredentialsFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
|
||||
creds := map[string]interface{}{
|
||||
"type": "service_account",
|
||||
"project_id": "test-project",
|
||||
"private_key_id": "key-123",
|
||||
"private_key": string(keyPEM),
|
||||
"client_email": "certctl@test-project.iam.gserviceaccount.com",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal credentials: %v", err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
credPath := filepath.Join(tmpDir, "credentials.json")
|
||||
if err := os.WriteFile(credPath, data, 0600); err != nil {
|
||||
t.Fatalf("Failed to write credentials file: %v", err)
|
||||
}
|
||||
|
||||
return credPath
|
||||
}
|
||||
|
||||
// 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",
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
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
|
||||
}
|
||||
|
||||
// generateTestCSR creates a test CSR for the given common name.
|
||||
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", 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 {
|
||||
t.Fatalf("Failed to create CSR: %v", err)
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
}))
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse CSR: %v", err)
|
||||
}
|
||||
|
||||
return csr, csrPEM
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
// Package sectigo implements the issuer.Connector interface for Sectigo Certificate Manager (SCM).
|
||||
//
|
||||
// Sectigo Certificate Manager is an enterprise certificate authority offering DV, OV, and EV
|
||||
// certificates. Like DigiCert, Sectigo uses an asynchronous order model: submit an enrollment,
|
||||
// receive an sslId, then poll for completion. OV/EV certificates require organization validation
|
||||
// which may take hours or days; DV certificates may be issued immediately.
|
||||
//
|
||||
// This connector maps to certctl's existing job state machine:
|
||||
// - IssueCertificate submits the enrollment; if status is "Issued", returns cert immediately.
|
||||
// If status is "Applied" or "Pending", returns OrderID with empty CertPEM — the job system
|
||||
// polls via GetOrderStatus.
|
||||
// - GetOrderStatus polls the order; when status becomes "Issued", downloads and parses the
|
||||
// PEM bundle via the collect endpoint.
|
||||
//
|
||||
// Authentication: Three custom headers on every request — customerUri, login, password.
|
||||
//
|
||||
// Sectigo SCM REST API used:
|
||||
//
|
||||
// POST /ssl/v1/enroll - Submit certificate enrollment
|
||||
// GET /ssl/v1/{sslId} - Check enrollment status
|
||||
// GET /ssl/v1/collect/{sslId}/pem - Download PEM bundle when issued
|
||||
// POST /ssl/v1/revoke/{sslId} - Revoke certificate
|
||||
// GET /ssl/v1/types - List available cert types (used for health check)
|
||||
package sectigo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the Sectigo Certificate Manager issuer connector configuration.
|
||||
type Config struct {
|
||||
// CustomerURI is the Sectigo customer URI (organization identifier).
|
||||
// Required. Set via CERTCTL_SECTIGO_CUSTOMER_URI environment variable.
|
||||
CustomerURI string `json:"customer_uri"`
|
||||
|
||||
// Login is the Sectigo API account login.
|
||||
// Required. Set via CERTCTL_SECTIGO_LOGIN environment variable.
|
||||
Login string `json:"login"`
|
||||
|
||||
// Password is the Sectigo API account password or API key.
|
||||
// Required. Set via CERTCTL_SECTIGO_PASSWORD environment variable.
|
||||
Password string `json:"password"`
|
||||
|
||||
// OrgID is the Sectigo organization ID for certificate enrollments.
|
||||
// Required. Set via CERTCTL_SECTIGO_ORG_ID environment variable.
|
||||
OrgID int `json:"org_id"`
|
||||
|
||||
// CertType is the Sectigo certificate type ID (from GET /ssl/v1/types).
|
||||
// Required for enrollment. Set via CERTCTL_SECTIGO_CERT_TYPE environment variable.
|
||||
CertType int `json:"cert_type"`
|
||||
|
||||
// Term is the certificate validity in days (e.g., 365, 730).
|
||||
// Default: 365. Set via CERTCTL_SECTIGO_TERM environment variable.
|
||||
Term int `json:"term"`
|
||||
|
||||
// BaseURL is the Sectigo SCM API base URL.
|
||||
// Default: "https://cert-manager.com/api".
|
||||
// Set via CERTCTL_SECTIGO_BASE_URL environment variable.
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for Sectigo Certificate Manager.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new Sectigo SCM connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil {
|
||||
if config.Term == 0 {
|
||||
config.Term = 365
|
||||
}
|
||||
if config.BaseURL == "" {
|
||||
config.BaseURL = "https://cert-manager.com/api"
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// enrollRequest is the JSON body for Sectigo certificate enrollment.
|
||||
type enrollRequest struct {
|
||||
OrgID int `json:"orgId"`
|
||||
CSR string `json:"csr"`
|
||||
CertType int `json:"certType"`
|
||||
Term int `json:"term"`
|
||||
SubjAltNames string `json:"subjAltNames,omitempty"`
|
||||
Comments string `json:"comments,omitempty"`
|
||||
ExternalRequester string `json:"externalRequester,omitempty"`
|
||||
}
|
||||
|
||||
// enrollResponse is the JSON response from a certificate enrollment.
|
||||
type enrollResponse struct {
|
||||
SSLId int `json:"sslId"`
|
||||
RenewId string `json:"renewId,omitempty"`
|
||||
}
|
||||
|
||||
// statusResponse is the JSON response from an enrollment status check.
|
||||
type statusResponse struct {
|
||||
SSLId int `json:"sslId"`
|
||||
Status string `json:"status"`
|
||||
CommonName string `json:"commonName,omitempty"`
|
||||
SerialNumber string `json:"serialNumber,omitempty"`
|
||||
}
|
||||
|
||||
// setAuthHeaders sets the three Sectigo authentication headers on a request.
|
||||
func (c *Connector) setAuthHeaders(req *http.Request) {
|
||||
req.Header.Set("customerUri", c.config.CustomerURI)
|
||||
req.Header.Set("login", c.config.Login)
|
||||
req.Header.Set("password", c.config.Password)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the Sectigo configuration is valid and API access works.
|
||||
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 Sectigo config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CustomerURI == "" {
|
||||
return fmt.Errorf("Sectigo customer_uri is required")
|
||||
}
|
||||
|
||||
if cfg.Login == "" {
|
||||
return fmt.Errorf("Sectigo login is required")
|
||||
}
|
||||
|
||||
if cfg.Password == "" {
|
||||
return fmt.Errorf("Sectigo password is required")
|
||||
}
|
||||
|
||||
if cfg.OrgID == 0 {
|
||||
return fmt.Errorf("Sectigo org_id is required")
|
||||
}
|
||||
|
||||
if cfg.Term == 0 {
|
||||
cfg.Term = 365
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://cert-manager.com/api"
|
||||
}
|
||||
|
||||
// Test API access via GET /ssl/v1/types (health check)
|
||||
typesURL := cfg.BaseURL + "/ssl/v1/types"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, typesURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create API test request: %w", err)
|
||||
}
|
||||
req.Header.Set("customerUri", cfg.CustomerURI)
|
||||
req.Header.Set("login", cfg.Login)
|
||||
req.Header.Set("password", cfg.Password)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Sectigo API not reachable at %s: %w", cfg.BaseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("Sectigo API credentials are invalid (status %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Sectigo API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Sectigo Certificate Manager configuration validated",
|
||||
"base_url", cfg.BaseURL,
|
||||
"org_id", cfg.OrgID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate submits a certificate enrollment to Sectigo SCM.
|
||||
// If the certificate is issued immediately (DV certs), returns the cert.
|
||||
// If pending (OV/EV certs), returns OrderID with empty CertPEM for polling.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing Sectigo enrollment request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs),
|
||||
"cert_type", c.config.CertType)
|
||||
|
||||
enrollReq := enrollRequest{
|
||||
OrgID: c.config.OrgID,
|
||||
CSR: request.CSRPEM,
|
||||
CertType: c.config.CertType,
|
||||
Term: c.config.Term,
|
||||
Comments: "Issued by certctl",
|
||||
}
|
||||
|
||||
if len(request.SANs) > 0 {
|
||||
enrollReq.SubjAltNames = strings.Join(request.SANs, ",")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(enrollReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal enrollment request: %w", err)
|
||||
}
|
||||
|
||||
enrollURL := c.config.BaseURL + "/ssl/v1/enroll"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, enrollURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create enrollment request: %w", err)
|
||||
}
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sectigo enrollment request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read enrollment response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("Sectigo enrollment returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var enrollResp enrollResponse
|
||||
if err := json.Unmarshal(respBody, &enrollResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse enrollment response: %w", err)
|
||||
}
|
||||
|
||||
orderID := fmt.Sprintf("%d", enrollResp.SSLId)
|
||||
|
||||
c.logger.Info("Sectigo enrollment submitted", "ssl_id", orderID)
|
||||
|
||||
// Check status immediately to see if cert was issued right away
|
||||
status, err := c.checkStatus(ctx, enrollResp.SSLId)
|
||||
if err != nil {
|
||||
// Status check failed but enrollment succeeded — return as pending
|
||||
c.logger.Warn("Sectigo status check after enrollment failed, treating as pending",
|
||||
"ssl_id", orderID, "error", err)
|
||||
return &issuer.IssuanceResult{
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if status.Status == "Issued" {
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, collectErr := c.collectCertificate(ctx, enrollResp.SSLId)
|
||||
if collectErr != nil {
|
||||
// Cert is issued but collect failed — might not be generated yet
|
||||
c.logger.Warn("Sectigo certificate issued but collect failed, treating as pending",
|
||||
"ssl_id", orderID, "error", collectErr)
|
||||
return &issuer.IssuanceResult{
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
c.logger.Info("Sectigo certificate issued immediately",
|
||||
"ssl_id", orderID,
|
||||
"serial", serial)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Pending — return OrderID for polling via GetOrderStatus
|
||||
c.logger.Info("Sectigo enrollment pending validation",
|
||||
"ssl_id", orderID,
|
||||
"status", status.Status)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews a certificate by submitting a new enrollment.
|
||||
// Sectigo supports POST /ssl/renewById/{sslId} but for simplicity we submit
|
||||
// a new enrollment (same pattern as DigiCert).
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing Sectigo 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,
|
||||
EKUs: request.EKUs,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at Sectigo SCM.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing Sectigo revocation request", "serial", request.Serial)
|
||||
|
||||
reason := "Unspecified"
|
||||
if request.Reason != nil {
|
||||
reason = mapRevocationReason(*request.Reason)
|
||||
}
|
||||
|
||||
revokeBody := map[string]interface{}{
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(revokeBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||
}
|
||||
|
||||
// Sectigo uses sslId in the URL path for revocation
|
||||
revokeURL := fmt.Sprintf("%s/ssl/v1/revoke/%s", c.config.BaseURL, request.Serial)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create revoke request: %w", err)
|
||||
}
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Sectigo revoke request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Sectigo returns 204 No Content on successful revocation
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("Sectigo revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
c.logger.Info("Sectigo certificate revoked", "serial", request.Serial, "reason", reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus checks the status of a Sectigo certificate enrollment.
|
||||
// If the enrollment is "Issued", downloads the certificate and returns it.
|
||||
// If still pending, returns pending status for continued polling.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
c.logger.Debug("checking Sectigo enrollment status", "ssl_id", orderID)
|
||||
|
||||
// Parse sslId from string
|
||||
var sslId int
|
||||
if _, err := fmt.Sscanf(orderID, "%d", &sslId); err != nil {
|
||||
return nil, fmt.Errorf("invalid Sectigo ssl_id: %s", orderID)
|
||||
}
|
||||
|
||||
status, err := c.checkStatus(ctx, sslId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch status.Status {
|
||||
case "Issued":
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, collectErr := c.collectCertificate(ctx, sslId)
|
||||
if collectErr != nil {
|
||||
// Cert approved but not yet generated — treat as pending
|
||||
if isCollectNotReady(collectErr) {
|
||||
msg := fmt.Sprintf("enrollment %s is issued but certificate not yet generated", orderID)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "pending",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to collect certificate: %w", collectErr)
|
||||
}
|
||||
|
||||
c.logger.Info("Sectigo enrollment completed",
|
||||
"ssl_id", orderID,
|
||||
"serial", serial)
|
||||
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "completed",
|
||||
CertPEM: &certPEM,
|
||||
ChainPEM: &chainPEM,
|
||||
Serial: &serial,
|
||||
NotBefore: ¬Before,
|
||||
NotAfter: ¬After,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
case "Applied", "Pending":
|
||||
msg := fmt.Sprintf("enrollment %s is %s", orderID, status.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "pending",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
case "Rejected":
|
||||
msg := fmt.Sprintf("enrollment %s was rejected", orderID)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "failed",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
case "Revoked", "Expired", "Not Enrolled":
|
||||
msg := fmt.Sprintf("enrollment %s has status: %s", orderID, status.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "failed",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
msg := fmt.Sprintf("unknown enrollment status: %s", status.Status)
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "pending",
|
||||
Message: &msg,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// checkStatus retrieves the enrollment status from Sectigo.
|
||||
func (c *Connector) checkStatus(ctx context.Context, sslId int) (*statusResponse, error) {
|
||||
statusURL := fmt.Sprintf("%s/ssl/v1/%d", c.config.BaseURL, sslId)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create status request: %w", err)
|
||||
}
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sectigo status request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read status response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Sectigo status returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var statusResp statusResponse
|
||||
if err := json.Unmarshal(respBody, &statusResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse status response: %w", err)
|
||||
}
|
||||
|
||||
return &statusResp, nil
|
||||
}
|
||||
|
||||
// collectCertificate downloads the PEM bundle for a Sectigo certificate.
|
||||
func (c *Connector) collectCertificate(ctx context.Context, sslId int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
collectURL := fmt.Sprintf("%s/ssl/v1/collect/%d/pem", c.config.BaseURL, sslId)
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, collectURL, nil)
|
||||
if reqErr != nil {
|
||||
err = fmt.Errorf("failed to create collect request: %w", reqErr)
|
||||
return
|
||||
}
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, doErr := c.httpClient.Do(req)
|
||||
if doErr != nil {
|
||||
err = fmt.Errorf("Sectigo collect request failed: %w", doErr)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("failed to read collect response: %w", readErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Sectigo returns 400 with code -183 when cert is approved but not yet generated
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
err = &collectNotReadyError{statusCode: resp.StatusCode, body: string(body)}
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = fmt.Errorf("Sectigo collect returned status %d: %s", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the PEM bundle: first cert is the leaf, rest are intermediates
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body))
|
||||
return
|
||||
}
|
||||
|
||||
// collectNotReadyError indicates the certificate is not yet generated.
|
||||
type collectNotReadyError struct {
|
||||
statusCode int
|
||||
body string
|
||||
}
|
||||
|
||||
func (e *collectNotReadyError) Error() string {
|
||||
return fmt.Sprintf("certificate not yet available (status %d): %s", e.statusCode, e.body)
|
||||
}
|
||||
|
||||
// isCollectNotReady checks if an error indicates the cert is not yet generated.
|
||||
func isCollectNotReady(err error) bool {
|
||||
_, ok := err.(*collectNotReadyError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata.
|
||||
func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
var certs []string
|
||||
remaining := bundle
|
||||
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest := pem.Decode([]byte(remaining))
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
certs = append(certs, string(pem.EncodeToMemory(block)))
|
||||
}
|
||||
remaining = string(rest)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
err = fmt.Errorf("no certificates found in PEM bundle")
|
||||
return
|
||||
}
|
||||
|
||||
certPEM = certs[0]
|
||||
if len(certs) > 1 {
|
||||
chainPEM = strings.Join(certs[1:], "")
|
||||
}
|
||||
|
||||
// Parse leaf cert for metadata
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
err = fmt.Errorf("failed to decode leaf certificate PEM")
|
||||
return
|
||||
}
|
||||
|
||||
cert, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil {
|
||||
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
||||
return
|
||||
}
|
||||
|
||||
serial = cert.SerialNumber.String()
|
||||
notBefore = cert.NotBefore
|
||||
notAfter = cert.NotAfter
|
||||
return
|
||||
}
|
||||
|
||||
// mapRevocationReason maps RFC 5280 / certctl reason strings to Sectigo reason strings.
|
||||
func mapRevocationReason(reason string) string {
|
||||
switch strings.ToLower(reason) {
|
||||
case "keycompromise", "key_compromise":
|
||||
return "Compromised"
|
||||
case "cessationofoperation", "cessation_of_operation":
|
||||
return "Cessation of Operation"
|
||||
case "affiliationchanged", "affiliation_changed":
|
||||
return "Affiliation Changed"
|
||||
case "superseded":
|
||||
return "Superseded"
|
||||
default:
|
||||
return "Unspecified"
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported because Sectigo manages CRL distribution.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("Sectigo manages CRL distribution; use Sectigo's CRL endpoints")
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported because Sectigo manages OCSP.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("Sectigo manages OCSP; use Sectigo's OCSP responder")
|
||||
}
|
||||
|
||||
// GetCACertPEM is not directly supported. Sectigo intermediate certificates
|
||||
// come with each certificate issuance as part of the PEM bundle.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("Sectigo intermediate certificates are included with each issued certificate")
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as Sectigo does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,843 @@
|
||||
package sectigo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
||||
)
|
||||
|
||||
func TestSectigoConnector(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) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ssl/v1/types" {
|
||||
// Verify all 3 auth headers are present
|
||||
if r.Header.Get("customerUri") != "test-org" {
|
||||
t.Errorf("Expected customerUri 'test-org', got '%s'", r.Header.Get("customerUri"))
|
||||
}
|
||||
if r.Header.Get("login") != "api-user" {
|
||||
t.Errorf("Expected login 'api-user', got '%s'", r.Header.Get("login"))
|
||||
}
|
||||
if r.Header.Get("password") != "api-pass" {
|
||||
t.Errorf("Expected password 'api-pass', got '%s'", r.Header.Get("password"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`[{"id":423,"name":"Sectigo OV SSL","term":[365,730]}]`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := sectigo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
CertType: 423,
|
||||
Term: 365,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
|
||||
connector := sectigo.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingCustomerURI", func(t *testing.T) {
|
||||
config := sectigo.Config{
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
}
|
||||
|
||||
connector := sectigo.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing customer_uri")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "customer_uri is required") {
|
||||
t.Errorf("Expected customer_uri required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingLogin", func(t *testing.T) {
|
||||
config := sectigo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
}
|
||||
|
||||
connector := sectigo.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing login")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "login is required") {
|
||||
t.Errorf("Expected login required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingPassword", func(t *testing.T) {
|
||||
config := sectigo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
OrgID: 12345,
|
||||
}
|
||||
|
||||
connector := sectigo.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing password")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "password is required") {
|
||||
t.Errorf("Expected password required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) {
|
||||
config := sectigo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
}
|
||||
|
||||
connector := sectigo.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing org_id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "org_id is required") {
|
||||
t.Errorf("Expected org_id required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidCredentials", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ssl/v1/types" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"code":0,"description":"Invalid credentials"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := sectigo.Config{
|
||||
CustomerURI: "bad-org",
|
||||
Login: "bad-user",
|
||||
Password: "bad-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
|
||||
connector := sectigo.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid credentials")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
testChainPEM, _ := generateTestCert(t)
|
||||
pemBundle := testCertPEM + testChainPEM
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify auth headers on every request
|
||||
if r.Header.Get("customerUri") == "" || r.Header.Get("login") == "" || r.Header.Get("password") == "" {
|
||||
t.Error("Missing auth headers on request")
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.URL.Path == "/ssl/v1/enroll" && r.Method == http.MethodPost:
|
||||
// Verify request body structure
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
if req["orgId"] == nil {
|
||||
t.Error("Expected orgId in enrollment request")
|
||||
}
|
||||
if req["certType"] == nil {
|
||||
t.Error("Expected certType in enrollment request")
|
||||
}
|
||||
// SANs should be comma-separated string, not array
|
||||
if sans, ok := req["subjAltNames"].(string); ok {
|
||||
if !strings.Contains(sans, ",") && len(sans) > 0 {
|
||||
// Single SAN is fine
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55001,"renewId":"ren-abc"}`))
|
||||
|
||||
case r.URL.Path == "/ssl/v1/55001" && r.Method == http.MethodGet:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`))
|
||||
|
||||
case r.URL.Path == "/ssl/v1/collect/55001/pem" && r.Method == http.MethodGet:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(pemBundle))
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
CertType: 423,
|
||||
Term: 365,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.example.com",
|
||||
SANs: []string{"app.example.com", "www.example.com"},
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.CertPEM == "" {
|
||||
t.Error("CertPEM should not be empty for immediate issuance")
|
||||
}
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial should not be empty for immediate issuance")
|
||||
}
|
||||
if result.OrderID != "55001" {
|
||||
t.Errorf("Expected OrderID '55001', got '%s'", result.OrderID)
|
||||
}
|
||||
t.Logf("Sectigo issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Pending", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/ssl/v1/enroll":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55002}`))
|
||||
case "/ssl/v1/55002":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55002,"status":"Applied","commonName":"secure.example.com"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
CertType: 423,
|
||||
Term: 365,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "secure.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "secure.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.OrderID != "55002" {
|
||||
t.Errorf("Expected OrderID '55002', got '%s'", result.OrderID)
|
||||
}
|
||||
if result.CertPEM != "" {
|
||||
t.Error("CertPEM should be empty for pending order")
|
||||
}
|
||||
if result.Serial != "" {
|
||||
t.Error("Serial should be empty for pending order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"code":-14,"description":"Invalid CSR"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
CertType: 423,
|
||||
Term: 365,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: "invalid-csr",
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
testChainPEM, _ := generateTestCert(t)
|
||||
pemBundle := testCertPEM + testChainPEM
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/ssl/v1/55001":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`))
|
||||
case "/ssl/v1/collect/55001/pem":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(pemBundle))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "55001")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
if status.CertPEM == nil || *status.CertPEM == "" {
|
||||
t.Error("CertPEM should not be empty for issued order")
|
||||
}
|
||||
if status.Serial == nil || *status.Serial == "" {
|
||||
t.Error("Serial should not be empty for issued order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ssl/v1/55002" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55002,"status":"Applied"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "55002")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "pending" {
|
||||
t.Errorf("Expected status 'pending', got '%s'", status.Status)
|
||||
}
|
||||
if status.CertPEM != nil {
|
||||
t.Error("CertPEM should be nil for pending order")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Rejected", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ssl/v1/55003" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55003,"status":"Rejected"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "55003")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "failed" {
|
||||
t.Errorf("Expected status 'failed', got '%s'", status.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_CollectNotReady", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/ssl/v1/55004":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55004,"status":"Issued","commonName":"pending-collect.example.com"}`))
|
||||
case "/ssl/v1/collect/55004/pem":
|
||||
// Sectigo returns 400 with code -183 when cert not yet generated
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"code":-183,"description":"Certificate is not available"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "55004")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be treated as pending (cert approved but not yet generated)
|
||||
if status.Status != "pending" {
|
||||
t.Errorf("Expected status 'pending' for collect-not-ready, got '%s'", status.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate_NewOrder", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/ssl/v1/enroll":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55010}`))
|
||||
case "/ssl/v1/55010":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55010,"status":"Applied"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
CertType: 423,
|
||||
Term: 365,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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.OrderID == "" {
|
||||
t.Error("OrderID should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") && r.Method == http.MethodPost {
|
||||
// Verify auth headers
|
||||
if r.Header.Get("customerUri") == "" {
|
||||
t.Error("Missing customerUri header on revoke request")
|
||||
}
|
||||
if r.Header.Get("login") == "" {
|
||||
t.Error("Missing login header on revoke request")
|
||||
}
|
||||
if r.Header.Get("password") == "" {
|
||||
t.Error("Missing password header on revoke request")
|
||||
}
|
||||
|
||||
// Verify reason in body
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
if req["reason"] == nil {
|
||||
t.Error("Expected reason in revoke request body")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "55001",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"code":-1,"description":"Certificate not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "00000",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for revocation of nonexistent cert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: "https://cert-manager.com/api",
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatal("GetRenewalInfo should return nil for Sectigo")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DefaultTerm", func(t *testing.T) {
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
CertType: 423,
|
||||
// Term intentionally left as 0
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
// Verify the connector was created (the default is set in New())
|
||||
if connector == nil {
|
||||
t.Fatal("Connector should not be nil")
|
||||
}
|
||||
|
||||
// Verify via a request that uses the term
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ssl/v1/enroll" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
// Default term should be 365
|
||||
if term, ok := req["term"].(float64); ok {
|
||||
if int(term) != 365 {
|
||||
t.Errorf("Expected default term 365, got %d", int(term))
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55099}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ssl/v1/55099" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55099,"status":"Applied"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Reconfigure with test server URL
|
||||
config.BaseURL = srv.URL
|
||||
connector = sectigo.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate with default term failed: %v", err)
|
||||
}
|
||||
if result.OrderID == "" {
|
||||
t.Error("OrderID should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AuthHeaders_PresentOnAllRequests", func(t *testing.T) {
|
||||
requestCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
// Every single request must have all 3 auth headers
|
||||
if r.Header.Get("customerUri") != "verify-org" {
|
||||
t.Errorf("Request %d: expected customerUri 'verify-org', got '%s'", requestCount, r.Header.Get("customerUri"))
|
||||
}
|
||||
if r.Header.Get("login") != "verify-user" {
|
||||
t.Errorf("Request %d: expected login 'verify-user', got '%s'", requestCount, r.Header.Get("login"))
|
||||
}
|
||||
if r.Header.Get("password") != "verify-pass" {
|
||||
t.Errorf("Request %d: expected password 'verify-pass', got '%s'", requestCount, r.Header.Get("password"))
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/ssl/v1/enroll":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55050}`))
|
||||
case "/ssl/v1/55050":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"sslId":55050,"status":"Applied"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "verify-org",
|
||||
Login: "verify-user",
|
||||
Password: "verify-pass",
|
||||
OrgID: 12345,
|
||||
CertType: 423,
|
||||
Term: 365,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "auth-check.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "auth-check.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if requestCount < 2 {
|
||||
t.Errorf("Expected at least 2 requests (enroll + status), got %d", requestCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevocationReasonMapping", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"keyCompromise", "Compromised"},
|
||||
{"cessationOfOperation", "Cessation of Operation"},
|
||||
{"affiliationChanged", "Affiliation Changed"},
|
||||
{"superseded", "Superseded"},
|
||||
{"unspecified", "Unspecified"},
|
||||
{"unknown_reason", "Unspecified"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
var receivedReason string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
receivedReason = req["reason"].(string)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := §igo.Config{
|
||||
CustomerURI: "test-org",
|
||||
Login: "api-user",
|
||||
Password: "api-pass",
|
||||
OrgID: 12345,
|
||||
BaseURL: srv.URL,
|
||||
}
|
||||
connector := sectigo.New(config, logger)
|
||||
|
||||
reason := tt.input
|
||||
err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{
|
||||
Serial: "12345",
|
||||
Reason: &reason,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if receivedReason != tt.expected {
|
||||
t.Errorf("Expected reason '%s', got '%s'", tt.expected, receivedReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
// generateTestCSR creates a test CSR for the given common name.
|
||||
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", 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 {
|
||||
t.Fatalf("Failed to create CSR: %v", err)
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
}))
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse CSR: %v", err)
|
||||
}
|
||||
|
||||
return csr, csrPEM
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Package stepca — JWE decryption for step-ca provisioner keys.
|
||||
//
|
||||
// step-ca stores provisioner private keys as JWE-encrypted JSON files using:
|
||||
// - Algorithm: PBES2-HS256+A128KW (PBKDF2 key derivation + AES-128 Key Wrap)
|
||||
// - Encryption: A128GCM (AES-128 in GCM mode)
|
||||
//
|
||||
// This file implements just enough JWE to decrypt these files without requiring
|
||||
// an external JOSE library. Uses only stdlib + golang.org/x/crypto/pbkdf2.
|
||||
package stepca
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// jweJSON is the JWE JSON Serialization format used by step-ca provisioner keys.
|
||||
type jweJSON struct {
|
||||
Protected string `json:"protected"`
|
||||
EncryptedKey string `json:"encrypted_key"`
|
||||
IV string `json:"iv"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// jweHeader is the protected header inside a step-ca provisioner key JWE.
|
||||
type jweHeader struct {
|
||||
Alg string `json:"alg"` // "PBES2-HS256+A128KW"
|
||||
Enc string `json:"enc"` // "A128GCM"
|
||||
Cty string `json:"cty"` // "jwk+json"
|
||||
P2s string `json:"p2s"` // PBKDF2 salt (base64url)
|
||||
P2c int `json:"p2c"` // PBKDF2 iteration count
|
||||
}
|
||||
|
||||
// jwkEC is a minimal JWK representation for EC private keys.
|
||||
type jwkEC struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
D string `json:"d"`
|
||||
Kid string `json:"kid"`
|
||||
}
|
||||
|
||||
// decryptProvisionerKey decrypts a step-ca JWE-encrypted provisioner key file.
|
||||
// Returns the parsed ECDSA private key and the key ID (kid).
|
||||
func decryptProvisionerKey(jweData []byte, password string) (*ecdsa.PrivateKey, string, error) {
|
||||
// Parse JWE JSON
|
||||
var jwe jweJSON
|
||||
if err := json.Unmarshal(jweData, &jwe); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse JWE JSON: %w", err)
|
||||
}
|
||||
|
||||
// Decode protected header
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(jwe.Protected)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode JWE protected header: %w", err)
|
||||
}
|
||||
|
||||
var header jweHeader
|
||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse JWE header: %w", err)
|
||||
}
|
||||
|
||||
if header.Alg != "PBES2-HS256+A128KW" {
|
||||
return nil, "", fmt.Errorf("unsupported JWE algorithm: %s (expected PBES2-HS256+A128KW)", header.Alg)
|
||||
}
|
||||
if header.Enc != "A128GCM" && header.Enc != "A256GCM" {
|
||||
return nil, "", fmt.Errorf("unsupported JWE encryption: %s (expected A128GCM or A256GCM)", header.Enc)
|
||||
}
|
||||
|
||||
// Decode PBKDF2 salt
|
||||
p2sSalt, err := base64.RawURLEncoding.DecodeString(header.P2s)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode PBKDF2 salt: %w", err)
|
||||
}
|
||||
|
||||
// Decode encrypted key, IV, ciphertext, tag
|
||||
encryptedKey, err := base64.RawURLEncoding.DecodeString(jwe.EncryptedKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode encrypted key: %w", err)
|
||||
}
|
||||
|
||||
iv, err := base64.RawURLEncoding.DecodeString(jwe.IV)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode IV: %w", err)
|
||||
}
|
||||
|
||||
ciphertext, err := base64.RawURLEncoding.DecodeString(jwe.Ciphertext)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
tag, err := base64.RawURLEncoding.DecodeString(jwe.Tag)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode tag: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Derive Key Encryption Key (KEK) using PBKDF2
|
||||
// PBES2-HS256+A128KW: PBKDF2-SHA256, 16-byte derived key for AES-128 Key Wrap
|
||||
// The salt for PBKDF2 is: UTF8(alg) || 0x00 || p2s
|
||||
algBytes := []byte(header.Alg)
|
||||
salt := make([]byte, len(algBytes)+1+len(p2sSalt))
|
||||
copy(salt, algBytes)
|
||||
salt[len(algBytes)] = 0x00
|
||||
copy(salt[len(algBytes)+1:], p2sSalt)
|
||||
|
||||
kekSize := 16 // AES-128 for A128KW
|
||||
kek := pbkdf2.Key([]byte(password), salt, header.P2c, kekSize, sha256.New)
|
||||
|
||||
// Step 2: AES Key Unwrap (RFC 3394) to get the Content Encryption Key (CEK)
|
||||
cek, err := aesKeyUnwrap(kek, encryptedKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("AES key unwrap failed (wrong password?): %w", err)
|
||||
}
|
||||
|
||||
// Step 3: AES-GCM decrypt the payload
|
||||
// AAD = ASCII(BASE64URL(protected header))
|
||||
aad := []byte(jwe.Protected)
|
||||
|
||||
block, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// GCM expects ciphertext+tag concatenated
|
||||
sealed := append(ciphertext, tag...)
|
||||
plaintext, err := gcm.Open(nil, iv, sealed, aad)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("GCM decryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Parse the decrypted JWK
|
||||
var jwk jwkEC
|
||||
if err := json.Unmarshal(plaintext, &jwk); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse decrypted JWK: %w", err)
|
||||
}
|
||||
|
||||
if jwk.Kty != "EC" {
|
||||
return nil, "", fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.Kty)
|
||||
}
|
||||
|
||||
key, err := jwkToECDSA(&jwk)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return key, jwk.Kid, nil
|
||||
}
|
||||
|
||||
// jwkToECDSA converts a JWK EC key to an *ecdsa.PrivateKey.
|
||||
func jwkToECDSA(jwk *jwkEC) (*ecdsa.PrivateKey, error) {
|
||||
var curve elliptic.Curve
|
||||
switch jwk.Crv {
|
||||
case "P-256":
|
||||
curve = elliptic.P256()
|
||||
case "P-384":
|
||||
curve = elliptic.P384()
|
||||
case "P-521":
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported curve: %s", jwk.Crv)
|
||||
}
|
||||
|
||||
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWK x: %w", err)
|
||||
}
|
||||
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWK y: %w", err)
|
||||
}
|
||||
dBytes, err := base64.RawURLEncoding.DecodeString(jwk.D)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWK d: %w", err)
|
||||
}
|
||||
|
||||
key := &ecdsa.PrivateKey{
|
||||
PublicKey: ecdsa.PublicKey{
|
||||
Curve: curve,
|
||||
X: new(big.Int).SetBytes(xBytes),
|
||||
Y: new(big.Int).SetBytes(yBytes),
|
||||
},
|
||||
D: new(big.Int).SetBytes(dBytes),
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// aesKeyUnwrap implements AES Key Unwrap per RFC 3394.
|
||||
func aesKeyUnwrap(kek, ciphertext []byte) ([]byte, error) {
|
||||
if len(ciphertext)%8 != 0 || len(ciphertext) < 24 {
|
||||
return nil, fmt.Errorf("invalid ciphertext length for AES Key Unwrap: %d", len(ciphertext))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(kek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
n := (len(ciphertext) / 8) - 1 // number of 64-bit key data blocks
|
||||
|
||||
// Initialize
|
||||
a := make([]byte, 8)
|
||||
copy(a, ciphertext[:8])
|
||||
|
||||
r := make([][]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
r[i] = make([]byte, 8)
|
||||
copy(r[i], ciphertext[(i+1)*8:(i+2)*8])
|
||||
}
|
||||
|
||||
// Unwrap: 6 rounds
|
||||
buf := make([]byte, 16)
|
||||
for j := 5; j >= 0; j-- {
|
||||
for i := n; i >= 1; i-- {
|
||||
// A ^= (n*j + i) encoded as big-endian uint64
|
||||
t := uint64(n*j + i)
|
||||
tBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(tBytes, t)
|
||||
for k := 0; k < 8; k++ {
|
||||
a[k] ^= tBytes[k]
|
||||
}
|
||||
|
||||
// B = AES-1(KEK, A || R[i])
|
||||
copy(buf[:8], a)
|
||||
copy(buf[8:], r[i-1])
|
||||
block.Decrypt(buf, buf)
|
||||
|
||||
copy(a, buf[:8])
|
||||
copy(r[i-1], buf[8:])
|
||||
}
|
||||
}
|
||||
|
||||
// Check the integrity check value (must be 0xA6A6A6A6A6A6A6A6)
|
||||
defaultIV := []byte{0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6}
|
||||
for i := 0; i < 8; i++ {
|
||||
if a[i] != defaultIV[i] {
|
||||
return nil, fmt.Errorf("AES Key Unwrap integrity check failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate unwrapped key data
|
||||
result := make([]byte, 0, n*8)
|
||||
for i := 0; i < n; i++ {
|
||||
result = append(result, r[i]...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -74,17 +75,37 @@ type Connector struct {
|
||||
}
|
||||
|
||||
// New creates a new step-ca connector with the given configuration and logger.
|
||||
// If RootCertPath is set, the HTTP client will trust that CA certificate for TLS connections.
|
||||
// Otherwise, the system trust store is used (which works if setup-trust.sh has run).
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil && config.ValidityDays == 0 {
|
||||
config.ValidityDays = 90
|
||||
// Don't default ValidityDays — let step-ca use its own default duration.
|
||||
// Operators can explicitly set ValidityDays if their step-ca is configured
|
||||
// with longer max durations. A zero value means "omit from sign request."
|
||||
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Load custom root CA cert if provided
|
||||
if config != nil && config.RootCertPath != "" {
|
||||
rootPEM, err := os.ReadFile(config.RootCertPath)
|
||||
if err == nil {
|
||||
pool := x509.NewCertPool()
|
||||
if pool.AppendCertsFromPEM(rootPEM) {
|
||||
httpClient.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: pool,
|
||||
},
|
||||
}
|
||||
logger.Info("step-ca custom root CA loaded", "path", config.RootCertPath)
|
||||
}
|
||||
} else {
|
||||
logger.Warn("failed to read step-ca root cert, using system trust store", "path", config.RootCertPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +124,7 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
return fmt.Errorf("step-ca provisioner_name is required")
|
||||
}
|
||||
|
||||
if cfg.ValidityDays == 0 {
|
||||
cfg.ValidityDays = 90
|
||||
}
|
||||
// Don't default ValidityDays — 0 means "let step-ca use its own default duration"
|
||||
|
||||
// Check CA health
|
||||
healthURL := cfg.CAURL + "/health"
|
||||
@@ -174,15 +193,18 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
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)
|
||||
|
||||
// Build the sign request.
|
||||
// When ValidityDays is 0 (default), omit NotBefore/NotAfter so step-ca uses its
|
||||
// own default duration (typically 24h). The signRequest struct has omitempty on
|
||||
// both time fields, so zero-value time.Time{} gets stripped from the JSON.
|
||||
signReq := signRequest{
|
||||
CsrPEM: request.CSRPEM,
|
||||
OTT: ott,
|
||||
NotBefore: now,
|
||||
NotAfter: notAfter,
|
||||
CsrPEM: request.CSRPEM,
|
||||
OTT: ott,
|
||||
}
|
||||
if c.config.ValidityDays > 0 {
|
||||
now := time.Now()
|
||||
signReq.NotBefore = now
|
||||
signReq.NotAfter = now.AddDate(0, 0, c.config.ValidityDays)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(signReq)
|
||||
@@ -318,39 +340,80 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The JWT is signed with the provisioner's private key (loaded from the encrypted JWE file
|
||||
// at ProvisionerKeyPath and decrypted with ProvisionerPassword).
|
||||
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
|
||||
var key *ecdsa.PrivateKey
|
||||
var kid string
|
||||
|
||||
if c.config.ProvisionerKeyPath != "" {
|
||||
// Production: load and decrypt the real provisioner key from disk
|
||||
var err error
|
||||
key, kid, err = c.loadProvisionerKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load provisioner key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fallback: generate an ephemeral key (for testing or when key path not configured).
|
||||
// This won't authenticate with a real step-ca server, but allows the connector
|
||||
// to function against mock servers in tests.
|
||||
c.logger.Warn("no provisioner key path configured, using ephemeral key (will not work with real step-ca)")
|
||||
var err error
|
||||
key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate ephemeral key: %w", err)
|
||||
}
|
||||
kid = "ephemeral"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// step-ca expects: aud = <ca-url>/1.0/sign (the sign endpoint audience)
|
||||
claims := map[string]interface{}{
|
||||
"sub": subject,
|
||||
"iss": c.config.ProvisionerName,
|
||||
"aud": c.config.CAURL + "/sign",
|
||||
"aud": c.config.CAURL + "/1.0/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
|
||||
"sha": kid, // step-ca uses this to look up the provisioner by key fingerprint
|
||||
}
|
||||
|
||||
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 signJWTWithKID(claims, key, kid)
|
||||
}
|
||||
|
||||
// loadProvisionerKey loads and decrypts the step-ca provisioner key from disk.
|
||||
// Returns the ECDSA private key and the key ID (JWK thumbprint).
|
||||
func (c *Connector) loadProvisionerKey() (*ecdsa.PrivateKey, string, error) {
|
||||
if c.config.ProvisionerKeyPath == "" {
|
||||
return nil, "", fmt.Errorf("provisioner_key_path is required for step-ca JWK authentication")
|
||||
}
|
||||
|
||||
return signJWT(claims, key)
|
||||
jweData, err := os.ReadFile(c.config.ProvisionerKeyPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read provisioner key file %s: %w", c.config.ProvisionerKeyPath, err)
|
||||
}
|
||||
|
||||
password := c.config.ProvisionerPassword
|
||||
if password == "" {
|
||||
return nil, "", fmt.Errorf("provisioner_password is required to decrypt the provisioner key")
|
||||
}
|
||||
|
||||
key, kid, err := decryptProvisionerKey(jweData, password)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decrypt provisioner key: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("provisioner key loaded and decrypted",
|
||||
"key_path", c.config.ProvisionerKeyPath,
|
||||
"kid", kid)
|
||||
|
||||
return key, kid, nil
|
||||
}
|
||||
|
||||
// generateJTI creates a unique JWT ID.
|
||||
@@ -360,14 +423,21 @@ func generateJTI() string {
|
||||
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
|
||||
// signJWTWithKID creates an ES256 JWT with a key ID in the header.
|
||||
func signJWTWithKID(claims map[string]interface{}, key *ecdsa.PrivateKey, kid string) (string, error) {
|
||||
// Header with kid so step-ca can look up the provisioner
|
||||
header := map[string]string{
|
||||
"alg": "ES256",
|
||||
"typ": "JWT",
|
||||
"kid": kid,
|
||||
}
|
||||
|
||||
return signJWTRaw(claims, key, header)
|
||||
}
|
||||
|
||||
// signJWTRaw creates an ES256 JWT from the given claims and header.
|
||||
func signJWTRaw(claims map[string]interface{}, key *ecdsa.PrivateKey, header map[string]string) (string, error) {
|
||||
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// Package vault implements the issuer.Connector interface for HashiCorp Vault PKI
|
||||
// secrets engine.
|
||||
//
|
||||
// Vault PKI provides a full-featured private CA with certificate signing, revocation,
|
||||
// CRL, and OCSP capabilities. This connector uses the Vault HTTP API to sign CSRs
|
||||
// via the /v1/{mount}/sign/{role} endpoint, authenticated with a Vault token.
|
||||
//
|
||||
// Vault issues certificates synchronously (like step-ca), so GetOrderStatus always
|
||||
// returns "completed". CRL and OCSP are delegated to Vault's own endpoints.
|
||||
//
|
||||
// Authentication: Vault token via X-Vault-Token header.
|
||||
//
|
||||
// Vault API used:
|
||||
//
|
||||
// GET /v1/sys/health - Health check
|
||||
// POST /v1/{mount}/sign/{role} - Sign CSR
|
||||
// POST /v1/{mount}/revoke - Revoke certificate
|
||||
// GET /v1/{mount}/ca/pem - Get CA certificate
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the Vault PKI issuer connector configuration.
|
||||
type Config struct {
|
||||
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
|
||||
// Required. Set via CERTCTL_VAULT_ADDR environment variable.
|
||||
Addr string `json:"addr"`
|
||||
|
||||
// Token is the Vault token for authentication.
|
||||
// Required. Set via CERTCTL_VAULT_TOKEN environment variable.
|
||||
Token string `json:"token"`
|
||||
|
||||
// Mount is the PKI secrets engine mount path.
|
||||
// Default: "pki". Set via CERTCTL_VAULT_MOUNT environment variable.
|
||||
Mount string `json:"mount"`
|
||||
|
||||
// Role is the PKI role name used for signing certificates.
|
||||
// Required. Set via CERTCTL_VAULT_ROLE environment variable.
|
||||
Role string `json:"role"`
|
||||
|
||||
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
|
||||
// Default: "8760h". Set via CERTCTL_VAULT_TTL environment variable.
|
||||
TTL string `json:"ttl"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for Vault PKI.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new Vault PKI connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil {
|
||||
if config.Mount == "" {
|
||||
config.Mount = "pki"
|
||||
}
|
||||
if config.TTL == "" {
|
||||
config.TTL = "8760h"
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// vaultResponse is the standard Vault API response wrapper.
|
||||
type vaultResponse struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// signData holds the data returned from the /sign endpoint.
|
||||
type signData struct {
|
||||
Certificate string `json:"certificate"`
|
||||
IssuingCA string `json:"issuing_ca"`
|
||||
CAChain []string `json:"ca_chain"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Expiration int64 `json:"expiration"`
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the Vault configuration is valid and the server 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 Vault config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Addr == "" {
|
||||
return fmt.Errorf("Vault addr is required")
|
||||
}
|
||||
|
||||
if cfg.Token == "" {
|
||||
return fmt.Errorf("Vault token is required")
|
||||
}
|
||||
|
||||
if cfg.Role == "" {
|
||||
return fmt.Errorf("Vault role is required")
|
||||
}
|
||||
|
||||
if cfg.Mount == "" {
|
||||
cfg.Mount = "pki"
|
||||
}
|
||||
if cfg.TTL == "" {
|
||||
cfg.TTL = "8760h"
|
||||
}
|
||||
|
||||
// Health check
|
||||
healthURL := cfg.Addr + "/v1/sys/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("Vault not reachable at %s: %w", cfg.Addr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Vault health returns 200 for initialized+unsealed, 429 for standby, 472 for DR secondary,
|
||||
// 473 for perf standby, 501 for uninitialized, 503 for sealed
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusTooManyRequests {
|
||||
return fmt.Errorf("Vault health check returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Vault PKI configuration validated",
|
||||
"addr", cfg.Addr,
|
||||
"mount", cfg.Mount,
|
||||
"role", cfg.Role)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate submits a CSR to Vault PKI for signing.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing Vault PKI issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// Build the sign request body
|
||||
signBody := map[string]interface{}{
|
||||
"csr": request.CSRPEM,
|
||||
"common_name": request.CommonName,
|
||||
"ttl": c.config.TTL,
|
||||
}
|
||||
|
||||
if len(request.SANs) > 0 {
|
||||
signBody["alt_names"] = strings.Join(request.SANs, ",")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(signBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal sign request: %w", err)
|
||||
}
|
||||
|
||||
// POST /v1/{mount}/sign/{role}
|
||||
signURL := fmt.Sprintf("%s/v1/%s/sign/%s", c.config.Addr, c.config.Mount, c.config.Role)
|
||||
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")
|
||||
req.Header.Set("X-Vault-Token", c.config.Token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Vault 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.StatusOK {
|
||||
var vaultResp vaultResponse
|
||||
if jsonErr := json.Unmarshal(respBody, &vaultResp); jsonErr == nil && len(vaultResp.Errors) > 0 {
|
||||
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, strings.Join(vaultResp.Errors, "; "))
|
||||
}
|
||||
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Parse the Vault response
|
||||
var vaultResp vaultResponse
|
||||
if err := json.Unmarshal(respBody, &vaultResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Vault response: %w", err)
|
||||
}
|
||||
|
||||
var data signData
|
||||
if err := json.Unmarshal(vaultResp.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Vault sign data: %w", err)
|
||||
}
|
||||
|
||||
if data.Certificate == "" {
|
||||
return nil, fmt.Errorf("no certificate in Vault sign response")
|
||||
}
|
||||
|
||||
// Parse the leaf certificate to extract metadata
|
||||
certPEM := data.Certificate
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM from Vault")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
// Build chain PEM from ca_chain or issuing_ca
|
||||
var chainPEM string
|
||||
if len(data.CAChain) > 0 {
|
||||
chainPEM = strings.Join(data.CAChain, "\n")
|
||||
} else if data.IssuingCA != "" {
|
||||
chainPEM = data.IssuingCA
|
||||
}
|
||||
|
||||
// Normalize serial: Vault uses colon-separated hex (e.g., "aa:bb:cc"), convert to plain string
|
||||
serial := normalizeSerial(data.SerialNumber)
|
||||
|
||||
orderID := fmt.Sprintf("vault-%s", serial)
|
||||
|
||||
c.logger.Info("Vault PKI certificate issued",
|
||||
"common_name", request.CommonName,
|
||||
"serial", serial,
|
||||
"not_after", cert.NotAfter)
|
||||
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
OrderID: orderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews a certificate by creating a new signing request.
|
||||
// For Vault PKI, renewal is functionally identical to issuance (new cert signed from CSR).
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing Vault PKI 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,
|
||||
EKUs: request.EKUs,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at Vault PKI.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing Vault PKI revocation request", "serial", request.Serial)
|
||||
|
||||
revokeBody := map[string]interface{}{
|
||||
"serial_number": request.Serial,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(revokeBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||
}
|
||||
|
||||
revokeURL := fmt.Sprintf("%s/v1/%s/revoke", c.config.Addr, c.config.Mount)
|
||||
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")
|
||||
req.Header.Set("X-Vault-Token", c.config.Token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Vault revoke request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("Vault revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
c.logger.Info("Vault PKI certificate revoked", "serial", request.Serial)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus returns the status of a Vault PKI order.
|
||||
// Vault 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
|
||||
}
|
||||
|
||||
// GenerateCRL is not supported because Vault serves CRL directly at /v1/{mount}/crl.
|
||||
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||
return nil, fmt.Errorf("Vault serves CRL directly at /v1/%s/crl; use Vault's endpoint", c.config.Mount)
|
||||
}
|
||||
|
||||
// SignOCSPResponse is not supported because Vault serves OCSP directly at /v1/{mount}/ocsp.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("Vault serves OCSP directly at /v1/%s/ocsp; use Vault's endpoint", c.config.Mount)
|
||||
}
|
||||
|
||||
// GetCACertPEM retrieves the CA certificate from Vault PKI.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
caURL := fmt.Sprintf("%s/v1/%s/ca/pem", c.config.Addr, c.config.Mount)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, caURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create CA cert request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Vault-Token", c.config.Token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Vault CA cert request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Vault CA cert returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read CA cert response: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as Vault does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// normalizeSerial converts Vault's colon-separated hex serial (e.g., "aa:bb:cc:dd")
|
||||
// to a plain string representation suitable for storage.
|
||||
func normalizeSerial(serial string) string {
|
||||
return strings.ReplaceAll(serial, ":", "-")
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,527 @@
|
||||
package vault_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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||
)
|
||||
|
||||
func TestVaultConnector(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) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/sys/health" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"initialized":true,"sealed":false,"standby":false}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token-12345",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
TTL: "8760h",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingAddr", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Token: "s.test-token",
|
||||
Role: "web-certs",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing addr")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "addr is required") {
|
||||
t.Errorf("Expected addr required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingToken", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Role: "web-certs",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token is required") {
|
||||
t.Errorf("Expected token required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingRole", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Token: "s.test-token",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing role")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "role is required") {
|
||||
t.Errorf("Expected role required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_UnreachableVault", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Addr: "http://localhost:19999",
|
||||
Token: "s.test-token",
|
||||
Role: "web-certs",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unreachable Vault")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"initialized":true,"sealed":false}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
// Verify auth header
|
||||
if r.Header.Get("X-Vault-Token") != "s.test-token" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":["permission denied"]}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := fmt.Sprintf(`{
|
||||
"data": {
|
||||
"certificate": %q,
|
||||
"issuing_ca": %q,
|
||||
"ca_chain": [%q],
|
||||
"serial_number": "aa:bb:cc:dd:ee:ff",
|
||||
"expiration": 1893456000
|
||||
}
|
||||
}`, testCertPEM, testCertPEM, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
TTL: "8760h",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.example.com",
|
||||
SANs: []string{"app.example.com", "www.example.com"},
|
||||
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")
|
||||
}
|
||||
if !strings.HasPrefix(result.OrderID, "vault-") {
|
||||
t.Errorf("Expected OrderID to start with 'vault-', got '%s'", result.OrderID)
|
||||
}
|
||||
// Verify serial normalization (colons replaced with dashes)
|
||||
if strings.Contains(result.Serial, ":") {
|
||||
t.Errorf("Serial should not contain colons, got '%s'", result.Serial)
|
||||
}
|
||||
t.Logf("Vault issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":["invalid CSR"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid CSR") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Forbidden", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":["permission denied"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.bad-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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 forbidden response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate_Success", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := fmt.Sprintf(`{
|
||||
"data": {
|
||||
"certificate": %q,
|
||||
"issuing_ca": %q,
|
||||
"serial_number": "11:22:33:44:55:66",
|
||||
"expiration": 1893456000
|
||||
}
|
||||
}`, testCertPEM, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "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 "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/v1/pki/revoke":
|
||||
// Verify token
|
||||
if r.Header.Get("X-Vault-Token") == "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":{"revocation_time":1234567890}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "aa-bb-cc-dd-ee-ff",
|
||||
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 "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/v1/pki/revoke":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":["serial not found"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "00-00-00-00",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACertPEM_Success", func(t *testing.T) {
|
||||
expectedPEM := "-----BEGIN CERTIFICATE-----\nTESTCA\n-----END CERTIFICATE-----\n"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/pki/ca/pem":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(expectedPEM))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
caPEM, err := connector.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||
}
|
||||
|
||||
if caPEM != expectedPEM {
|
||||
t.Errorf("Expected CA PEM %q, got %q", expectedPEM, caPEM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Synchronous", func(t *testing.T) {
|
||||
config := &vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "vault-aa-bb-cc")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
if status.OrderID != "vault-aa-bb-cc" {
|
||||
t.Errorf("Expected OrderID 'vault-aa-bb-cc', got '%s'", status.OrderID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||
config := &vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatal("GetRenewalInfo should return nil for Vault")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// generateTestCSR creates a test CSR for the given common name.
|
||||
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", 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 {
|
||||
t.Fatalf("Failed to create CSR: %v", err)
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
}))
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse CSR: %v", err)
|
||||
}
|
||||
|
||||
return csr, csrPEM
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package envoy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the Envoy deployment target configuration.
|
||||
// Envoy uses file-based certificate delivery — the agent writes cert/key files
|
||||
// to a directory that Envoy watches via its SDS (Secret Discovery Service)
|
||||
// file-based configuration or static filename references in the bootstrap config.
|
||||
type Config struct {
|
||||
CertDir string `json:"cert_dir"` // Directory where Envoy watches for cert files (required)
|
||||
CertFilename string `json:"cert_filename"` // Filename for certificate (default: cert.pem)
|
||||
KeyFilename string `json:"key_filename"` // Filename for private key (default: key.pem)
|
||||
ChainFilename string `json:"chain_filename"` // Optional filename for chain (if set, chain written separately)
|
||||
SDSConfig bool `json:"sds_config"` // If true, write an SDS discovery JSON file for file-based SDS
|
||||
}
|
||||
|
||||
// SDSResource represents an Envoy SDS tls_certificate resource for file-based SDS.
|
||||
// This matches Envoy's expected format for file-based Secret Discovery Service.
|
||||
type SDSResource struct {
|
||||
Resources []SDSTLSCertificate `json:"resources"`
|
||||
}
|
||||
|
||||
// SDSTLSCertificate represents a single SDS tls_certificate entry.
|
||||
type SDSTLSCertificate struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
TLSCertificate TLSCertificate `json:"tls_certificate"`
|
||||
}
|
||||
|
||||
// TLSCertificate contains the file paths for cert and key in Envoy's SDS format.
|
||||
type TLSCertificate struct {
|
||||
CertificateChain DataSource `json:"certificate_chain"`
|
||||
PrivateKey DataSource `json:"private_key"`
|
||||
}
|
||||
|
||||
// DataSource represents an Envoy data source pointing to a file path.
|
||||
type DataSource struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Envoy proxy servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
// Envoy watches the configured directory via its file-based SDS or static config
|
||||
// and automatically picks up certificate changes without an explicit reload.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Envoy 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 the certificate directory is configured and 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 Envoy config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CertDir == "" {
|
||||
return fmt.Errorf("Envoy cert_dir is required")
|
||||
}
|
||||
|
||||
// Default filenames if not provided
|
||||
if cfg.CertFilename == "" {
|
||||
cfg.CertFilename = "cert.pem"
|
||||
}
|
||||
if cfg.KeyFilename == "" {
|
||||
cfg.KeyFilename = "key.pem"
|
||||
}
|
||||
|
||||
// Validate filenames don't contain path separators (prevent path traversal)
|
||||
if strings.Contains(cfg.CertFilename, "/") || strings.Contains(cfg.CertFilename, "\\") {
|
||||
return fmt.Errorf("Envoy cert_filename must not contain path separators")
|
||||
}
|
||||
if strings.Contains(cfg.KeyFilename, "/") || strings.Contains(cfg.KeyFilename, "\\") {
|
||||
return fmt.Errorf("Envoy key_filename must not contain path separators")
|
||||
}
|
||||
if cfg.ChainFilename != "" && (strings.Contains(cfg.ChainFilename, "/") || strings.Contains(cfg.ChainFilename, "\\")) {
|
||||
return fmt.Errorf("Envoy chain_filename must not contain path separators")
|
||||
}
|
||||
|
||||
c.logger.Info("validating Envoy configuration",
|
||||
"cert_dir", cfg.CertDir,
|
||||
"cert_filename", cfg.CertFilename,
|
||||
"key_filename", cfg.KeyFilename,
|
||||
"chain_filename", cfg.ChainFilename,
|
||||
"sds_config", cfg.SDSConfig)
|
||||
|
||||
// Verify directory exists and is writable
|
||||
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("Envoy cert directory does not exist: %s", cfg.CertDir)
|
||||
}
|
||||
|
||||
// Try to write a test file to verify directory is writable
|
||||
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
return fmt.Errorf("Envoy cert directory is not writable: %s (%w)", cfg.CertDir, err)
|
||||
}
|
||||
os.Remove(testFile)
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Envoy configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate writes the certificate and key files to the configured directory.
|
||||
// Envoy watches this directory via file-based SDS or static config references
|
||||
// and automatically picks up changes without requiring a reload command.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Write certificate (+ chain if chain_filename not set) to cert_filename with mode 0644
|
||||
// 2. Write private key to key_filename with mode 0600
|
||||
// 3. If chain_filename set and chain provided, write chain separately with mode 0644
|
||||
// 4. If sds_config is true, write SDS JSON file pointing to cert/key paths
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to Envoy",
|
||||
"cert_dir", c.config.CertDir,
|
||||
"cert_filename", c.config.CertFilename,
|
||||
"key_filename", c.config.KeyFilename)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFilename)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFilename)
|
||||
|
||||
// Build certificate data: if chain_filename is set, write chain separately;
|
||||
// otherwise append chain to cert file (standard fullchain behavior)
|
||||
certData := request.CertPEM + "\n"
|
||||
if request.ChainPEM != "" && c.config.ChainFilename == "" {
|
||||
certData += request.ChainPEM + "\n"
|
||||
}
|
||||
|
||||
// Write certificate with mode 0644 (readable by Envoy process)
|
||||
if err := os.WriteFile(certPath, []byte(certData), 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: certPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Write private key with secure permissions (0600: rw-------)
|
||||
if request.KeyPEM != "" {
|
||||
if err := os.WriteFile(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: keyPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Write chain separately if chain_filename is configured
|
||||
if c.config.ChainFilename != "" && request.ChainPEM != "" {
|
||||
chainPath := filepath.Join(c.config.CertDir, c.config.ChainFilename)
|
||||
if err := os.WriteFile(chainPath, []byte(request.ChainPEM+"\n"), 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: chainPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Write SDS JSON file if configured
|
||||
if c.config.SDSConfig {
|
||||
if err := c.writeSDSConfig(); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write SDS config: %v", err)
|
||||
c.logger.Error("SDS config deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: certPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
c.logger.Info("certificate deployed to Envoy successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", certPath,
|
||||
"key_path", keyPath,
|
||||
"sds_config", c.config.SDSConfig)
|
||||
|
||||
metadata := map[string]string{
|
||||
"cert_path": certPath,
|
||||
"key_path": keyPath,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
}
|
||||
if c.config.SDSConfig {
|
||||
metadata["sds_config_path"] = filepath.Join(c.config.CertDir, "sds.json")
|
||||
}
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: certPath,
|
||||
DeploymentID: fmt.Sprintf("envoy-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed to Envoy (file-based SDS will auto-reload)",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeSDSConfig writes an Envoy SDS JSON file that references the cert/key file paths.
|
||||
// This file is consumed by Envoy's file-based SDS provider (path_config_source).
|
||||
func (c *Connector) writeSDSConfig() error {
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFilename)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFilename)
|
||||
|
||||
sdsResource := SDSResource{
|
||||
Resources: []SDSTLSCertificate{
|
||||
{
|
||||
Type: "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret",
|
||||
Name: "server_cert",
|
||||
TLSCertificate: TLSCertificate{
|
||||
CertificateChain: DataSource{Filename: certPath},
|
||||
PrivateKey: DataSource{Filename: keyPath},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sdsJSON, err := json.MarshalIndent(sdsResource, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal SDS config: %w", err)
|
||||
}
|
||||
|
||||
sdsPath := filepath.Join(c.config.CertDir, "sds.json")
|
||||
if err := os.WriteFile(sdsPath, sdsJSON, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write SDS config file: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("SDS config file written", "path", sdsPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate files are readable.
|
||||
// It checks that both the certificate and key files exist and are accessible.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating Envoy deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFilename)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFilename)
|
||||
|
||||
// Verify certificate file exists and is readable
|
||||
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("certificate file not found: %s", certPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: certPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify key file exists and is readable
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("private key file not found: %s", keyPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: keyPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("Envoy deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: certPath,
|
||||
Message: "Certificate and key files accessible",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": certPath,
|
||||
"key_path": keyPath,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package envoy_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/envoy"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFilename: "cert.pem",
|
||||
KeyFilename: "key.pem",
|
||||
}
|
||||
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
connector := envoy.New(&envoy.Config{}, testLogger())
|
||||
if err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`)); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_MissingCertDir(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := envoy.Config{CertFilename: "cert.pem", KeyFilename: "key.pem"}
|
||||
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err == nil {
|
||||
t.Fatal("expected error for missing cert_dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_DirectoryNotExists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := envoy.Config{CertDir: "/nonexistent/directory"}
|
||||
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err == nil {
|
||||
t.Fatal("expected error for non-existent directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_PathTraversal_CertFilename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
cfg := envoy.Config{CertDir: tmpDir, CertFilename: "../../../etc/passwd"}
|
||||
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err == nil {
|
||||
t.Fatal("expected error for path traversal in cert_filename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_PathTraversal_KeyFilename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
cfg := envoy.Config{CertDir: tmpDir, KeyFilename: "sub/key.pem"}
|
||||
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err == nil {
|
||||
t.Fatal("expected error for path traversal in key_filename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_PathTraversal_ChainFilename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
cfg := envoy.Config{CertDir: tmpDir, ChainFilename: "../chain.pem"}
|
||||
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err == nil {
|
||||
t.Fatal("expected error for path traversal in chain_filename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateConfig_DefaultFilenames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
cfg := envoy.Config{CertDir: tmpDir} // No filenames — should use defaults
|
||||
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig with defaults failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_DeployCertificate_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nCAcert...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify cert file was created with chain appended (no chain_filename set)
|
||||
certData, err := os.ReadFile(filepath.Join(tmpDir, "cert.pem"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
if got := string(certData); got != "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nCAcert...\n-----END CERTIFICATE-----\n" {
|
||||
t.Fatalf("cert content mismatch: got %q", got)
|
||||
}
|
||||
|
||||
// Verify key file created with correct permissions
|
||||
keyPath := filepath.Join(tmpDir, "key.pem")
|
||||
keyInfo, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("key file not found: %v", err)
|
||||
}
|
||||
if perms := keyInfo.Mode().Perm(); perms != 0600 {
|
||||
t.Fatalf("key permissions are %o, expected 0600", perms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_DeployCertificate_WithoutChain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Cert file should only contain the leaf cert (no chain)
|
||||
certData, _ := os.ReadFile(filepath.Join(tmpDir, "cert.pem"))
|
||||
if got := string(certData); got != "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n" {
|
||||
t.Fatalf("cert content mismatch: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_DeployCertificate_SeparateChainFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFilename: "cert.pem",
|
||||
KeyFilename: "key.pem",
|
||||
ChainFilename: "chain.pem",
|
||||
}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nleaf...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nCA...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Cert file should only contain leaf (chain is separate)
|
||||
certData, _ := os.ReadFile(filepath.Join(tmpDir, "cert.pem"))
|
||||
if got := string(certData); got != "-----BEGIN CERTIFICATE-----\nleaf...\n-----END CERTIFICATE-----\n" {
|
||||
t.Fatalf("cert should not contain chain when chain_filename is set: got %q", got)
|
||||
}
|
||||
|
||||
// Chain file should exist with chain data
|
||||
chainData, err := os.ReadFile(filepath.Join(tmpDir, "chain.pem"))
|
||||
if err != nil {
|
||||
t.Fatalf("chain file not found: %v", err)
|
||||
}
|
||||
if got := string(chainData); got != "-----BEGIN CERTIFICATE-----\nCA...\n-----END CERTIFICATE-----\n" {
|
||||
t.Fatalf("chain content mismatch: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_DeployCertificate_WithSDSConfig(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFilename: "cert.pem",
|
||||
KeyFilename: "key.pem",
|
||||
SDSConfig: true,
|
||||
}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify SDS JSON file was created
|
||||
sdsPath := filepath.Join(tmpDir, "sds.json")
|
||||
sdsData, err := os.ReadFile(sdsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("SDS config file not found: %v", err)
|
||||
}
|
||||
|
||||
// Parse and verify SDS JSON structure
|
||||
var sdsResource envoy.SDSResource
|
||||
if err := json.Unmarshal(sdsData, &sdsResource); err != nil {
|
||||
t.Fatalf("invalid SDS JSON: %v", err)
|
||||
}
|
||||
|
||||
if len(sdsResource.Resources) != 1 {
|
||||
t.Fatalf("expected 1 SDS resource, got %d", len(sdsResource.Resources))
|
||||
}
|
||||
|
||||
res := sdsResource.Resources[0]
|
||||
if res.Type != "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret" {
|
||||
t.Fatalf("wrong @type: %s", res.Type)
|
||||
}
|
||||
if res.Name != "server_cert" {
|
||||
t.Fatalf("wrong name: %s", res.Name)
|
||||
}
|
||||
|
||||
expectedCertPath := filepath.Join(tmpDir, "cert.pem")
|
||||
expectedKeyPath := filepath.Join(tmpDir, "key.pem")
|
||||
if res.TLSCertificate.CertificateChain.Filename != expectedCertPath {
|
||||
t.Fatalf("cert chain path mismatch: got %s, want %s", res.TLSCertificate.CertificateChain.Filename, expectedCertPath)
|
||||
}
|
||||
if res.TLSCertificate.PrivateKey.Filename != expectedKeyPath {
|
||||
t.Fatalf("private key path mismatch: got %s, want %s", res.TLSCertificate.PrivateKey.Filename, expectedKeyPath)
|
||||
}
|
||||
|
||||
// Verify SDS path is in metadata
|
||||
if result.Metadata["sds_config_path"] != sdsPath {
|
||||
t.Fatalf("SDS config path not in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_DeployCertificate_WriteError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := envoy.Config{
|
||||
CertDir: "/root/envoy/certs",
|
||||
CertFilename: "cert.pem",
|
||||
KeyFilename: "key.pem",
|
||||
}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for write failure")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("deployment should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateDeployment_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// First deploy
|
||||
deployReq := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
connector.DeployCertificate(ctx, deployReq)
|
||||
|
||||
// Then validate
|
||||
validateReq := target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
}
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, validateReq)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Fatalf("validation should succeed, got: %s", result.Message)
|
||||
}
|
||||
if result.Serial != "123456" {
|
||||
t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateDeployment_CertFileNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
validateReq := target.ValidationRequest{CertificateID: "mc-test", Serial: "123456"}
|
||||
result, err := connector.ValidateDeployment(ctx, validateReq)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing certificate file")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("validation should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvoyConnector_ValidateDeployment_KeyFileNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"}
|
||||
connector := envoy.New(&cfg, testLogger())
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// Write cert but not key
|
||||
os.WriteFile(filepath.Join(tmpDir, "cert.pem"), []byte("cert"), 0644)
|
||||
|
||||
validateReq := target.ValidationRequest{CertificateID: "mc-test", Serial: "123456"}
|
||||
result, err := connector.ValidateDeployment(ctx, validateReq)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing key file")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("validation should fail")
|
||||
}
|
||||
}
|
||||
@@ -2,101 +2,241 @@ package iis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// Config represents the IIS deployment target configuration.
|
||||
// This configuration is for Windows agents that manage IIS servers.
|
||||
// Supports two modes:
|
||||
// - "local" (default): runs PowerShell locally on a Windows agent
|
||||
// - "winrm": connects to a remote Windows server via WinRM (proxy agent pattern)
|
||||
type Config struct {
|
||||
Hostname string `json:"hostname"` // Target hostname or IP
|
||||
SiteName string `json:"site_name"` // IIS site name (e.g., "Default Web Site")
|
||||
CertStore string `json:"cert_store"` // Windows cert store (e.g., "My", "WebHosting")
|
||||
BindingInfo string `json:"binding_info"` // Binding info (e.g., "*.example.com")
|
||||
Port int `json:"port"` // HTTPS port (default 443)
|
||||
SNI bool `json:"sni"` // Enable Server Name Indication
|
||||
IPAddress string `json:"ip_address"` // Bind to specific IP (default "*")
|
||||
Mode string `json:"mode"` // "local" (default) or "winrm"
|
||||
|
||||
// WinRM settings (only used when Mode is "winrm")
|
||||
WinRM WinRMConfig `json:"winrm"`
|
||||
}
|
||||
|
||||
// PowerShellExecutor abstracts PowerShell command execution for testability.
|
||||
// On real Windows deployments, the realExecutor calls powershell.exe directly.
|
||||
// Tests inject a mock executor to verify command construction without Windows.
|
||||
type PowerShellExecutor interface {
|
||||
Execute(ctx context.Context, script string) (string, error)
|
||||
}
|
||||
|
||||
// realExecutor calls powershell.exe on the local system.
|
||||
type realExecutor struct{}
|
||||
|
||||
func (e *realExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for IIS (Internet Information Services).
|
||||
// This connector runs on Windows agents and manages certificate deployment via IIS.
|
||||
// This connector runs on Windows agents and manages certificate deployment via PowerShell.
|
||||
//
|
||||
// IIS certificate management requires:
|
||||
// - Windows Server with IIS installed
|
||||
// - PowerShell execution available
|
||||
// - Administrative privileges
|
||||
// - Windows Server with IIS installed
|
||||
// - PowerShell execution available
|
||||
// - Administrative privileges
|
||||
//
|
||||
// TODO: Implement actual PowerShell command execution for:
|
||||
// - Certificate import: Import-PfxCertificate
|
||||
// - IIS binding update: New-WebBinding, Set-WebBinding
|
||||
// - Validation: Get-WebBinding
|
||||
// Deployment flow:
|
||||
// 1. Convert PEM cert+key to PFX (PKCS#12) format via go-pkcs12
|
||||
// 2. Import PFX to Windows certificate store via Import-PfxCertificate
|
||||
// 3. Compute SHA-1 thumbprint (IIS certificate identifier)
|
||||
// 4. Update IIS HTTPS binding via New-WebBinding + AddSslCertificate
|
||||
// 5. Verify binding is active via Get-WebBinding
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
executor PowerShellExecutor
|
||||
}
|
||||
|
||||
// New creates a new IIS target connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
// In "local" mode (default), uses the real PowerShell executor.
|
||||
// In "winrm" mode, creates a WinRM client for remote execution.
|
||||
func New(config *Config, logger *slog.Logger) (*Connector, error) {
|
||||
mode := config.Mode
|
||||
if mode == "" {
|
||||
mode = "local"
|
||||
}
|
||||
|
||||
var executor PowerShellExecutor
|
||||
switch mode {
|
||||
case "local":
|
||||
executor = &realExecutor{}
|
||||
case "winrm":
|
||||
winrmExec, err := newWinRMExecutor(&config.WinRM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize WinRM executor: %w", err)
|
||||
}
|
||||
executor = winrmExec
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported IIS connector mode %q (must be 'local' or 'winrm')", mode)
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
config: config,
|
||||
logger: logger,
|
||||
executor: executor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithExecutor creates a new IIS target connector with an injected executor.
|
||||
// Used in tests to mock PowerShell execution on non-Windows platforms.
|
||||
func NewWithExecutor(config *Config, logger *slog.Logger, executor PowerShellExecutor) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
// validIISName matches safe IIS site names and cert store names.
|
||||
// Allows alphanumeric, spaces, underscores, hyphens, and dots.
|
||||
var validIISName = regexp.MustCompile(`^[a-zA-Z0-9 _\-\.]+$`)
|
||||
|
||||
// validateIISName checks that an IIS name field contains only safe characters.
|
||||
// This prevents PowerShell injection via malicious site or store names.
|
||||
func validateIISName(name, field string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%s is required", field)
|
||||
}
|
||||
if len(name) > 256 {
|
||||
return fmt.Errorf("%s exceeds maximum length (256 characters)", field)
|
||||
}
|
||||
if !validIISName.MatchString(name) {
|
||||
return fmt.Errorf("%s contains invalid characters (allowed: alphanumeric, space, underscore, hyphen, dot)", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validIPOrWildcard matches valid IP addresses or the wildcard "*".
|
||||
var validIPOrWildcard = regexp.MustCompile(`^(\*|(\d{1,3}\.){3}\d{1,3})$`)
|
||||
|
||||
// ValidateConfig checks that the IIS configuration is valid and accessible.
|
||||
// It verifies that we're on Windows and that the IIS site exists.
|
||||
//
|
||||
// TODO: Implement actual PowerShell checks.
|
||||
// It verifies field values, PowerShell availability, and optionally checks that
|
||||
// the IIS site exists and the cert store is accessible.
|
||||
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 IIS config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.SiteName == "" || cfg.CertStore == "" {
|
||||
return fmt.Errorf("IIS site_name and cert_store are required")
|
||||
// Validate required fields
|
||||
if err := validateIISName(cfg.SiteName, "site_name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateIISName(cfg.CertStore, "cert_store"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify we're on Windows
|
||||
if runtime.GOOS != "windows" {
|
||||
return fmt.Errorf("IIS connector only runs on Windows, got %s", runtime.GOOS)
|
||||
// Apply defaults
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 443
|
||||
}
|
||||
if cfg.IPAddress == "" {
|
||||
cfg.IPAddress = "*"
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
return fmt.Errorf("port must be between 1 and 65535, got %d", cfg.Port)
|
||||
}
|
||||
|
||||
// Validate IP address format
|
||||
if !validIPOrWildcard.MatchString(cfg.IPAddress) {
|
||||
return fmt.Errorf("ip_address must be a valid IPv4 address or '*', got %q", cfg.IPAddress)
|
||||
}
|
||||
|
||||
// Validate binding_info if provided (safe characters only)
|
||||
if cfg.BindingInfo != "" {
|
||||
if len(cfg.BindingInfo) > 512 {
|
||||
return fmt.Errorf("binding_info exceeds maximum length (512 characters)")
|
||||
}
|
||||
// Allow typical binding chars: alphanumeric, *, :, ., -
|
||||
validBinding := regexp.MustCompile(`^[a-zA-Z0-9\*\:\.\-]+$`)
|
||||
if !validBinding.MatchString(cfg.BindingInfo) {
|
||||
return fmt.Errorf("binding_info contains invalid characters")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply mode default
|
||||
if cfg.Mode == "" {
|
||||
cfg.Mode = "local"
|
||||
}
|
||||
if cfg.Mode != "local" && cfg.Mode != "winrm" {
|
||||
return fmt.Errorf("unsupported mode %q (must be 'local' or 'winrm')", cfg.Mode)
|
||||
}
|
||||
|
||||
c.logger.Info("validating IIS configuration",
|
||||
"site_name", cfg.SiteName,
|
||||
"cert_store", cfg.CertStore,
|
||||
"hostname", cfg.Hostname)
|
||||
"hostname", cfg.Hostname,
|
||||
"port", cfg.Port,
|
||||
"mode", cfg.Mode)
|
||||
|
||||
// TODO: Implement PowerShell check
|
||||
// In production:
|
||||
// 1. Run PowerShell command: Get-IISSite -Name {SiteName}
|
||||
// 2. Verify site exists and is running
|
||||
// 3. Check cert store: Get-Item -Path "Cert:\LocalMachine\{CertStore}"
|
||||
// Verify PowerShell is available (only in local mode — WinRM handles this remotely)
|
||||
if cfg.Mode == "local" {
|
||||
if _, err := exec.LookPath("powershell.exe"); err != nil {
|
||||
return fmt.Errorf("powershell.exe not found in PATH: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Warn("IIS validation not yet fully implemented",
|
||||
"site_name", cfg.SiteName)
|
||||
// Verify IIS site exists
|
||||
siteCheckScript := fmt.Sprintf(`Get-Website -Name '%s' | Select-Object -ExpandProperty Name`, cfg.SiteName)
|
||||
output, err := c.executor.Execute(ctx, siteCheckScript)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IIS site %q not found or inaccessible: %s (error: %w)", cfg.SiteName, strings.TrimSpace(output), err)
|
||||
}
|
||||
|
||||
// Verify cert store is accessible
|
||||
storeCheckScript := fmt.Sprintf(`Test-Path 'Cert:\LocalMachine\%s'`, cfg.CertStore)
|
||||
output, err = c.executor.Execute(ctx, storeCheckScript)
|
||||
if err != nil || !strings.Contains(strings.TrimSpace(output), "True") {
|
||||
return fmt.Errorf("certificate store %q is not accessible: %s", cfg.CertStore, strings.TrimSpace(output))
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("IIS configuration validated",
|
||||
"site_name", cfg.SiteName,
|
||||
"cert_store", cfg.CertStore)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate imports a certificate to the Windows certificate store and updates
|
||||
// the IIS binding to use the new certificate.
|
||||
//
|
||||
// The IIS deployment process (via PowerShell):
|
||||
// 1. Create a temporary PFX file from the certificate and existing private key
|
||||
// (Note: The private key is managed by the agent, not provided by the control plane)
|
||||
// 2. Import the PFX to the Windows certificate store (My store by default)
|
||||
// 3. Get the certificate thumbprint
|
||||
// 4. Update the IIS binding to use the new certificate by thumbprint
|
||||
// 5. Verify the binding is active
|
||||
//
|
||||
// TODO: Implement actual PowerShell commands:
|
||||
// - Import-PfxCertificate -FilePath {pfxPath} -CertStoreLocation "Cert:\LocalMachine\My"
|
||||
// - Get-ChildItem -Path "Cert:\LocalMachine\My" | Where {$_.Subject -eq "CN=..."}
|
||||
// - Set-WebBinding -Name {SiteName} -BindingInformation "{BindingInfo}" -Protocol https -SslFlags 1 -CertificateThumbprint {thumbprint}
|
||||
// Deployment flow:
|
||||
// 1. Convert PEM cert+key+chain to PFX format (go-pkcs12 with random password)
|
||||
// 2. Write PFX to temp file (cleaned up on exit, even on error)
|
||||
// 3. Compute SHA-1 thumbprint from DER cert (matches Windows certutil output)
|
||||
// 4. Import PFX to Windows cert store via Import-PfxCertificate
|
||||
// 5. Update IIS HTTPS binding via New-WebBinding + AddSslCertificate
|
||||
// 6. Return result with thumbprint in metadata
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to IIS",
|
||||
"site_name", c.config.SiteName,
|
||||
@@ -104,44 +244,204 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement IIS certificate deployment
|
||||
// In production:
|
||||
// 1. Create temporary PFX from CertPEM and ChainPEM
|
||||
// (Private key should already exist on the agent)
|
||||
// 2. Import certificate:
|
||||
// PowerShell: Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\LocalMachine\{CertStore}" -Password $password
|
||||
// 3. Get certificate thumbprint:
|
||||
// PowerShell: (Get-ChildItem -Path "Cert:\LocalMachine\{CertStore}" | Where {$_.Subject -like "*CN=*"}).Thumbprint
|
||||
// 4. Update IIS binding:
|
||||
// PowerShell: Set-WebBinding -Name "{SiteName}" -BindingInformation "{BindingInfo}:443:*.example.com" -Protocol https -CertificateThumbprint $thumbprint
|
||||
// 5. Remove temporary PFX file
|
||||
// Validate we have a private key (required for PFX creation)
|
||||
if request.KeyPEM == "" {
|
||||
errMsg := "private key (KeyPEM) is required for IIS deployment"
|
||||
c.logger.Error("deployment failed", "error", errMsg)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Step 1: Create PFX from PEM inputs
|
||||
pfxPassword, err := generateRandomPassword(32)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to generate PFX password: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
pfxData, err := createPFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to create PFX: %v", err)
|
||||
c.logger.Error("PFX creation failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Step 2+3: Compute thumbprint and import PFX
|
||||
// In local mode: write PFX to temp file, import via file path
|
||||
// In WinRM mode: base64-encode PFX, decode on remote side to temp file, import, clean up
|
||||
thumbprint, err := computeThumbprint(request.CertPEM)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
c.logger.Debug("certificate thumbprint computed", "thumbprint", thumbprint)
|
||||
|
||||
// Step 4: Import PFX to Windows certificate store
|
||||
var importScript string
|
||||
mode := c.config.Mode
|
||||
if mode == "" {
|
||||
mode = "local"
|
||||
}
|
||||
|
||||
if mode == "winrm" {
|
||||
// WinRM mode: base64-encode PFX, decode on remote, import, cleanup
|
||||
pfxBase64 := base64.StdEncoding.EncodeToString(pfxData)
|
||||
importScript = fmt.Sprintf(
|
||||
`$pfxPath = [System.IO.Path]::GetTempFileName() + '.pfx'; `+
|
||||
`[System.IO.File]::WriteAllBytes($pfxPath, [System.Convert]::FromBase64String('%s')); `+
|
||||
`try { `+
|
||||
`$password = ConvertTo-SecureString -String '%s' -AsPlainText -Force; `+
|
||||
`Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation 'Cert:\LocalMachine\%s' -Password $password `+
|
||||
`} finally { Remove-Item -Path $pfxPath -Force -ErrorAction SilentlyContinue }`,
|
||||
pfxBase64, pfxPassword, c.config.CertStore,
|
||||
)
|
||||
} else {
|
||||
// Local mode: write PFX to local temp file
|
||||
tmpFile, fileErr := os.CreateTemp("", "certctl-*.pfx")
|
||||
if fileErr != nil {
|
||||
errMsg := fmt.Sprintf("failed to create temp PFX file: %v", fileErr)
|
||||
c.logger.Error("deployment failed", "error", fileErr)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
pfxPath := tmpFile.Name()
|
||||
defer os.Remove(pfxPath) // Always clean up temp PFX
|
||||
|
||||
if _, writeErr := tmpFile.Write(pfxData); writeErr != nil {
|
||||
tmpFile.Close()
|
||||
errMsg := fmt.Sprintf("failed to write temp PFX file: %v", writeErr)
|
||||
c.logger.Error("deployment failed", "error", writeErr)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
importScript = fmt.Sprintf(
|
||||
`$password = ConvertTo-SecureString -String '%s' -AsPlainText -Force; `+
|
||||
`Import-PfxCertificate -FilePath '%s' -CertStoreLocation 'Cert:\LocalMachine\%s' -Password $password`,
|
||||
pfxPassword, pfxPath, c.config.CertStore,
|
||||
)
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, importScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("PFX import failed: %v (output: %s)", err, strings.TrimSpace(output))
|
||||
c.logger.Error("PFX import failed",
|
||||
"error", err,
|
||||
"output", strings.TrimSpace(output),
|
||||
"cert_store", c.config.CertStore)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
c.logger.Info("PFX imported to certificate store",
|
||||
"cert_store", c.config.CertStore,
|
||||
"thumbprint", thumbprint)
|
||||
|
||||
// Step 5: Update IIS HTTPS binding
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
ipAddress := c.config.IPAddress
|
||||
if ipAddress == "" {
|
||||
ipAddress = "*"
|
||||
}
|
||||
hostHeader := c.config.BindingInfo
|
||||
sniFlag := 0
|
||||
if c.config.SNI {
|
||||
sniFlag = 1
|
||||
}
|
||||
|
||||
bindingScript := fmt.Sprintf(
|
||||
// Remove existing HTTPS binding on this port (if any), then create new one
|
||||
`$existing = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; `+
|
||||
`if ($existing) { $existing | Remove-WebBinding }; `+
|
||||
`New-WebBinding -Name '%s' -Protocol 'https' -Port %d -IPAddress '%s' -HostHeader '%s' -SslFlags %d; `+
|
||||
`$binding = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d; `+
|
||||
`$binding.AddSslCertificate('%s', '%s')`,
|
||||
c.config.SiteName, port,
|
||||
c.config.SiteName, port, ipAddress, hostHeader, sniFlag,
|
||||
c.config.SiteName, port,
|
||||
thumbprint, c.config.CertStore,
|
||||
)
|
||||
|
||||
output, err = c.executor.Execute(ctx, bindingScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("IIS binding update failed: %v (output: %s)", err, strings.TrimSpace(output))
|
||||
c.logger.Error("IIS binding update failed",
|
||||
"error", err,
|
||||
"output", strings.TrimSpace(output),
|
||||
"site_name", c.config.SiteName)
|
||||
// Cert is imported but binding failed — partial success
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": thumbprint,
|
||||
"cert_store": c.config.CertStore,
|
||||
"import_success": "true",
|
||||
"binding_error": strings.TrimSpace(output),
|
||||
},
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("IIS deployment not yet implemented",
|
||||
"site_name", c.config.SiteName)
|
||||
c.logger.Info("certificate deployed to IIS successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"site_name", c.config.SiteName,
|
||||
"thumbprint", thumbprint)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
DeploymentID: fmt.Sprintf("iis-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployment to IIS initiated (stub)",
|
||||
DeploymentID: fmt.Sprintf("iis-%s-%d", thumbprint[:8], time.Now().Unix()),
|
||||
Message: "Certificate imported and IIS binding updated successfully",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"hostname": c.config.Hostname,
|
||||
"site_name": c.config.SiteName,
|
||||
"cert_store": c.config.CertStore,
|
||||
"thumbprint": thumbprint,
|
||||
"port": fmt.Sprintf("%d", port),
|
||||
"sni": fmt.Sprintf("%t", c.config.SNI),
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the certificate is properly deployed in IIS.
|
||||
// It checks the IIS binding configuration to ensure it's active with the correct certificate.
|
||||
//
|
||||
// TODO: Implement actual PowerShell validation.
|
||||
// PowerShell command:
|
||||
// - Get-IISSiteBinding -Name {SiteName} | Where {$_.protocol -eq "https"}
|
||||
// It checks the IIS binding to ensure it's active with the correct certificate thumbprint.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating IIS deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
@@ -150,33 +450,211 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement IIS deployment validation
|
||||
// In production:
|
||||
// 1. Query IIS binding status:
|
||||
// PowerShell: Get-WebBinding -Name "{SiteName}" -Protocol "https"
|
||||
// 2. Verify binding exists and is active
|
||||
// 3. Extract certificate thumbprint from binding
|
||||
// 4. Query certificate store to verify thumbprint matches expected certificate
|
||||
// 5. Check certificate validity dates and key match
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
|
||||
// Query IIS binding for HTTPS on the configured port
|
||||
bindingScript := fmt.Sprintf(
|
||||
`$binding = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; `+
|
||||
`if ($binding) { $binding.certificateHash } else { 'NO_BINDING' }`,
|
||||
c.config.SiteName, port,
|
||||
)
|
||||
|
||||
output, err := c.executor.Execute(ctx, bindingScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to query IIS binding: %v (output: %s)", err, strings.TrimSpace(output))
|
||||
c.logger.Error("validation failed", "error", err, "output", strings.TrimSpace(output))
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
bindingHash := strings.TrimSpace(output)
|
||||
if bindingHash == "NO_BINDING" || bindingHash == "" {
|
||||
errMsg := fmt.Sprintf("no HTTPS binding found on IIS site %q port %d", c.config.SiteName, port)
|
||||
c.logger.Error("validation failed", "error", errMsg)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify the certificate exists in the store
|
||||
certCheckScript := fmt.Sprintf(
|
||||
`$cert = Get-ChildItem -Path 'Cert:\LocalMachine\%s\%s' -ErrorAction SilentlyContinue; `+
|
||||
`if ($cert -and $cert.NotAfter -gt (Get-Date)) { 'VALID' } `+
|
||||
`elseif ($cert) { 'EXPIRED' } `+
|
||||
`else { 'NOT_FOUND' }`,
|
||||
c.config.CertStore, bindingHash,
|
||||
)
|
||||
|
||||
output, err = c.executor.Execute(ctx, certCheckScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to verify certificate in store: %v", err)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
certStatus := strings.TrimSpace(output)
|
||||
validationDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("IIS validation not yet implemented",
|
||||
"site_name", c.config.SiteName)
|
||||
switch certStatus {
|
||||
case "VALID":
|
||||
c.logger.Info("IIS deployment validated successfully",
|
||||
"duration", validationDuration.String(),
|
||||
"thumbprint", bindingHash)
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: "Certificate is bound to IIS site and valid",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": bindingHash,
|
||||
"site_name": c.config.SiteName,
|
||||
"cert_store": c.config.CertStore,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: "Certificate deployment validation initiated (stub)",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"hostname": c.config.Hostname,
|
||||
"site_name": c.config.SiteName,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
case "EXPIRED":
|
||||
errMsg := fmt.Sprintf("certificate %s is expired in store %q", bindingHash, c.config.CertStore)
|
||||
c.logger.Error("validation failed: certificate expired", "thumbprint", bindingHash)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": bindingHash,
|
||||
"status": "expired",
|
||||
},
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
|
||||
default: // NOT_FOUND or unexpected
|
||||
errMsg := fmt.Sprintf("certificate %s not found in store %q", bindingHash, c.config.CertStore)
|
||||
c.logger.Error("validation failed: certificate not in store", "thumbprint", bindingHash)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": bindingHash,
|
||||
"status": "not_found",
|
||||
},
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// executePowerShellCommand will be implemented in V3 when IIS target connector ships.
|
||||
// Pattern: exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCommand)
|
||||
// createPFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format.
|
||||
// IIS requires PFX for certificate import. Uses go-pkcs12 Modern encoder
|
||||
// with strong encryption (same library used by M27 export service).
|
||||
func createPFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) {
|
||||
// Parse leaf certificate
|
||||
certBlock, _ := pem.Decode([]byte(certPEM))
|
||||
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM")
|
||||
}
|
||||
leafCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
|
||||
}
|
||||
|
||||
// Parse private key (supports PKCS#8, PKCS#1 RSA, and EC)
|
||||
keyBlock, _ := pem.Decode([]byte(keyPEM))
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("failed to decode private key PEM")
|
||||
}
|
||||
privateKey, err := parsePrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse CA chain certificates (optional)
|
||||
var caCerts []*x509.Certificate
|
||||
if chainPEM != "" {
|
||||
rest := []byte(chainPEM)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||
}
|
||||
caCerts = append(caCerts, caCert)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as PKCS#12 with Modern encryption
|
||||
pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
||||
}
|
||||
|
||||
return pfxData, nil
|
||||
}
|
||||
|
||||
// parsePrivateKey attempts to parse a DER-encoded private key.
|
||||
// Tries PKCS#8, PKCS#1 RSA, and EC formats in order.
|
||||
func parsePrivateKey(der []byte) (interface{}, error) {
|
||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported private key format")
|
||||
}
|
||||
|
||||
// computeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate.
|
||||
// IIS uses SHA-1 thumbprints as the primary certificate identifier.
|
||||
// Returns uppercase hex string matching Windows certutil output.
|
||||
func computeThumbprint(certPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", fmt.Errorf("failed to decode certificate PEM for thumbprint")
|
||||
}
|
||||
hash := sha1.Sum(block.Bytes)
|
||||
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
|
||||
}
|
||||
|
||||
// generateRandomPassword creates a random alphanumeric password for transient PFX encryption.
|
||||
// The password is only used between PFX creation and import — it never persists.
|
||||
func generateRandomPassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
package iis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/masterzen/winrm"
|
||||
)
|
||||
|
||||
// WinRMConfig holds WinRM connection settings for remote IIS management.
|
||||
// Used when Mode is "winrm" — the proxy agent connects to a remote Windows
|
||||
// server over WinRM and executes PowerShell commands remotely.
|
||||
type WinRMConfig struct {
|
||||
Host string `json:"winrm_host"` // WinRM target hostname or IP (required)
|
||||
Port int `json:"winrm_port"` // WinRM port (default 5985 for HTTP, 5986 for HTTPS)
|
||||
Username string `json:"winrm_username"` // Windows user (e.g., "Administrator")
|
||||
Password string `json:"winrm_password"` // Windows password
|
||||
UseHTTPS bool `json:"winrm_https"` // Use HTTPS (port 5986) instead of HTTP (port 5985)
|
||||
Insecure bool `json:"winrm_insecure"` // Skip TLS certificate verification (for self-signed certs)
|
||||
Timeout int `json:"winrm_timeout"` // Operation timeout in seconds (default 60)
|
||||
}
|
||||
|
||||
// winrmExecutor implements PowerShellExecutor by running PowerShell commands
|
||||
// on a remote Windows server via WinRM. This enables the proxy agent pattern:
|
||||
// a Linux agent in the same network zone manages Windows IIS servers remotely.
|
||||
type winrmExecutor struct {
|
||||
client *winrm.Client
|
||||
}
|
||||
|
||||
// newWinRMExecutor creates a WinRM client and returns a PowerShellExecutor.
|
||||
func newWinRMExecutor(cfg *WinRMConfig) (*winrmExecutor, error) {
|
||||
if cfg.Host == "" {
|
||||
return nil, fmt.Errorf("winrm_host is required for WinRM mode")
|
||||
}
|
||||
if cfg.Username == "" {
|
||||
return nil, fmt.Errorf("winrm_username is required for WinRM mode")
|
||||
}
|
||||
if cfg.Password == "" {
|
||||
return nil, fmt.Errorf("winrm_password is required for WinRM mode")
|
||||
}
|
||||
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
if cfg.UseHTTPS {
|
||||
port = 5986
|
||||
} else {
|
||||
port = 5985
|
||||
}
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.Timeout) * time.Second
|
||||
if cfg.Timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
endpoint := winrm.NewEndpoint(
|
||||
cfg.Host,
|
||||
port,
|
||||
cfg.UseHTTPS,
|
||||
cfg.Insecure,
|
||||
nil, // CA cert
|
||||
nil, // Client cert
|
||||
nil, // Client key
|
||||
timeout,
|
||||
)
|
||||
|
||||
client, err := winrm.NewClient(endpoint, cfg.Username, cfg.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WinRM client: %w", err)
|
||||
}
|
||||
|
||||
return &winrmExecutor{client: client}, nil
|
||||
}
|
||||
|
||||
// Execute runs a PowerShell script on the remote Windows server via WinRM.
|
||||
// The script is wrapped in powershell.exe invocation on the remote side.
|
||||
func (e *winrmExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||
// RunPSWithContext returns (stdout, stderr, exitCode, error)
|
||||
stdout, stderr, exitCode, err := e.client.RunPSWithContext(ctx, script)
|
||||
if err != nil {
|
||||
return stdout + stderr, fmt.Errorf("WinRM command failed: %w", err)
|
||||
}
|
||||
if exitCode != 0 {
|
||||
return stdout + stderr, fmt.Errorf("PowerShell exited with code %d: %s", exitCode, stdout+stderr)
|
||||
}
|
||||
|
||||
return stdout, nil
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
@@ -67,13 +68,13 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
"chain_path", cfg.ChainPath)
|
||||
|
||||
// Verify directory exists and is writable
|
||||
certDir := cfg.CertPath[:len(cfg.CertPath)-len("/cert.pem")] // Simple path extraction
|
||||
certDir := filepath.Dir(cfg.CertPath)
|
||||
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("NGINX cert directory does not exist: %s", certDir)
|
||||
}
|
||||
|
||||
// Verify validate command works
|
||||
cmd := exec.CommandContext(ctx, cfg.ValidateCommand)
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.logger.Warn("NGINX config validation failed during config check",
|
||||
"error", err,
|
||||
@@ -115,20 +116,37 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
}
|
||||
|
||||
// Write chain with same permissions
|
||||
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)
|
||||
if c.config.ChainPath != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Write private key if provided and key_path is configured
|
||||
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)
|
||||
}
|
||||
c.logger.Info("private key written", "key_path", c.config.KeyPath)
|
||||
}
|
||||
|
||||
// Validate NGINX configuration before reload
|
||||
c.logger.Debug("validating NGINX configuration", "validate_command", c.config.ValidateCommand)
|
||||
validateCmd := exec.CommandContext(ctx, c.config.ValidateCommand)
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
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))
|
||||
@@ -142,7 +160,7 @@ 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, c.config.ReloadCommand)
|
||||
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
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))
|
||||
@@ -187,7 +205,7 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
||||
startTime := time.Now()
|
||||
|
||||
// Validate NGINX configuration
|
||||
validateCmd := exec.CommandContext(ctx, 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("validation failed", "error", err)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// Config represents the Postfix/Dovecot deployment target configuration.
|
||||
// This connector supports dual-mode operation: "postfix" for Postfix MTA
|
||||
// and "dovecot" for Dovecot IMAP/POP3. The mode determines default file
|
||||
// paths and reload commands. Both modes write cert/key/chain files and
|
||||
// reload the mail service.
|
||||
type Config struct {
|
||||
Mode string `json:"mode"` // "postfix" (default) or "dovecot"
|
||||
CertPath string `json:"cert_path"` // Path where cert will be written
|
||||
KeyPath string `json:"key_path"` // Path where private key will be written
|
||||
ChainPath string `json:"chain_path"` // Path where CA chain will be written (optional — if empty, chain appended to cert)
|
||||
ReloadCommand string `json:"reload_command"` // Command to reload service
|
||||
ValidateCommand string `json:"validate_command"` // Optional command to validate config before reload
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Postfix and Dovecot
|
||||
// mail servers. This connector runs on the AGENT side and handles local
|
||||
// certificate deployment for mail server TLS (STARTTLS, SMTPS, IMAPS, POP3S).
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Postfix/Dovecot target connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// applyDefaults sets mode-specific default values for any unconfigured fields.
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.Mode == "" {
|
||||
cfg.Mode = "postfix"
|
||||
}
|
||||
|
||||
switch cfg.Mode {
|
||||
case "dovecot":
|
||||
if cfg.CertPath == "" {
|
||||
cfg.CertPath = "/etc/dovecot/certs/cert.pem"
|
||||
}
|
||||
if cfg.KeyPath == "" {
|
||||
cfg.KeyPath = "/etc/dovecot/certs/key.pem"
|
||||
}
|
||||
if cfg.ReloadCommand == "" {
|
||||
cfg.ReloadCommand = "doveadm reload"
|
||||
}
|
||||
if cfg.ValidateCommand == "" {
|
||||
cfg.ValidateCommand = "doveconf -n"
|
||||
}
|
||||
default: // "postfix"
|
||||
if cfg.CertPath == "" {
|
||||
cfg.CertPath = "/etc/postfix/certs/cert.pem"
|
||||
}
|
||||
if cfg.KeyPath == "" {
|
||||
cfg.KeyPath = "/etc/postfix/certs/key.pem"
|
||||
}
|
||||
if cfg.ReloadCommand == "" {
|
||||
cfg.ReloadCommand = "postfix reload"
|
||||
}
|
||||
if cfg.ValidateCommand == "" {
|
||||
cfg.ValidateCommand = "postfix check"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the configuration is valid for the selected mode.
|
||||
// It applies mode-specific defaults, validates shell commands against injection,
|
||||
// and verifies the certificate directory exists.
|
||||
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 mail server config: %w", err)
|
||||
}
|
||||
|
||||
// Validate mode
|
||||
if cfg.Mode != "" && cfg.Mode != "postfix" && cfg.Mode != "dovecot" {
|
||||
return fmt.Errorf("invalid mode %q: must be \"postfix\" or \"dovecot\"", cfg.Mode)
|
||||
}
|
||||
|
||||
// Apply mode-specific defaults
|
||||
applyDefaults(&cfg)
|
||||
|
||||
// Validate commands to prevent injection attacks
|
||||
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||
return fmt.Errorf("invalid reload_command: %w", err)
|
||||
}
|
||||
if cfg.ValidateCommand != "" {
|
||||
if err := validation.ValidateShellCommand(cfg.ValidateCommand); err != nil {
|
||||
return fmt.Errorf("invalid validate_command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("validating mail server configuration",
|
||||
"mode", cfg.Mode,
|
||||
"cert_path", cfg.CertPath,
|
||||
"key_path", cfg.KeyPath,
|
||||
"chain_path", cfg.ChainPath)
|
||||
|
||||
// Verify certificate directory exists
|
||||
certDir := filepath.Dir(cfg.CertPath)
|
||||
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("%s cert directory does not exist: %s", cfg.Mode, certDir)
|
||||
}
|
||||
|
||||
// Verify validate command works (best-effort — service might not be installed yet)
|
||||
if cfg.ValidateCommand != "" {
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.logger.Warn("config validation command failed during config check",
|
||||
"error", err,
|
||||
"mode", cfg.Mode,
|
||||
"validate_command", cfg.ValidateCommand)
|
||||
}
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("mail server configuration validated", "mode", cfg.Mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate writes the certificate, key, and chain to the configured paths
|
||||
// and reloads the mail service to pick up the new certificates.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Write certificate to cert_path with mode 0644 (if chain_path empty, append chain)
|
||||
// 2. Write private key to key_path with mode 0600
|
||||
// 3. If chain_path is set, write chain separately with mode 0644
|
||||
// 4. Validate configuration (if validate_command is set)
|
||||
// 5. Reload service
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to mail server",
|
||||
"mode", c.config.Mode,
|
||||
"cert_path", c.config.CertPath,
|
||||
"key_path", c.config.KeyPath)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Build certificate data: if chain_path is set, write chain separately;
|
||||
// otherwise append chain to cert file (fullchain behavior)
|
||||
certData := request.CertPEM
|
||||
if request.ChainPEM != "" && c.config.ChainPath == "" {
|
||||
certData += "\n" + request.ChainPEM
|
||||
}
|
||||
|
||||
// Write certificate with mode 0644 (rw-r--r--)
|
||||
if err := os.WriteFile(c.config.CertPath, []byte(certData), 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)
|
||||
}
|
||||
c.logger.Info("private key written", "key_path", c.config.KeyPath)
|
||||
}
|
||||
|
||||
// Write chain separately if chain_path is configured
|
||||
if c.config.ChainPath != "" && request.ChainPEM != "" {
|
||||
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 configuration before reload
|
||||
if c.config.ValidateCommand != "" {
|
||||
c.logger.Debug("validating 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("%s config validation failed: %v (output: %s)", c.config.Mode, err, string(output))
|
||||
c.logger.Error("config 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload service
|
||||
c.logger.Debug("reloading service", "reload_command", c.config.ReloadCommand)
|
||||
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("%s reload failed: %v (output: %s)", c.config.Mode, err, string(output))
|
||||
c.logger.Error("service 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 mail server successfully",
|
||||
"mode", c.config.Mode,
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", c.config.CertPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.CertPath,
|
||||
DeploymentID: fmt.Sprintf("%s-%d", c.config.Mode, time.Now().Unix()),
|
||||
Message: fmt.Sprintf("Certificate deployed and %s reloaded successfully", c.config.Mode),
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": c.config.CertPath,
|
||||
"key_path": c.config.KeyPath,
|
||||
"mode": c.config.Mode,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
||||
// It runs the validate command (if configured) and checks that the cert file exists.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating mail server deployment",
|
||||
"mode", c.config.Mode,
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Validate configuration if validate command is set
|
||||
if c.config.ValidateCommand != "" {
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("%s config validation failed: %v (output: %s)", c.config.Mode, 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("mail server deployment validated successfully",
|
||||
"mode", c.config.Mode,
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: fmt.Sprintf("%s configuration valid and certificate accessible", c.config.Mode),
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"mode": c.config.Mode,
|
||||
"validate_command": c.config.ValidateCommand,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package postfix_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/postfix"
|
||||
)
|
||||
|
||||
// --- Config Validation Tests ---
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_DovecotMode(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := postfix.Config{
|
||||
Mode: "dovecot",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig for dovecot mode failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
connector := postfix.New(&postfix.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_InvalidMode(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := postfix.Config{
|
||||
Mode: "nginx",
|
||||
CertPath: "/tmp/cert.pem",
|
||||
ReloadCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid mode") {
|
||||
t.Fatalf("expected 'invalid mode' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_DirectoryNotExists(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: "/nonexistent/directory/cert.pem",
|
||||
KeyPath: "/nonexistent/directory/key.pem",
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.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 TestPostfixConnector_ValidateConfig_MissingCertPath(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// An empty config with mode=postfix will get defaults applied.
|
||||
// The defaults point to /etc/postfix/certs/ which won't exist in test,
|
||||
// so this will fail at directory check — which is fine; it validates that
|
||||
// defaults are applied and path validation catches missing dirs.
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when default cert directory doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_DefaultsApplied(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a directory matching the postfix default path structure
|
||||
tmpDir := t.TempDir()
|
||||
certDir := filepath.Join(tmpDir, "postfix", "certs")
|
||||
os.MkdirAll(certDir, 0755)
|
||||
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(certDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(certDir, "key.pem"),
|
||||
// Leave ReloadCommand and ValidateCommand empty to get defaults
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
// Defaults will be applied for reload/validate commands.
|
||||
// The validate command will be "postfix check" which won't exist in test env
|
||||
// but ValidateConfig only warns on validate command failure (doesn't error).
|
||||
// The reload command "postfix reload" will be validated by ValidateShellCommand.
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig with defaults failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deployment Tests ---
|
||||
|
||||
func TestPostfixConnector_DeployCertificate_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nkey\n-----END 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 cert file was written (just cert, not chain — since chain_path is set)
|
||||
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: got %q", string(certData))
|
||||
}
|
||||
|
||||
// Verify key file was written
|
||||
keyData, err := os.ReadFile(cfg.KeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read key file: %v", err)
|
||||
}
|
||||
if string(keyData) != req.KeyPEM {
|
||||
t.Errorf("key 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 key has correct permissions (0600)
|
||||
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())
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
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["mode"] != "postfix" {
|
||||
t.Errorf("expected mode=postfix in metadata, got %s", result.Metadata["mode"])
|
||||
}
|
||||
if _, ok := result.Metadata["duration_ms"]; !ok {
|
||||
t.Errorf("expected duration_ms in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_DeployCertificate_ChainAppendedToCert(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: "", // No chain_path — chain should be appended to cert
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(cfg, logger)
|
||||
|
||||
certPEM := "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----"
|
||||
chainPEM := "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----"
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----",
|
||||
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 cert file contains both cert and chain (fullchain)
|
||||
certData, err := os.ReadFile(cfg.CertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
expected := certPEM + "\n" + chainPEM
|
||||
if string(certData) != expected {
|
||||
t.Errorf("expected fullchain content, got: %q", string(certData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_DeployCertificate_CertWriteFail(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: "/nonexistent/directory/cert.pem",
|
||||
KeyPath: "/nonexistent/directory/key.pem",
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.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 TestPostfixConnector_DeployCertificate_ValidateCommandFails(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "false", // Exits with code 1
|
||||
}
|
||||
|
||||
connector := postfix.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 TestPostfixConnector_DeployCertificate_ReloadCommandFails(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ReloadCommand: "false", // Exits with code 1
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.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")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validation Tests ---
|
||||
|
||||
func TestPostfixConnector_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 := &postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: certPath,
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.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")
|
||||
}
|
||||
if result.Metadata == nil {
|
||||
t.Fatal("expected metadata in result")
|
||||
}
|
||||
if result.Metadata["mode"] != "postfix" {
|
||||
t.Errorf("expected mode=postfix in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateDeployment_CertNotFound(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: "/nonexistent/cert.pem",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.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")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Security Tests (Command Injection Prevention) ---
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_RejectCommandInjectionSemicolon(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ReloadCommand: "postfix reload; rm -rf /",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for command injection in reload_command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_RejectCommandInjectionPipe(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "postfix check | cat /etc/passwd",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for command injection in validate_command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_RejectCommandSubstitution(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ReloadCommand: "echo $(whoami)",
|
||||
ValidateCommand: "true",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for command substitution in reload_command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostfixConnector_ValidateConfig_RejectBackticks(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := postfix.Config{
|
||||
Mode: "postfix",
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ReloadCommand: "true",
|
||||
ValidateCommand: "postfix check `whoami`",
|
||||
}
|
||||
|
||||
connector := postfix.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for backtick injection in validate_command")
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ const (
|
||||
IssuerTypeGenericCA IssuerType = "GenericCA"
|
||||
IssuerTypeStepCA IssuerType = "StepCA"
|
||||
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
||||
IssuerTypeVault IssuerType = "VaultPKI"
|
||||
IssuerTypeDigiCert IssuerType = "DigiCert"
|
||||
IssuerTypeSectigo IssuerType = "Sectigo"
|
||||
IssuerTypeGoogleCAS IssuerType = "GoogleCAS"
|
||||
)
|
||||
|
||||
// TargetType represents the type of deployment target.
|
||||
@@ -82,4 +86,7 @@ const (
|
||||
TargetTypeIIS TargetType = "IIS"
|
||||
TargetTypeTraefik TargetType = "Traefik"
|
||||
TargetTypeCaddy TargetType = "Caddy"
|
||||
TargetTypeEnvoy TargetType = "Envoy"
|
||||
TargetTypePostfix TargetType = "Postfix"
|
||||
TargetTypeDovecot TargetType = "Dovecot"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ type Job struct {
|
||||
Type JobType `json:"type"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
AgentID *string `json:"agent_id,omitempty"`
|
||||
Status JobStatus `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
|
||||
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
var result []*domain.Job
|
||||
for _, j := range m.jobs {
|
||||
if j.AgentID != nil && *j.AgentID == agentID {
|
||||
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||
result = append(result, j)
|
||||
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||
result = append(result, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type mockAuditRepository struct {
|
||||
events []*domain.AuditEvent
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ type JobRepository interface {
|
||||
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
||||
// GetPendingJobs returns jobs not yet processed of a specific type.
|
||||
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
||||
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||
ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error)
|
||||
}
|
||||
|
||||
// RenewalPolicyRepository defines operations for managing renewal policies.
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewJobRepository(db *sql.DB) *JobRepository {
|
||||
// List returns all jobs
|
||||
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
ORDER BY created_at DESC
|
||||
@@ -52,7 +52,7 @@ func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
||||
// Get retrieves a job by ID
|
||||
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE id = $1
|
||||
@@ -77,11 +77,11 @@ func (r *JobRepository) Create(ctx context.Context, job *domain.Job) error {
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO jobs (
|
||||
id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id
|
||||
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
||||
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
|
||||
job.CreatedAt).Scan(&job.ID)
|
||||
|
||||
@@ -99,15 +99,16 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
|
||||
type = $1,
|
||||
certificate_id = $2,
|
||||
target_id = $3,
|
||||
status = $4,
|
||||
attempts = $5,
|
||||
max_attempts = $6,
|
||||
last_error = $7,
|
||||
scheduled_at = $8,
|
||||
started_at = $9,
|
||||
completed_at = $10
|
||||
WHERE id = $11
|
||||
`, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
||||
agent_id = $4,
|
||||
status = $5,
|
||||
attempts = $6,
|
||||
max_attempts = $7,
|
||||
last_error = $8,
|
||||
scheduled_at = $9,
|
||||
started_at = $10,
|
||||
completed_at = $11
|
||||
WHERE id = $12
|
||||
`, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
||||
job.CompletedAt, job.ID)
|
||||
|
||||
@@ -150,7 +151,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
|
||||
// ListByStatus returns jobs with a specific status
|
||||
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE status = $1
|
||||
@@ -181,7 +182,7 @@ func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatu
|
||||
// ListByCertificate returns all jobs for a certificate
|
||||
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE certificate_id = $1
|
||||
@@ -239,7 +240,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
|
||||
// GetPendingJobs returns jobs not yet processed of a specific type
|
||||
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE type = $1 AND status = $2
|
||||
@@ -267,13 +268,71 @@ func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobTy
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||
// Deployment jobs are matched by agent_id directly (set at creation time), with a fallback
|
||||
// for legacy jobs where agent_id is NULL but target_id resolves to the agent via deployment_targets.
|
||||
// AwaitingCSR jobs are matched through certificate → target mappings → agent ownership.
|
||||
func (r *JobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||
last_error, scheduled_at, started_at, completed_at, created_at
|
||||
FROM jobs
|
||||
WHERE agent_id = $1 AND status = 'Pending' AND type = 'Deployment'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||
FROM jobs j
|
||||
INNER JOIN deployment_targets dt ON j.target_id = dt.id
|
||||
WHERE j.agent_id IS NULL AND j.status = 'Pending' AND j.type = 'Deployment'
|
||||
AND dt.agent_id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||
FROM jobs j
|
||||
WHERE j.status = 'AwaitingCSR'
|
||||
AND j.type IN ('Renewal', 'Issuance')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM certificate_target_mappings ctm
|
||||
INNER JOIN deployment_targets dt ON ctm.target_id = dt.id
|
||||
WHERE ctm.certificate_id = j.certificate_id
|
||||
AND dt.agent_id = $1
|
||||
)
|
||||
|
||||
ORDER BY created_at ASC
|
||||
`, agentID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query pending jobs for agent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []*domain.Job
|
||||
for rows.Next() {
|
||||
job, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating pending agent job rows: %w", err)
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// scanJob scans a job from a row or rows
|
||||
func scanJob(scanner interface {
|
||||
Scan(...interface{}) error
|
||||
}) (*domain.Job, error) {
|
||||
var job domain.Job
|
||||
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
|
||||
&job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
||||
&job.AgentID, &job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
||||
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
|
||||
+14
-34
@@ -178,14 +178,15 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
||||
}
|
||||
|
||||
version := &domain.CertificateVersion{
|
||||
ID: generateID("certver"),
|
||||
CertificateID: certID,
|
||||
SerialNumber: result.Serial,
|
||||
NotBefore: result.NotBefore,
|
||||
NotAfter: result.NotAfter,
|
||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||
CSRPEM: string(csrPEM),
|
||||
CreatedAt: time.Now(),
|
||||
ID: generateID("certver"),
|
||||
CertificateID: certID,
|
||||
SerialNumber: result.Serial,
|
||||
NotBefore: result.NotBefore,
|
||||
NotAfter: result.NotAfter,
|
||||
FingerprintSHA256: computeCertFingerprint(result.CertPEM),
|
||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||
CSRPEM: string(csrPEM),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
|
||||
@@ -251,38 +252,17 @@ func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID strin
|
||||
|
||||
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
||||
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
||||
// Jobs are scoped to the requesting agent via agent_id (set at job creation) or
|
||||
// through target→agent relationships for legacy jobs and AwaitingCSR routing.
|
||||
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
// Fetch agent to verify it exists
|
||||
// Verify agent exists
|
||||
_, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
var workForAgent []*domain.Job
|
||||
|
||||
// Get pending deployment jobs
|
||||
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
|
||||
}
|
||||
for _, job := range pendingJobs {
|
||||
if job.Type == domain.JobTypeDeployment {
|
||||
workForAgent = append(workForAgent, job)
|
||||
}
|
||||
}
|
||||
|
||||
// Get AwaitingCSR jobs (agent keygen mode — agent needs to generate key + submit CSR)
|
||||
awaitingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusAwaitingCSR)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list awaiting CSR jobs: %w", err)
|
||||
}
|
||||
for _, job := range awaitingJobs {
|
||||
if job.Type == domain.JobTypeRenewal || job.Type == domain.JobTypeIssuance {
|
||||
workForAgent = append(workForAgent, job)
|
||||
}
|
||||
}
|
||||
|
||||
return workForAgent, nil
|
||||
// Return only jobs assigned to this agent (via agent_id or target→agent relationship)
|
||||
return s.jobRepo.ListPendingByAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
// ReportJobStatus updates a job's status based on agent feedback.
|
||||
|
||||
@@ -131,8 +131,9 @@ func TestHeartbeat_NotFound(t *testing.T) {
|
||||
func TestGetPendingWork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentID := "agent-001"
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
ID: agentID,
|
||||
Name: "prod-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
@@ -146,6 +147,7 @@ func TestGetPendingWork(t *testing.T) {
|
||||
Type: domain.JobTypeDeployment,
|
||||
CertificateID: "cert-001",
|
||||
Status: domain.JobStatusPending,
|
||||
AgentID: &agentID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
job2 := &domain.Job{
|
||||
@@ -157,7 +159,7 @@ func TestGetPendingWork(t *testing.T) {
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
||||
Agents: map[string]*domain.Agent{agentID: agent},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
@@ -177,7 +179,7 @@ func TestGetPendingWork(t *testing.T) {
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||
|
||||
jobs, err := agentService.GetPendingWork(ctx, "agent-001")
|
||||
jobs, err := agentService.GetPendingWork(ctx, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork failed: %v", err)
|
||||
}
|
||||
@@ -185,11 +187,132 @@ func TestGetPendingWork(t *testing.T) {
|
||||
if len(jobs) != 1 {
|
||||
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
||||
}
|
||||
if jobs[0].Type != domain.JobTypeDeployment {
|
||||
if len(jobs) > 0 && jobs[0].Type != domain.JobTypeDeployment {
|
||||
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentA := "agent-A"
|
||||
agentB := "agent-B"
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{
|
||||
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||
agentB: {ID: agentB, Name: "agent-B", Hostname: "host-b", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashB"},
|
||||
},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
jobA := &domain.Job{ID: "job-A", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-002", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-A": jobA, "job-B": jobB},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditService := NewAuditService(&mockAuditRepo{})
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||
|
||||
// Agent A should only see its job
|
||||
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork for agent-A failed: %v", err)
|
||||
}
|
||||
if len(jobsA) != 1 {
|
||||
t.Fatalf("expected 1 job for agent-A, got %d", len(jobsA))
|
||||
}
|
||||
if jobsA[0].ID != "job-A" {
|
||||
t.Errorf("expected job-A, got %s", jobsA[0].ID)
|
||||
}
|
||||
|
||||
// Agent B should only see its job
|
||||
jobsB, err := agentService.GetPendingWork(ctx, agentB)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork for agent-B failed: %v", err)
|
||||
}
|
||||
if len(jobsB) != 1 {
|
||||
t.Fatalf("expected 1 job for agent-B, got %d", len(jobsB))
|
||||
}
|
||||
if jobsB[0].ID != "job-B" {
|
||||
t.Errorf("expected job-B, got %s", jobsB[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentA := "agent-A"
|
||||
agentB := "agent-B"
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{
|
||||
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||
},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// All jobs belong to agent-B
|
||||
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-B": jobB},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditService := NewAuditService(&mockAuditRepo{})
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||
|
||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork failed: %v", err)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Errorf("expected 0 jobs for agent-A (all jobs are for agent-B), got %d", len(jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agentA := "agent-A"
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{
|
||||
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||
},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
deployJob := &domain.Job{ID: "job-deploy", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||
csrJob := &domain.Job{ID: "job-csr", Type: domain.JobTypeRenewal, CertificateID: "cert-002", Status: domain.JobStatusAwaitingCSR, AgentID: &agentA, CreatedAt: now}
|
||||
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-deploy": deployJob, "job-csr": csrJob},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditService := NewAuditService(&mockAuditRepo{})
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||
|
||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork failed: %v", err)
|
||||
}
|
||||
if len(jobs) != 2 {
|
||||
t.Fatalf("expected 2 jobs (deployment + AwaitingCSR), got %d", len(jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportJobStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
type CertificateService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
targetRepo repository.TargetRepository
|
||||
jobRepo repository.JobRepository
|
||||
policyService *PolicyService
|
||||
auditService *AuditService
|
||||
revSvc *RevocationSvc
|
||||
caSvc *CAOperationsSvc
|
||||
keygenMode string
|
||||
}
|
||||
|
||||
// NewCertificateService creates a new certificate service.
|
||||
@@ -48,6 +50,16 @@ func (s *CertificateService) SetTargetRepo(repo repository.TargetRepository) {
|
||||
s.targetRepo = repo
|
||||
}
|
||||
|
||||
// SetJobRepo sets the job repository for creating renewal/issuance jobs.
|
||||
func (s *CertificateService) SetJobRepo(repo repository.JobRepository) {
|
||||
s.jobRepo = repo
|
||||
}
|
||||
|
||||
// SetKeygenMode sets the key generation mode (agent or server).
|
||||
func (s *CertificateService) SetKeygenMode(mode string) {
|
||||
s.keygenMode = mode
|
||||
}
|
||||
|
||||
// List returns a paginated list of certificates matching the filter.
|
||||
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
||||
certs, total, err := s.certRepo.List(ctx, filter)
|
||||
@@ -195,6 +207,8 @@ func (s *CertificateService) GetVersions(ctx context.Context, certID string) ([]
|
||||
}
|
||||
|
||||
// TriggerRenewalWithActor initiates a renewal job if the certificate is eligible.
|
||||
// Creates a Renewal job (or Issuance for new certs) so the scheduler's job processor
|
||||
// can pick it up and route it through the issuer connector.
|
||||
func (s *CertificateService) TriggerRenewalWithActor(ctx context.Context, certID string, actor string) error {
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
@@ -220,6 +234,45 @@ func (s *CertificateService) TriggerRenewalWithActor(ctx context.Context, certID
|
||||
return fmt.Errorf("failed to update certificate status: %w", err)
|
||||
}
|
||||
|
||||
// Create a renewal job so the job processor can pick it up.
|
||||
// In agent keygen mode, the job starts as AwaitingCSR so the agent
|
||||
// generates the key pair and submits a CSR. In server mode, it starts as Pending.
|
||||
if s.jobRepo != nil {
|
||||
jobStatus := domain.JobStatusPending
|
||||
if s.keygenMode == "agent" {
|
||||
jobStatus = domain.JobStatusAwaitingCSR
|
||||
}
|
||||
|
||||
// Determine job type: Issuance for certs that have never been issued,
|
||||
// Renewal for certs that already have a version.
|
||||
jobType := domain.JobTypeRenewal
|
||||
if cert.ExpiresAt.IsZero() || cert.ExpiresAt.Year() < 2000 {
|
||||
jobType = domain.JobTypeIssuance
|
||||
}
|
||||
|
||||
job := &domain.Job{
|
||||
ID: generateID("job"),
|
||||
CertificateID: cert.ID,
|
||||
Type: jobType,
|
||||
Status: jobStatus,
|
||||
MaxAttempts: 3,
|
||||
ScheduledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||
slog.Error("failed to create renewal job", "cert_id", cert.ID, "error", err)
|
||||
return fmt.Errorf("failed to create renewal job: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("created renewal job via API trigger",
|
||||
"job_id", job.ID,
|
||||
"cert_id", cert.ID,
|
||||
"job_type", string(jobType),
|
||||
"job_status", string(jobStatus),
|
||||
"keygen_mode", s.keygenMode)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"renewal_triggered", "certificate", certID,
|
||||
@@ -304,6 +357,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
||||
if cert.UpdatedAt.IsZero() {
|
||||
cert.UpdatedAt = now
|
||||
}
|
||||
// Default status to Pending if not set (DB column DEFAULT only applies when column is omitted from INSERT)
|
||||
if cert.Status == "" {
|
||||
cert.Status = domain.CertificateStatusPending
|
||||
}
|
||||
// Default tags to empty map if nil (avoids JSON null in JSONB column)
|
||||
if cert.Tags == nil {
|
||||
cert.Tags = make(map[string]string)
|
||||
}
|
||||
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID str
|
||||
if target.ID != "" {
|
||||
job.TargetID = &target.ID
|
||||
}
|
||||
// Route job to the target's assigned agent
|
||||
if target.AgentID != "" {
|
||||
agentID := target.AgentID
|
||||
job.AgentID = &agentID
|
||||
}
|
||||
|
||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
||||
|
||||
@@ -85,6 +85,45 @@ func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
|
||||
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
||||
t.Errorf("expected job to have TargetID set")
|
||||
}
|
||||
|
||||
// M31: Verify AgentID is set from target's agent assignment
|
||||
if job.AgentID == nil {
|
||||
t.Errorf("expected job to have AgentID set (M31 agent routing)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentService_CreateDeploymentJobs_SetsAgentID verifies AgentID is populated from target.
|
||||
func TestDeploymentService_CreateDeploymentJobs_SetsAgentID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "tgt-nginx-1",
|
||||
Name: "NGINX Server 1",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-web-01",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDeploymentJobs failed: %v", err)
|
||||
}
|
||||
|
||||
if len(jobIDs) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(jobIDs))
|
||||
}
|
||||
|
||||
job := jobRepo.Jobs[jobIDs[0]]
|
||||
if job.AgentID == nil {
|
||||
t.Fatal("expected AgentID to be set on deployment job")
|
||||
}
|
||||
if *job.AgentID != "agent-web-01" {
|
||||
t.Errorf("expected AgentID 'agent-web-01', got '%s'", *job.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,16 @@ func (s *JobService) ProcessPendingJobs(ctx context.Context) error {
|
||||
|
||||
// Process each job
|
||||
for _, job := range pendingJobs {
|
||||
// Skip deployment jobs that have an agent_id — those are meant for agent
|
||||
// pickup via GetPendingWork(), not server-side processing. The server should
|
||||
// only process deployment jobs without an agent (legacy/serverless targets).
|
||||
if job.Type == domain.JobTypeDeployment && job.AgentID != nil && *job.AgentID != "" {
|
||||
s.logger.Debug("skipping agent-routed deployment job",
|
||||
"job_id", job.ID,
|
||||
"agent_id", *job.AgentID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.processJob(ctx, job); err != nil {
|
||||
s.logger.Error("failed to process job",
|
||||
"job_id", job.ID,
|
||||
|
||||
@@ -26,12 +26,18 @@ type RenewalService struct {
|
||||
jobRepo repository.JobRepository
|
||||
renewalPolicyRepo repository.RenewalPolicyRepository
|
||||
profileRepo repository.CertificateProfileRepository
|
||||
targetRepo repository.TargetRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
keygenMode string // "agent" (default) or "server" (demo only)
|
||||
}
|
||||
|
||||
// SetTargetRepo sets the target repository for resolving agent_id on deployment jobs.
|
||||
func (s *RenewalService) SetTargetRepo(repo repository.TargetRepository) {
|
||||
s.targetRepo = repo
|
||||
}
|
||||
|
||||
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
|
||||
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
|
||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
||||
@@ -163,10 +169,39 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
||||
|
||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
||||
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer {
|
||||
connector, hasIssuer := s.issuerRegistry[cert.IssuerID]
|
||||
if !hasIssuer {
|
||||
continue
|
||||
}
|
||||
|
||||
// ARI check (RFC 9702): if the issuer supports ARI, let the CA direct renewal timing.
|
||||
// Fetch the latest cert version to get the PEM chain for the ARI query.
|
||||
ariChecked := false
|
||||
if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" {
|
||||
if ariResult, ariErr := connector.GetRenewalInfo(ctx, version.PEMChain); ariErr != nil {
|
||||
// ARI error is non-fatal — log and fall through to threshold-based renewal
|
||||
slog.Warn("ARI check failed, falling back to threshold-based renewal",
|
||||
"cert_id", cert.ID, "issuer_id", cert.IssuerID, "error", ariErr)
|
||||
} else if ariResult != nil {
|
||||
ariChecked = true
|
||||
now := time.Now()
|
||||
if now.Before(ariResult.SuggestedWindowStart) {
|
||||
// CA says it's too early to renew — skip this cert
|
||||
slog.Debug("ARI: renewal not yet suggested by CA",
|
||||
"cert_id", cert.ID,
|
||||
"suggested_start", ariResult.SuggestedWindowStart,
|
||||
"suggested_end", ariResult.SuggestedWindowEnd)
|
||||
continue
|
||||
}
|
||||
slog.Info("ARI: CA suggests renewal now",
|
||||
"cert_id", cert.ID,
|
||||
"suggested_start", ariResult.SuggestedWindowStart,
|
||||
"suggested_end", ariResult.SuggestedWindowEnd)
|
||||
}
|
||||
// ariResult == nil means issuer doesn't support ARI — fall through to threshold logic
|
||||
}
|
||||
_ = ariChecked // used for audit metadata below
|
||||
|
||||
// Check for existing pending/running renewal jobs to avoid duplicates
|
||||
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
|
||||
if err == nil {
|
||||
@@ -206,9 +241,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
auditMeta := map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}
|
||||
if ariChecked {
|
||||
auditMeta["renewal_trigger"] = "ari"
|
||||
}
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"renewal_job_created", "certificate", cert.ID,
|
||||
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}); auditErr != nil {
|
||||
"renewal_job_created", "certificate", cert.ID, auditMeta); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
@@ -598,24 +636,67 @@ func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domai
|
||||
}
|
||||
|
||||
// createDeploymentJobs creates pending deployment jobs for each target associated with a cert.
|
||||
// If cert.TargetIDs is empty (common — the repository doesn't populate this field),
|
||||
// falls back to querying certificate_target_mappings via targetRepo.ListByCertificate.
|
||||
func (s *RenewalService) createDeploymentJobs(ctx context.Context, cert *domain.ManagedCertificate) {
|
||||
if len(cert.TargetIDs) == 0 {
|
||||
// Resolve targets: prefer in-memory TargetIDs, fall back to DB query
|
||||
type targetInfo struct {
|
||||
id string
|
||||
agentID string
|
||||
}
|
||||
var targets []targetInfo
|
||||
|
||||
if len(cert.TargetIDs) > 0 {
|
||||
// TargetIDs populated (e.g. from test or manual wiring)
|
||||
for _, tid := range cert.TargetIDs {
|
||||
ti := targetInfo{id: tid}
|
||||
if s.targetRepo != nil {
|
||||
if target, err := s.targetRepo.Get(ctx, tid); err == nil && target.AgentID != "" {
|
||||
ti.agentID = target.AgentID
|
||||
}
|
||||
}
|
||||
targets = append(targets, ti)
|
||||
}
|
||||
} else if s.targetRepo != nil {
|
||||
// TargetIDs empty — query certificate_target_mappings via repository
|
||||
dbTargets, err := s.targetRepo.ListByCertificate(ctx, cert.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to query targets for certificate", "cert_id", cert.ID, "error", err)
|
||||
return
|
||||
}
|
||||
for _, t := range dbTargets {
|
||||
targets = append(targets, targetInfo{id: t.ID, agentID: t.AgentID})
|
||||
}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
slog.Debug("no targets found for certificate, skipping deployment", "cert_id", cert.ID)
|
||||
return
|
||||
}
|
||||
for _, targetID := range cert.TargetIDs {
|
||||
tid := targetID
|
||||
|
||||
for _, t := range targets {
|
||||
tid := t.id
|
||||
var agentIDPtr *string
|
||||
if t.agentID != "" {
|
||||
aid := t.agentID
|
||||
agentIDPtr = &aid
|
||||
}
|
||||
|
||||
deployJob := &domain.Job{
|
||||
ID: generateID("job"),
|
||||
CertificateID: cert.ID,
|
||||
Type: domain.JobTypeDeployment,
|
||||
Status: domain.JobStatusPending,
|
||||
TargetID: &tid,
|
||||
AgentID: agentIDPtr,
|
||||
MaxAttempts: 3,
|
||||
ScheduledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := s.jobRepo.Create(ctx, deployJob); err != nil {
|
||||
slog.Error("failed to create deployment job for target", "target_id", targetID, "error", err)
|
||||
slog.Error("failed to create deployment job for target", "target_id", tid, "cert_id", cert.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("created deployment job", "job_id", deployJob.ID, "cert_id", cert.ID, "target_id", tid, "agent_id", t.agentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,4 +863,283 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ARI (RFC 9702) Scheduler Integration Tests ---
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI says renew now: window started in the past
|
||||
ariConnector := &mockIssuerConnector{
|
||||
getRenewalInfoResult: &RenewalInfoResult{
|
||||
SuggestedWindowStart: time.Now().Add(-24 * time.Hour),
|
||||
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
||||
},
|
||||
}
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-acme": ariConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create cert expiring in 20 days with a cert version (needed for ARI lookup)
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-renew",
|
||||
Name: "ARI Cert",
|
||||
CommonName: "ari.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-acme",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-1", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI says renew now, so a renewal job should be created
|
||||
hasRenewalJob := false
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
hasRenewalJob = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRenewalJob {
|
||||
t.Errorf("expected renewal job when ARI ShouldRenewNow is true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI says NOT yet: window starts in the future
|
||||
ariConnector := &mockIssuerConnector{
|
||||
getRenewalInfoResult: &RenewalInfoResult{
|
||||
SuggestedWindowStart: time.Now().Add(72 * time.Hour),
|
||||
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
||||
},
|
||||
}
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-acme": ariConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Cert is within the 30-day threshold window (would normally trigger renewal),
|
||||
// but ARI says "not yet"
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-wait",
|
||||
Name: "ARI Wait Cert",
|
||||
CommonName: "ari-wait.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-acme",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-2", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI says not yet, so NO renewal job should be created
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
t.Errorf("expected no renewal job when ARI says not yet, but found one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-local": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-nil",
|
||||
Name: "No ARI Cert",
|
||||
CommonName: "no-ari.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-3", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI is nil (not supported), so threshold-based logic applies; cert is within 30-day window
|
||||
hasRenewalJob := false
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
hasRenewalJob = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRenewalJob {
|
||||
t.Errorf("expected renewal job via threshold fallback when ARI returns nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := newMockJobRepository()
|
||||
policyRepo := newMockRenewalPolicyRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
// ARI returns an error — should fall through to threshold-based renewal
|
||||
ariConnector := &mockIssuerConnector{
|
||||
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
||||
}
|
||||
issuerRegistry := map[string]IssuerConnector{
|
||||
"iss-acme": ariConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-ari-err",
|
||||
Name: "ARI Error Cert",
|
||||
CommonName: "ari-err.example.com",
|
||||
SANs: []string{},
|
||||
OwnerID: "owner-1",
|
||||
TeamID: "team-1",
|
||||
IssuerID: "iss-acme",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, 15),
|
||||
Tags: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||
{ID: "cv-4", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||
}
|
||||
|
||||
policy := &domain.RenewalPolicy{
|
||||
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
policyRepo.AddPolicy(policy)
|
||||
|
||||
err := svc.CheckExpiringCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// ARI failed but renewal should still happen via threshold fallback
|
||||
hasRenewalJob := false
|
||||
for _, job := range jobRepo.Jobs {
|
||||
if job.Type == domain.JobTypeRenewal {
|
||||
hasRenewalJob = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRenewalJob {
|
||||
t.Errorf("expected renewal job via threshold fallback when ARI errors")
|
||||
}
|
||||
}
|
||||
|
||||
// stringPtr is defined in notification_test.go
|
||||
|
||||
@@ -243,6 +243,25 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.ListErr != nil {
|
||||
return nil, m.ListErr
|
||||
}
|
||||
var result []*domain.Job
|
||||
for _, j := range m.Jobs {
|
||||
if j.AgentID != nil && *j.AgentID == agentID {
|
||||
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||
result = append(result, j)
|
||||
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||
result = append(result, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepo) AddJob(job *domain.Job) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -660,8 +679,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
|
||||
|
||||
// mockIssuerConnector is a test implementation of IssuerConnector
|
||||
type mockIssuerConnector struct {
|
||||
Result *IssuanceResult
|
||||
Err error
|
||||
Result *IssuanceResult
|
||||
Err error
|
||||
getRenewalInfoResult *RenewalInfoResult
|
||||
getRenewalInfoErr error
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
@@ -717,14 +738,14 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
if m.getRenewalInfoErr != nil {
|
||||
return nil, m.getRenewalInfoErr
|
||||
}
|
||||
now := time.Now()
|
||||
return &RenewalInfoResult{
|
||||
SuggestedWindowStart: now,
|
||||
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
|
||||
}, nil
|
||||
if m.getRenewalInfoResult != nil {
|
||||
return m.getRenewalInfoResult, nil
|
||||
}
|
||||
// Default: return nil, nil (issuer does not support ARI)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Constructor functions for mocks
|
||||
|
||||
@@ -65,6 +65,10 @@ func (m *mockVerificationJobRepo) GetPendingJobs(ctx context.Context, jobType do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVerificationJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newVerificationTestService creates a VerificationService wired with test doubles.
|
||||
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
|
||||
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
|
||||
|
||||
+27
-23
@@ -39,44 +39,48 @@ ON CONFLICT (id) DO NOTHING;
|
||||
-- 3. Issuers
|
||||
-- ============================================================
|
||||
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VALUES
|
||||
('iss-local', 'Local Dev CA', 'local', '{"ca_common_name": "CertCtl Demo CA", "validity_days": 90}', true, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('iss-acme-le', 'Let''s Encrypt Staging', 'acme', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||
('iss-stepca', 'step-ca Internal', 'stepca', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', true, NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
|
||||
('iss-acme-zs', 'ZeroSSL (EAB)', 'acme', '{"directory_url": "https://acme.zerossl.com/v2/DV90", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
|
||||
('iss-openssl', 'Custom OpenSSL CA', 'openssl', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days')
|
||||
('iss-local', 'Local Dev CA', 'GenericCA', '{"ca_common_name": "CertCtl Demo CA", "validity_days": 90}', true, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||
('iss-acme-le', 'Let''s Encrypt Staging', 'ACME', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'),
|
||||
('iss-stepca', 'step-ca Internal', 'StepCA', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', true, NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'),
|
||||
('iss-acme-zs', 'ZeroSSL (EAB)', 'ACME', '{"directory_url": "https://acme.zerossl.com/v2/DV90", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
|
||||
('iss-openssl', 'Custom OpenSSL CA', 'OpenSSL', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
|
||||
('iss-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'),
|
||||
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'),
|
||||
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||
('iss-googlecas','Google CAS', 'GoogleCAS', '{"project": "demo-project", "location": "us-central1", "ca_pool": "demo-pool"}', false, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Agents (8 agents across multiple platforms)
|
||||
-- ============================================================
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '120 days', 'demo_hash_1', 'linux', 'amd64', '10.0.1.10', '2.0.14'),
|
||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '90 days', 'demo_hash_2', 'linux', 'amd64', '10.0.2.20', '2.0.14'),
|
||||
('ag-lb-prod', 'lb-prod-agent', 'lb-prod-01.internal', 'online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '150 days', 'demo_hash_3', 'linux', 'amd64', '10.0.1.50', '2.0.14'),
|
||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '60 days', 'demo_hash_4', 'windows', 'amd64', '10.0.3.15', '2.0.12'),
|
||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '90 days', 'demo_hash_5', 'linux', 'arm64', '10.0.4.30', '2.0.14'),
|
||||
('ag-edge-01', 'edge-eu-agent', 'edge-eu-01.internal', 'online', NOW() - INTERVAL '50 seconds', NOW() - INTERVAL '45 days', 'demo_hash_6', 'linux', 'arm64', '10.0.5.10', '2.0.14'),
|
||||
('ag-k8s-prod', 'k8s-prod-agent', 'k8s-node-01.internal', 'online', NOW() - INTERVAL '10 seconds', NOW() - INTERVAL '30 days', 'demo_hash_7', 'linux', 'amd64', '10.0.6.10', '2.0.14'),
|
||||
('ag-mac-dev', 'mac-dev-agent', 'dev-mac-01.internal', 'online', NOW() - INTERVAL '60 seconds', NOW() - INTERVAL '15 days', 'demo_hash_8', 'darwin', 'arm64', '10.0.7.5', '2.0.14')
|
||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'Online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '120 days', 'demo_hash_1', 'linux', 'amd64', '10.0.1.10', '2.0.14'),
|
||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'Online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '90 days', 'demo_hash_2', 'linux', 'amd64', '10.0.2.20', '2.0.14'),
|
||||
('ag-lb-prod', 'lb-prod-agent', 'lb-prod-01.internal', 'Online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '150 days', 'demo_hash_3', 'linux', 'amd64', '10.0.1.50', '2.0.14'),
|
||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'Offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '60 days', 'demo_hash_4', 'windows', 'amd64', '10.0.3.15', '2.0.12'),
|
||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'Online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '90 days', 'demo_hash_5', 'linux', 'arm64', '10.0.4.30', '2.0.14'),
|
||||
('ag-edge-01', 'edge-eu-agent', 'edge-eu-01.internal', 'Online', NOW() - INTERVAL '50 seconds', NOW() - INTERVAL '45 days', 'demo_hash_6', 'linux', 'arm64', '10.0.5.10', '2.0.14'),
|
||||
('ag-k8s-prod', 'k8s-prod-agent', 'k8s-node-01.internal', 'Online', NOW() - INTERVAL '10 seconds', NOW() - INTERVAL '30 days', 'demo_hash_7', 'linux', 'amd64', '10.0.6.10', '2.0.14'),
|
||||
('ag-mac-dev', 'mac-dev-agent', 'dev-mac-01.internal', 'Online', NOW() - INTERVAL '60 seconds', NOW() - INTERVAL '15 days', 'demo_hash_8', 'darwin', 'arm64', '10.0.7.5', '2.0.14')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Sentinel agent for network-discovered certificates
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'online', NOW(), NOW() - INTERVAL '90 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.14')
|
||||
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'Online', NOW(), NOW() - INTERVAL '90 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.14')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Deployment Targets (8 targets across multiple connector types)
|
||||
-- ============================================================
|
||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at) VALUES
|
||||
('tgt-nginx-prod', 'NGINX Production', 'nginx', 'ag-web-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '120 days', NOW()),
|
||||
('tgt-nginx-staging', 'NGINX Staging', 'nginx', 'ag-web-staging', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW()),
|
||||
('tgt-haproxy-prod', 'HAProxy Production', 'haproxy', 'ag-lb-prod', '{"combined_pem_path": "/etc/haproxy/ssl/site.pem", "reload_command": "systemctl reload haproxy"}', true, NOW() - INTERVAL '150 days', NOW()),
|
||||
('tgt-apache-prod', 'Apache Production', 'apache', 'ag-web-prod', '{"cert_path": "/etc/httpd/ssl/cert.pem", "key_path": "/etc/httpd/ssl/key.pem", "chain_path": "/etc/httpd/ssl/chain.pem", "reload_command": "apachectl graceful"}', true, NOW() - INTERVAL '100 days', NOW()),
|
||||
('tgt-iis-prod', 'IIS Production', 'iis', 'ag-iis-prod', '{"site_name": "Default Web Site", "binding_info": "*:443:"}', true, NOW() - INTERVAL '60 days', NOW()),
|
||||
('tgt-traefik-prod', 'Traefik Production', 'traefik', 'ag-k8s-prod', '{"watch_dir": "/etc/traefik/dynamic/certs"}', true, NOW() - INTERVAL '30 days', NOW()),
|
||||
('tgt-caddy-prod', 'Caddy Production', 'caddy', 'ag-edge-01', '{"mode": "api", "admin_url": "http://localhost:2019"}', true, NOW() - INTERVAL '45 days', NOW()),
|
||||
('tgt-nginx-data', 'NGINX Data Services', 'nginx', 'ag-data-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW())
|
||||
('tgt-nginx-prod', 'NGINX Production', 'NGINX', 'ag-web-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '120 days', NOW()),
|
||||
('tgt-nginx-staging', 'NGINX Staging', 'NGINX', 'ag-web-staging', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW()),
|
||||
('tgt-haproxy-prod', 'HAProxy Production', 'HAProxy', 'ag-lb-prod', '{"combined_pem_path": "/etc/haproxy/ssl/site.pem", "reload_command": "systemctl reload haproxy"}', true, NOW() - INTERVAL '150 days', NOW()),
|
||||
('tgt-apache-prod', 'Apache Production', 'Apache', 'ag-web-prod', '{"cert_path": "/etc/httpd/ssl/cert.pem", "key_path": "/etc/httpd/ssl/key.pem", "chain_path": "/etc/httpd/ssl/chain.pem", "reload_command": "apachectl graceful"}', true, NOW() - INTERVAL '100 days', NOW()),
|
||||
('tgt-iis-prod', 'IIS Production', 'IIS', 'ag-iis-prod', '{"site_name": "Default Web Site", "binding_info": "*:443:"}', true, NOW() - INTERVAL '60 days', NOW()),
|
||||
('tgt-traefik-prod', 'Traefik Production', 'Traefik', 'ag-k8s-prod', '{"watch_dir": "/etc/traefik/dynamic/certs"}', true, NOW() - INTERVAL '30 days', NOW()),
|
||||
('tgt-caddy-prod', 'Caddy Production', 'Caddy', 'ag-edge-01', '{"mode": "api", "admin_url": "http://localhost:2019"}', true, NOW() - INTERVAL '45 days', NOW()),
|
||||
('tgt-nginx-data', 'NGINX Data Services', 'NGINX', 'ag-data-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW() - INTERVAL '90 days', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
@@ -126,7 +130,7 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. Managed Certificates (35 certs across multiple issuers and environments)
|
||||
-- 7. Managed Certificates (32 certs across multiple issuers and environments)
|
||||
-- ============================================================
|
||||
INSERT INTO managed_certificates (id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at) VALUES
|
||||
-- ---- Active, healthy production certs (Local CA) ----
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
-- =============================================================================
|
||||
-- certctl Test Environment — Seed Data
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Pre-populates the database with the minimum objects needed to test the full
|
||||
-- certificate lifecycle against real CA backends (Pebble, step-ca, Local CA).
|
||||
--
|
||||
-- Load order (handled by Docker entrypoint filename sorting):
|
||||
-- 001_schema.sql → ... → 008_verification.sql → 010_seed.sql → 015_seed_test.sql
|
||||
--
|
||||
-- All IDs use a "test-" prefix so they're easy to spot in the dashboard.
|
||||
-- =============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Team
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO teams (id, name, description)
|
||||
VALUES (
|
||||
'team-test-ops',
|
||||
'Test Operations',
|
||||
'Operations team for certctl testing environment'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Owner (references team)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO owners (id, name, email, team_id)
|
||||
VALUES (
|
||||
'owner-test-admin',
|
||||
'Test Admin',
|
||||
'admin@certctl-test.local',
|
||||
'team-test-ops'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Agent — must exist before the agent binary sends its first heartbeat
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The agent binary (certctl-agent container) connects with:
|
||||
-- CERTCTL_AGENT_ID=agent-test-01
|
||||
-- CERTCTL_AGENT_NAME=test-agent-01
|
||||
-- The heartbeat handler does a GET by ID — if the agent doesn't exist, it 404s.
|
||||
-- api_key_hash is SHA-256 of "test-agent-key-2026" (not used for auth, just stored).
|
||||
INSERT INTO agents (id, name, hostname, status, registered_at, api_key_hash, os, architecture, ip_address, version)
|
||||
VALUES (
|
||||
'agent-test-01',
|
||||
'test-agent-01',
|
||||
'certctl-test-agent',
|
||||
'online',
|
||||
NOW(),
|
||||
'cad819dee454889f686d678f691e5084e58ba149762eae2fda4d0bd2abaceefa',
|
||||
'linux',
|
||||
'amd64',
|
||||
'10.30.50.8',
|
||||
'test'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- The network scanner uses "server-scanner" as a virtual agent.
|
||||
-- It gets auto-created by the server code, but seed it here to avoid races.
|
||||
INSERT INTO agents (id, name, hostname, status, registered_at, api_key_hash)
|
||||
VALUES (
|
||||
'server-scanner',
|
||||
'server-scanner',
|
||||
'certctl-server',
|
||||
'online',
|
||||
NOW(),
|
||||
'no-key'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Issuers — one row per CA backend in the test environment
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- These are metadata records the dashboard reads. The actual CA connections
|
||||
-- are configured via env vars on the server container.
|
||||
|
||||
-- Local CA (self-signed, always available)
|
||||
INSERT INTO issuers (id, name, type, config, enabled)
|
||||
VALUES (
|
||||
'iss-local',
|
||||
'Local CA (Self-Signed)',
|
||||
'local',
|
||||
'{"mode": "self-signed", "description": "Built-in self-signed CA for testing"}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ACME via Pebble (simulates Let''s Encrypt)
|
||||
INSERT INTO issuers (id, name, type, config, enabled)
|
||||
VALUES (
|
||||
'iss-acme-staging',
|
||||
'ACME (Pebble Test CA)',
|
||||
'acme',
|
||||
'{"directory_url": "https://pebble:14000/dir", "email": "test@certctl.dev", "challenge_type": "http-01", "description": "Pebble ACME test server simulating Lets Encrypt"}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- step-ca (Smallstep private CA)
|
||||
INSERT INTO issuers (id, name, type, config, enabled)
|
||||
VALUES (
|
||||
'iss-stepca',
|
||||
'step-ca (Private CA)',
|
||||
'stepca',
|
||||
'{"url": "https://step-ca:9000", "provisioner": "admin", "description": "Smallstep private CA with JWK provisioner"}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Certificate Profile — TLS server certs, 90-day max
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO certificate_profiles (id, name, description, max_ttl_seconds, allowed_ekus, allowed_key_algorithms)
|
||||
VALUES (
|
||||
'prof-test-tls',
|
||||
'Test TLS Server',
|
||||
'Standard TLS server certificate profile for testing',
|
||||
7776000, -- 90 days
|
||||
'["serverAuth"]'::jsonb,
|
||||
'[{"algorithm": "ECDSA", "min_size": 256}, {"algorithm": "RSA", "min_size": 2048}]'::jsonb
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Certificate Profile — S/MIME email protection
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO certificate_profiles (id, name, description, max_ttl_seconds, allowed_ekus, allowed_key_algorithms)
|
||||
VALUES (
|
||||
'prof-test-smime',
|
||||
'Test S/MIME Email',
|
||||
'S/MIME certificate profile for email signing and encryption',
|
||||
31536000, -- 365 days
|
||||
'["emailProtection"]'::jsonb,
|
||||
'[{"algorithm": "ECDSA", "min_size": 256}, {"algorithm": "RSA", "min_size": 2048}]'::jsonb
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Deployment Target — NGINX (references agent-test-01)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The agent deploys certs to NGINX via the shared nginx_certs volume.
|
||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled)
|
||||
VALUES (
|
||||
'target-test-nginx',
|
||||
'Test NGINX',
|
||||
'NGINX',
|
||||
'agent-test-01',
|
||||
'{"cert_path": "/nginx-certs/cert.pem", "key_path": "/nginx-certs/key.pem", "chain_path": "/nginx-certs/chain.pem", "reload_command": "true", "validate_command": "true"}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -78,6 +78,17 @@ import {
|
||||
triggerNetworkScan,
|
||||
previewDigest,
|
||||
sendDigest,
|
||||
getJob,
|
||||
getJobVerification,
|
||||
getIssuer,
|
||||
getTarget,
|
||||
getPrometheusMetrics,
|
||||
getCertificateDeployments,
|
||||
getCRL,
|
||||
getOCSPStatus,
|
||||
updateIssuer,
|
||||
updateTarget,
|
||||
getPolicy,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
@@ -627,6 +638,50 @@ describe('API Client', () => {
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('createIssuer sends correct payload for VaultPKI type', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-vault', name: 'Vault PKI' }));
|
||||
const vaultPayload = {
|
||||
name: 'Vault PKI',
|
||||
type: 'VaultPKI',
|
||||
config: {
|
||||
addr: 'https://vault.internal:8200',
|
||||
token: 'hvs.test-token',
|
||||
mount: 'pki',
|
||||
role: 'web-certs',
|
||||
ttl: '8760h',
|
||||
},
|
||||
};
|
||||
await createIssuer(vaultPayload);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.type).toBe('VaultPKI');
|
||||
expect(body.config.addr).toBe('https://vault.internal:8200');
|
||||
expect(body.config.role).toBe('web-certs');
|
||||
});
|
||||
|
||||
it('createIssuer sends correct payload for DigiCert type', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-digicert', name: 'DigiCert' }));
|
||||
const digicertPayload = {
|
||||
name: 'DigiCert CertCentral',
|
||||
type: 'DigiCert',
|
||||
config: {
|
||||
api_key: 'test-api-key',
|
||||
org_id: '12345',
|
||||
product_type: 'ssl_basic',
|
||||
},
|
||||
};
|
||||
await createIssuer(digicertPayload);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.type).toBe('DigiCert');
|
||||
expect(body.config.org_id).toBe('12345');
|
||||
expect(body.config.product_type).toBe('ssl_basic');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Audit ──────────────────────────────────────────
|
||||
@@ -1006,4 +1061,148 @@ describe('API Client', () => {
|
||||
expect(result.message).toBe('digest sent');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Job Detail ────────────────────────────
|
||||
|
||||
describe('Job Detail', () => {
|
||||
it('getJob fetches single job by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'job-1', type: 'Deployment', status: 'Completed' }));
|
||||
const result = await getJob('job-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1');
|
||||
expect(result.id).toBe('job-1');
|
||||
expect(result.type).toBe('Deployment');
|
||||
});
|
||||
|
||||
it('getJobVerification fetches verification result', async () => {
|
||||
const verificationData = {
|
||||
job_id: 'job-1',
|
||||
target_id: 't-nginx1',
|
||||
verified: true,
|
||||
actual_fingerprint: 'abc123',
|
||||
expected_fingerprint: 'abc123',
|
||||
verified_at: '2026-03-28T12:00:00Z',
|
||||
};
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse(verificationData));
|
||||
const result = await getJobVerification('job-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1/verification');
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.actual_fingerprint).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Issuer Detail ─────────────────────────
|
||||
|
||||
describe('Issuer Detail', () => {
|
||||
it('getIssuer fetches single issuer by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-local', name: 'Local CA', type: 'local_ca', status: 'active' }));
|
||||
const result = await getIssuer('iss-local');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/issuers/iss-local');
|
||||
expect(result.name).toBe('Local CA');
|
||||
expect(result.type).toBe('local_ca');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Target Detail ─────────────────────────
|
||||
|
||||
describe('Target Detail', () => {
|
||||
it('getTarget fetches single target by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-nginx1', name: 'Web Server', type: 'nginx', hostname: 'web1.example.com' }));
|
||||
const result = await getTarget('t-nginx1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/targets/t-nginx1');
|
||||
expect(result.name).toBe('Web Server');
|
||||
expect(result.type).toBe('nginx');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prometheus Metrics ────────────────────
|
||||
|
||||
describe('Prometheus Metrics', () => {
|
||||
it('getPrometheusMetrics fetches text format', async () => {
|
||||
const metricsText = '# HELP certctl_certificate_total Total certificates\ncertctl_certificate_total 10';
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(metricsText),
|
||||
} as Response)
|
||||
);
|
||||
const result = await getPrometheusMetrics();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics/prometheus');
|
||||
expect(result).toContain('certctl_certificate_total');
|
||||
});
|
||||
|
||||
it('getPrometheusMetrics throws on error', async () => {
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('error'),
|
||||
} as Response)
|
||||
);
|
||||
await expect(getPrometheusMetrics()).rejects.toThrow('Prometheus metrics failed: 500');
|
||||
});
|
||||
|
||||
it('getPrometheusMetrics includes auth header', async () => {
|
||||
setApiKey('prom-key');
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('metrics'),
|
||||
} as Response)
|
||||
);
|
||||
await getPrometheusMetrics();
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.headers['Authorization']).toBe('Bearer prom-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Frontend Audit: New API Functions', () => {
|
||||
it('getCertificateDeployments sends GET with cert ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0 }));
|
||||
await getCertificateDeployments('mc-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/certificates/mc-1/deployments');
|
||||
});
|
||||
|
||||
it('getCRL sends GET to /crl', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ entries: [], total: 0 }));
|
||||
await getCRL();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/crl');
|
||||
});
|
||||
|
||||
it('getOCSPStatus sends GET with issuer and serial', async () => {
|
||||
const buf = new ArrayBuffer(8);
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: () => Promise.resolve(buf),
|
||||
} as Response)
|
||||
);
|
||||
await getOCSPStatus('iss-local', 'ABC123');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/ocsp/iss-local/ABC123');
|
||||
});
|
||||
|
||||
it('updateIssuer sends PUT with data', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-1', name: 'Updated' }));
|
||||
await updateIssuer('iss-1', { name: 'Updated' });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers/iss-1');
|
||||
expect(init.method).toBe('PUT');
|
||||
});
|
||||
|
||||
it('updateTarget sends PUT with data', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-1', name: 'Updated' }));
|
||||
await updateTarget('t-1', { name: 'Updated' });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/targets/t-1');
|
||||
expect(init.method).toBe('PUT');
|
||||
});
|
||||
|
||||
it('getPolicy sends GET with policy ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', name: 'Test' }));
|
||||
await getPolicy('pol-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,26 @@ export const exportCertificatePKCS12 = (id: string, password: string = '') => {
|
||||
});
|
||||
};
|
||||
|
||||
// Certificate Deployments
|
||||
export const getCertificateDeployments = (id: string, params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/certificates/${id}/deployments?${qs}`);
|
||||
};
|
||||
|
||||
// CRL / OCSP
|
||||
export const getCRL = () =>
|
||||
fetchJSON<{ version: number; entries: unknown[]; total: number; generated_at: string }>(`${BASE}/crl`);
|
||||
|
||||
export const getOCSPStatus = (issuerId: string, serial: string) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
return fetch(`${BASE}/ocsp/${issuerId}/${serial}`, { headers })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`OCSP request failed: ${r.status}`);
|
||||
return r.arrayBuffer();
|
||||
});
|
||||
};
|
||||
|
||||
// Agents
|
||||
export const getAgents = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
@@ -170,6 +190,9 @@ export const createPolicy = (data: Partial<PolicyRule>) =>
|
||||
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
|
||||
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const getPolicy = (id: string) =>
|
||||
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`);
|
||||
|
||||
export const deletePolicy = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
|
||||
|
||||
@@ -188,6 +211,9 @@ export const createIssuer = (data: Partial<Issuer>) =>
|
||||
export const testIssuerConnection = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
|
||||
|
||||
export const updateIssuer = (id: string, data: Partial<Issuer>) =>
|
||||
fetchJSON<Issuer>(`${BASE}/issuers/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteIssuer = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
|
||||
|
||||
@@ -200,6 +226,9 @@ export const getTargets = (params: Record<string, string> = {}) => {
|
||||
export const createTarget = (data: Partial<Target>) =>
|
||||
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updateTarget = (id: string, data: Partial<Target>) =>
|
||||
fetchJSON<Target>(`${BASE}/targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteTarget = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||
|
||||
@@ -365,5 +394,32 @@ export const previewDigest = () => {
|
||||
export const sendDigest = () =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
|
||||
|
||||
// Jobs (single)
|
||||
export const getJob = (id: string) =>
|
||||
fetchJSON<Job>(`${BASE}/jobs/${id}`);
|
||||
|
||||
// Job Verification
|
||||
export const getJobVerification = (id: string) =>
|
||||
fetchJSON<{ job_id: string; target_id: string; verified: boolean; actual_fingerprint: string; expected_fingerprint: string; verified_at: string; error?: string }>(`${BASE}/jobs/${id}/verification`);
|
||||
|
||||
// Issuers (single)
|
||||
export const getIssuer = (id: string) =>
|
||||
fetchJSON<Issuer>(`${BASE}/issuers/${id}`);
|
||||
|
||||
// Targets (single)
|
||||
export const getTarget = (id: string) =>
|
||||
fetchJSON<Target>(`${BASE}/targets/${id}`);
|
||||
|
||||
// Prometheus metrics (text format)
|
||||
export const getPrometheusMetrics = () => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
return fetch(`${BASE}/metrics/prometheus`, { headers })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`Prometheus metrics failed: ${r.status}`);
|
||||
return r.text();
|
||||
});
|
||||
};
|
||||
|
||||
// Health
|
||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||
|
||||
@@ -18,7 +18,10 @@ export interface Certificate {
|
||||
expires_at: string;
|
||||
revoked_at?: string;
|
||||
revocation_reason?: string;
|
||||
target_ids?: string[];
|
||||
tags: Record<string, string>;
|
||||
last_renewal_at?: string;
|
||||
last_deployment_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -45,6 +48,8 @@ export interface CertificateVersion {
|
||||
csr_pem: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
key_algorithm?: string;
|
||||
key_size?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -70,6 +75,8 @@ export interface Job {
|
||||
id: string;
|
||||
certificate_id: string;
|
||||
type: string;
|
||||
target_id?: string;
|
||||
agent_id?: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
@@ -133,7 +140,10 @@ export interface Issuer {
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
/** Backend returns enabled boolean; status is derived from this */
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Target {
|
||||
@@ -145,6 +155,7 @@ export interface Target {
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface KeyAlgorithmRule {
|
||||
|
||||
@@ -19,6 +19,8 @@ const nav = [
|
||||
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
|
||||
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
|
||||
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
||||
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
];
|
||||
|
||||
@@ -69,7 +71,7 @@ export default function Layout() {
|
||||
</nav>
|
||||
|
||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.14</span>
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.20</span>
|
||||
{authRequired && (
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -23,6 +23,9 @@ const statusStyles: Record<string, string> = {
|
||||
Unmanaged: 'badge-warning',
|
||||
Managed: 'badge-success',
|
||||
Dismissed: 'badge-neutral',
|
||||
// Issuer statuses
|
||||
Enabled: 'badge-success',
|
||||
Disabled: 'badge-neutral',
|
||||
// Notification statuses
|
||||
sent: 'badge-success',
|
||||
pending: 'badge-warning',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Full config viewer modal with sensitive field redaction.
|
||||
* Replaces the 60-char truncation in the issuers table.
|
||||
* Reusable for targets in M35 — no IssuersPage-specific imports.
|
||||
*/
|
||||
import { isSensitiveKey } from '../../config/issuerTypes';
|
||||
|
||||
interface ConfigDetailModalProps {
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ConfigDetailModal({ title, config, onClose }: ConfigDetailModalProps) {
|
||||
const entries = Object.entries(config);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div className="bg-surface border border-surface-border rounded-lg shadow-lg max-w-lg w-full mx-4">
|
||||
<div className="border-b border-surface-border px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||
<button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 max-h-96 overflow-y-auto">
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{entries.map(([key, val]) => {
|
||||
const redacted = isSensitiveKey(key);
|
||||
return (
|
||||
<div key={key} className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{key}</span>
|
||||
<span className="text-sm text-ink font-mono text-right max-w-xs break-all">
|
||||
{redacted ? '********' : String(val ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Renders config fields from an IssuerTypeConfig.configFields definition.
|
||||
* Handles sensitive field masking. M34 will reuse this directly for its
|
||||
* dynamic config wizard. M35 can reuse it for target config forms.
|
||||
*/
|
||||
import type { ConfigField } from '../../config/issuerTypes';
|
||||
|
||||
interface ConfigFormProps {
|
||||
fields: ConfigField[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
/** When true, sensitive fields show as ******** with a "Change" button.
|
||||
* Used in edit mode — empty value means "keep existing". */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfigForm({ fields, values, onChange, editMode }: ConfigFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fields.map((field) => (
|
||||
<ConfigFieldInput
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
editMode={editMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
editMode,
|
||||
}: {
|
||||
field: ConfigField;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
editMode?: boolean;
|
||||
}) {
|
||||
const inputCls =
|
||||
'w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors';
|
||||
|
||||
// In edit mode, sensitive fields that haven't been touched show as masked
|
||||
if (editMode && field.sensitive && value === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-ink-muted font-mono">********</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="text-xs text-brand-400 hover:text-brand-500"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<select
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">Select {field.label}</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<textarea
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
className={`${inputCls} font-mono text-xs`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number | string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
placeholder={field.placeholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text or password
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<input
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ field }: { field: ConfigField }) {
|
||||
return (
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600 ml-1">*</span>}
|
||||
{field.sensitive && (
|
||||
<span className="ml-2 text-xs text-yellow-500 font-normal">sensitive</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Issuer type selector grid. Used in both the catalog view and create wizard.
|
||||
* M34 will reuse this for its 3-step wizard (Select Type step).
|
||||
*/
|
||||
import { issuerTypes, type IssuerTypeConfig } from '../../config/issuerTypes';
|
||||
|
||||
interface TypeSelectorProps {
|
||||
onSelect: (typeId: string) => void;
|
||||
/** Filter to only show these type IDs. If not provided, shows all non-comingSoon types. */
|
||||
filterIds?: string[];
|
||||
}
|
||||
|
||||
export default function TypeSelector({ onSelect, filterIds }: TypeSelectorProps) {
|
||||
const types = filterIds
|
||||
? issuerTypes.filter(t => filterIds.includes(t.id))
|
||||
: issuerTypes.filter(t => !t.comingSoon);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{types.map((type: IssuerTypeConfig) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onSelect(type.id)}
|
||||
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-opacity-5 transition-all text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{type.icon}</span>
|
||||
<span className="font-medium text-ink">{type.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Shared issuer type configuration.
|
||||
* Imported by IssuersPage.tsx (M33), and will be reused by M34 (Dynamic Issuer Config)
|
||||
* for its 3-step wizard config forms.
|
||||
*/
|
||||
|
||||
export interface ConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'text' | 'password' | 'number' | 'select' | 'textarea';
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
/** Mark fields that contain secrets (tokens, keys, passwords).
|
||||
* Display as ******** when viewing existing config. M34 will use this
|
||||
* for AES-GCM encryption decisions. */
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuerTypeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
configFields: ConfigField[];
|
||||
/** If true, this type is not yet implemented — show as "Coming Soon" */
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical type label map. Keys match what the backend API returns.
|
||||
* DB stores: local, acme, stepca, openssl, VaultPKI, DigiCert
|
||||
*/
|
||||
export const typeLabels: Record<string, string> = {
|
||||
local: 'Local CA',
|
||||
local_ca: 'Local CA', // backward compat (some frontend references)
|
||||
acme: 'ACME',
|
||||
stepca: 'step-ca',
|
||||
openssl: 'OpenSSL/Custom',
|
||||
VaultPKI: 'Vault PKI',
|
||||
DigiCert: 'DigiCert',
|
||||
Sectigo: 'Sectigo SCM',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
/**
|
||||
* All supported issuer types + 2 "Coming Soon" stubs.
|
||||
* Order: most common first, coming-soon last.
|
||||
*/
|
||||
export const issuerTypes: IssuerTypeConfig[] = [
|
||||
{
|
||||
id: 'acme',
|
||||
name: 'ACME',
|
||||
description: "Let's Encrypt, ZeroSSL, or any ACME-compatible CA",
|
||||
icon: '\uD83D\uDD12',
|
||||
configFields: [
|
||||
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
|
||||
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
|
||||
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
|
||||
{ key: 'eab_kid', label: 'EAB Key ID', placeholder: 'External Account Binding Key ID (optional)', required: false },
|
||||
{ key: 'eab_hmac', label: 'EAB HMAC Key', placeholder: 'External Account Binding HMAC key', required: false, type: 'password', sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Local CA',
|
||||
description: 'Self-signed or subordinate CA for internal certificates',
|
||||
icon: '\uD83C\uDFE0',
|
||||
configFields: [
|
||||
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
|
||||
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stepca',
|
||||
name: 'step-ca',
|
||||
description: 'Smallstep private CA with JWK provisioner auth',
|
||||
icon: '\uD83D\uDC63',
|
||||
configFields: [
|
||||
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
|
||||
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
|
||||
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'VaultPKI',
|
||||
name: 'Vault PKI',
|
||||
description: 'HashiCorp Vault PKI secrets engine',
|
||||
icon: '\uD83D\uDD10',
|
||||
configFields: [
|
||||
{ key: 'addr', label: 'Vault Address', placeholder: 'https://vault.internal:8200', required: true },
|
||||
{ key: 'token', label: 'Vault Token', placeholder: 'hvs.CAES...', required: true, type: 'password', sensitive: true },
|
||||
{ key: 'mount', label: 'PKI Mount Path', placeholder: 'pki', required: false, defaultValue: 'pki' },
|
||||
{ key: 'role', label: 'PKI Role Name', placeholder: 'web-certs', required: true },
|
||||
{ key: 'ttl', label: 'Certificate TTL', placeholder: '8760h', required: false, defaultValue: '8760h' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'DigiCert',
|
||||
name: 'DigiCert CertCentral',
|
||||
description: 'DigiCert CertCentral for OV/EV certificates',
|
||||
icon: '\uD83C\uDF10',
|
||||
configFields: [
|
||||
{ key: 'api_key', label: 'DigiCert API Key', placeholder: 'Your DigiCert API key', required: true, type: 'password', sensitive: true },
|
||||
{ key: 'org_id', label: 'Organization ID', placeholder: '12345', required: true },
|
||||
{ key: 'product_type', label: 'Product Type', type: 'select', options: ['ssl_basic', 'ssl_plus', 'ssl_wildcard', 'ssl_ev_basic', 'ssl_ev_plus'], required: false, defaultValue: 'ssl_basic' },
|
||||
{ key: 'base_url', label: 'API Base URL Override', placeholder: 'https://www.digicert.com/services/v2', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openssl',
|
||||
name: 'OpenSSL/Custom',
|
||||
description: 'Script-based signing with your own CA',
|
||||
icon: '\uD83D\uDD27',
|
||||
configFields: [
|
||||
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
|
||||
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
|
||||
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
|
||||
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'Sectigo',
|
||||
name: 'Sectigo SCM',
|
||||
description: 'Sectigo Certificate Manager for DV, OV, and EV certificates',
|
||||
icon: '\uD83D\uDD10',
|
||||
configFields: [
|
||||
{ key: 'customer_uri', label: 'Customer URI', required: true, placeholder: 'your-org-uri' },
|
||||
{ key: 'login', label: 'API Login', required: true, placeholder: 'api-account-name' },
|
||||
{ key: 'password', label: 'API Password', required: true, sensitive: true, type: 'password' },
|
||||
{ key: 'org_id', label: 'Organization ID', required: true, placeholder: '12345', type: 'number' },
|
||||
{ key: 'cert_type', label: 'Certificate Type ID', required: false, placeholder: '423', type: 'number' },
|
||||
{ key: 'term', label: 'Validity (days)', required: false, placeholder: '365', type: 'number' },
|
||||
{ key: 'base_url', label: 'Base URL', required: false, placeholder: 'https://cert-manager.com/api' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'GoogleCAS',
|
||||
name: 'Google CAS',
|
||||
description: 'Google Cloud Certificate Authority Service \u2014 managed private CA on GCP',
|
||||
icon: '\u2601\uFE0F',
|
||||
configFields: [
|
||||
{ key: 'project', label: 'GCP Project ID', required: true, placeholder: 'my-gcp-project' },
|
||||
{ key: 'location', label: 'Location', required: true, placeholder: 'us-central1' },
|
||||
{ key: 'ca_pool', label: 'CA Pool', required: true, placeholder: 'my-ca-pool' },
|
||||
{ key: 'credentials', label: 'Service Account JSON Path', required: true, placeholder: '/path/to/credentials.json', sensitive: true },
|
||||
{ key: 'ttl', label: 'Default TTL', required: false, placeholder: '8760h' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entrust',
|
||||
name: 'Entrust',
|
||||
description: 'Entrust Certificate Services \u2014 coming soon',
|
||||
icon: '\uD83D\uDCE6',
|
||||
configFields: [],
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Sensitive config key patterns for redaction in display */
|
||||
const SENSITIVE_PATTERNS = ['password', 'secret', 'token', 'key', 'hmac', 'private'];
|
||||
|
||||
/** Check if a config key should be redacted */
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return SENSITIVE_PATTERNS.some(p => lower.includes(p));
|
||||
}
|
||||
|
||||
/** Redact sensitive values in a config object */
|
||||
export function redactConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).map(([k, v]) => [k, isSensitiveKey(k) ? '********' : v])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns catalog status info per issuer type.
|
||||
* M36 (Onboarding) will use this to detect first-run state.
|
||||
*/
|
||||
export function getIssuerCatalogStatus(
|
||||
configuredIssuers: { type: string }[]
|
||||
): { type: IssuerTypeConfig; status: 'connected' | 'available' | 'coming_soon'; count: number }[] {
|
||||
return issuerTypes.map(t => {
|
||||
if (t.comingSoon) {
|
||||
return { type: t, status: 'coming_soon' as const, count: 0 };
|
||||
}
|
||||
// Match both the canonical id and common aliases
|
||||
const aliases: Record<string, string[]> = {
|
||||
local: ['local', 'local_ca'],
|
||||
};
|
||||
const matchIds = aliases[t.id] || [t.id];
|
||||
const matching = configuredIssuers.filter(i => matchIds.includes(i.type));
|
||||
return {
|
||||
type: t,
|
||||
status: matching.length > 0 ? 'connected' as const : 'available' as const,
|
||||
count: matching.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,11 @@ import ShortLivedPage from './pages/ShortLivedPage';
|
||||
import AgentFleetPage from './pages/AgentFleetPage';
|
||||
import DiscoveryPage from './pages/DiscoveryPage';
|
||||
import NetworkScanPage from './pages/NetworkScanPage';
|
||||
import DigestPage from './pages/DigestPage';
|
||||
import ObservabilityPage from './pages/ObservabilityPage';
|
||||
import JobDetailPage from './pages/JobDetailPage';
|
||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||
import TargetDetailPage from './pages/TargetDetailPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||
<Route path="fleet" element={<AgentFleetPage />} />
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="jobs/:id" element={<JobDetailPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="targets/:id" element={<TargetDetailPage />} />
|
||||
<Route path="owners" element={<OwnersPage />} />
|
||||
<Route path="teams" element={<TeamsPage />} />
|
||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
||||
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="short-lived" element={<ShortLivedPage />} />
|
||||
<Route path="discovery" element={<DiscoveryPage />} />
|
||||
<Route path="network-scans" element={<NetworkScanPage />} />
|
||||
<Route path="digest" element={<DigestPage />} />
|
||||
<Route path="observability" element={<ObservabilityPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -13,6 +13,14 @@ const OS_COLORS: Record<string, string> = {
|
||||
unknown: '#64748b',
|
||||
};
|
||||
|
||||
const OS_DISPLAY_NAMES: Record<string, string> = {
|
||||
darwin: 'macOS',
|
||||
};
|
||||
|
||||
function displayOS(os: string): string {
|
||||
return OS_DISPLAY_NAMES[os.toLowerCase()] || os;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
Online: '#10b981',
|
||||
Offline: '#ef4444',
|
||||
@@ -86,7 +94,7 @@ export default function AgentFleetPage() {
|
||||
return acc;
|
||||
}, {});
|
||||
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
|
||||
name,
|
||||
name: displayOS(name),
|
||||
value,
|
||||
fill: OS_COLORS[name.toLowerCase()] || '#64748b',
|
||||
}));
|
||||
@@ -216,7 +224,7 @@ export default function AgentFleetPage() {
|
||||
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
|
||||
/>
|
||||
<h4 className="text-sm font-medium text-ink">
|
||||
{group.os} / {group.arch}
|
||||
{displayOS(group.os)} / {group.arch}
|
||||
</h4>
|
||||
<span className="text-xs text-ink-faint">
|
||||
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners } from '../api/client';
|
||||
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers } from '../api/client';
|
||||
import { REVOCATION_REASONS } from '../api/types';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
@@ -16,20 +16,66 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
name: '',
|
||||
id: '',
|
||||
common_name: '',
|
||||
sans: '',
|
||||
environment: 'production',
|
||||
issuer_id: '',
|
||||
certificate_profile_id: '',
|
||||
owner_id: '',
|
||||
team_id: '',
|
||||
renewal_policy_id: '',
|
||||
tags: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { data: profilesResp } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => getProfiles(),
|
||||
});
|
||||
const { data: issuersResp } = useQuery({
|
||||
queryKey: ['issuers'],
|
||||
queryFn: () => getIssuers(),
|
||||
});
|
||||
const profiles = profilesResp?.data || [];
|
||||
const issuers = issuersResp?.data || [];
|
||||
|
||||
const selectedProfile = profiles.find(p => p.id === form.certificate_profile_id);
|
||||
const ttlLabel = selectedProfile
|
||||
? selectedProfile.max_ttl_seconds < 3600
|
||||
? `${Math.round(selectedProfile.max_ttl_seconds / 60)}m`
|
||||
: selectedProfile.max_ttl_seconds < 86400
|
||||
? `${Math.round(selectedProfile.max_ttl_seconds / 3600)}h`
|
||||
: `${Math.round(selectedProfile.max_ttl_seconds / 86400)}d`
|
||||
: null;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => createCertificate(form),
|
||||
mutationFn: () => {
|
||||
const payload: Record<string, unknown> = { ...form };
|
||||
// Convert comma-separated SANs to array
|
||||
if (form.sans.trim()) {
|
||||
payload.sans = form.sans.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else {
|
||||
delete payload.sans;
|
||||
}
|
||||
// Convert comma-separated key=value tags to object
|
||||
if (form.tags.trim()) {
|
||||
const tags: Record<string, string> = {};
|
||||
form.tags.split(',').forEach(pair => {
|
||||
const [k, ...v] = pair.split('=');
|
||||
if (k?.trim()) tags[k.trim()] = v.join('=').trim();
|
||||
});
|
||||
payload.tags = tags;
|
||||
} else {
|
||||
delete payload.tags;
|
||||
}
|
||||
return createCertificate(payload);
|
||||
},
|
||||
onSuccess: () => onSuccess(),
|
||||
onError: (err: Error) => setError(err.message),
|
||||
});
|
||||
|
||||
const inputClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20";
|
||||
const selectClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
@@ -39,57 +85,90 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Name *</label>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
className={inputClass}
|
||||
placeholder="API Production Cert" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
|
||||
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
className={inputClass}
|
||||
placeholder="mc-api-prod (auto-generated if empty)" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Common Name *</label>
|
||||
<input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_name: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
className={inputClass}
|
||||
placeholder="api.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">SANs (comma-separated)</label>
|
||||
<input value={form.sans} onChange={e => setForm(f => ({ ...f, sans: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="api.example.com, api-v2.example.com" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Issuer *</label>
|
||||
<select value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
|
||||
className={selectClass}>
|
||||
<option value="">Select issuer...</option>
|
||||
{issuers.map(i => (
|
||||
<option key={i.id} value={i.id}>{i.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">
|
||||
Profile {ttlLabel && <span className="text-brand-400 font-medium">(TTL: {ttlLabel})</span>}
|
||||
</label>
|
||||
<select value={form.certificate_profile_id} onChange={e => setForm(f => ({ ...f, certificate_profile_id: e.target.value }))}
|
||||
className={selectClass}>
|
||||
<option value="">Select profile...</option>
|
||||
{profiles.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}{p.max_ttl_seconds ? ` (${p.max_ttl_seconds < 3600 ? `${Math.round(p.max_ttl_seconds / 60)}m` : p.max_ttl_seconds < 86400 ? `${Math.round(p.max_ttl_seconds / 3600)}h` : `${Math.round(p.max_ttl_seconds / 86400)}d`})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Environment</label>
|
||||
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink">
|
||||
className={selectClass}>
|
||||
<option value="production">Production</option>
|
||||
<option value="staging">Staging</option>
|
||||
<option value="development">Development</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Issuer ID *</label>
|
||||
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
placeholder="iss-local" />
|
||||
<label className="text-xs text-ink-muted block mb-1">Policy</label>
|
||||
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="rp-standard" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Owner ID</label>
|
||||
<label className="text-xs text-ink-muted block mb-1">Owner</label>
|
||||
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
className={inputClass}
|
||||
placeholder="o-alice" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Team ID</label>
|
||||
<label className="text-xs text-ink-muted block mb-1">Team</label>
|
||||
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
className={inputClass}
|
||||
placeholder="t-platform" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Policy ID</label>
|
||||
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
|
||||
placeholder="rp-standard" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Tags</label>
|
||||
<input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="env=prod, team=platform, app=api" />
|
||||
<p className="text-xs text-ink-faint mt-0.5">Comma-separated key=value pairs</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
@@ -245,15 +324,25 @@ export default function CertificatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState('');
|
||||
const [issuerFilter, setIssuerFilter] = useState('');
|
||||
const [ownerFilter, setOwnerFilter] = useState('');
|
||||
const [profileFilter, setProfileFilter] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
|
||||
const [showBulkReassign, setShowBulkReassign] = useState(false);
|
||||
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
|
||||
|
||||
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
|
||||
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
|
||||
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (envFilter) params.environment = envFilter;
|
||||
if (issuerFilter) params.issuer_id = issuerFilter;
|
||||
if (ownerFilter) params.owner_id = ownerFilter;
|
||||
if (profileFilter) params.profile_id = profileFilter;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificates', params],
|
||||
@@ -302,7 +391,8 @@ export default function CertificatesPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'env', label: 'Environment', render: (c) => <span className="text-ink-muted">{c.environment || '—'}</span> },
|
||||
{ key: 'last_renewal', label: 'Last Renewal', render: (c) => <span className="text-xs text-ink-muted">{c.last_renewal_at ? formatDate(c.last_renewal_at) : '—'}</span> },
|
||||
{ key: 'last_deploy', label: 'Last Deploy', render: (c) => <span className="text-xs text-ink-muted">{c.last_deployment_at ? formatDate(c.last_deployment_at) : '—'}</span> },
|
||||
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> },
|
||||
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-ink-muted text-xs">{c.owner_id}</span> },
|
||||
];
|
||||
@@ -382,6 +472,36 @@ export default function CertificatesPage() {
|
||||
<option value="staging">Staging</option>
|
||||
<option value="development">Development</option>
|
||||
</select>
|
||||
<select
|
||||
value={issuerFilter}
|
||||
onChange={e => setIssuerFilter(e.target.value)}
|
||||
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||
>
|
||||
<option value="">All issuers</option>
|
||||
{issuersData?.data?.map(i => (
|
||||
<option key={i.id} value={i.id}>{i.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={ownerFilter}
|
||||
onChange={e => setOwnerFilter(e.target.value)}
|
||||
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||
>
|
||||
<option value="">All owners</option>
|
||||
{ownersData?.data?.map(o => (
|
||||
<option key={o.id} value={o.id}>{o.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={profileFilter}
|
||||
onChange={e => setProfileFilter(e.target.value)}
|
||||
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||
>
|
||||
<option value="">All profiles</option>
|
||||
{profilesData?.data?.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { previewDigest, sendDigest } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
|
||||
export default function DigestPage() {
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const { data: html, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['digest-preview'],
|
||||
queryFn: previewDigest,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: sendDigest,
|
||||
onSuccess: () => setShowConfirm(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Certificate Digest"
|
||||
subtitle="Preview and send the scheduled certificate digest email"
|
||||
action={
|
||||
<button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={!html || sendMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
Send Digest Now
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{sendMutation.isSuccess && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
|
||||
Digest sent successfully.
|
||||
</div>
|
||||
)}
|
||||
{sendMutation.isError && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
Failed to send digest: {(sendMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading digest preview...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ErrorState
|
||||
error={error as Error}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{html && (
|
||||
<div className="bg-white border border-surface-border rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-surface border-b border-surface-border flex items-center justify-between">
|
||||
<span className="text-xs text-ink-muted font-medium">Email Preview</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-xs text-brand-400 hover:text-brand-500"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
srcDoc={html}
|
||||
title="Digest Preview"
|
||||
className="w-full border-0"
|
||||
style={{ minHeight: '600px' }}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-surface-border">
|
||||
<h3 className="text-lg font-semibold text-ink">Send Digest</h3>
|
||||
<p className="text-sm text-ink-muted mt-1">
|
||||
This will send the certificate digest email to all configured recipients.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
|
||||
<button onClick={() => setShowConfirm(false)} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={sendMutation.isPending}
|
||||
className="px-4 py-2 text-sm text-white bg-brand-500 hover:bg-brand-600 rounded disabled:opacity-50"
|
||||
>
|
||||
{sendMutation.isPending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -197,6 +197,18 @@ export default function DiscoveryPage() {
|
||||
label: 'Expiry',
|
||||
render: (c) => <span className="text-xs">{formatExpiry(c.not_after)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'key_info',
|
||||
label: 'Key',
|
||||
render: (c) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-ink-muted">{c.key_algorithm}{c.key_size ? ` ${c.key_size}` : ''}</span>
|
||||
{c.is_ca && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">CA</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fingerprint',
|
||||
label: 'Fingerprint',
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Certificate, Issuer } from '../api/types';
|
||||
import { typeLabels, redactConfig } from '../config/issuerTypes';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Derive display status from backend enabled boolean */
|
||||
function issuerStatus(issuer: Issuer): string {
|
||||
if (issuer.enabled !== undefined) {
|
||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
return issuer.status || 'Unknown';
|
||||
}
|
||||
|
||||
export default function IssuerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: issuer, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuer', id],
|
||||
queryFn: () => getIssuer(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: certsData } = useQuery({
|
||||
queryKey: ['certificates', { issuer_id: id }],
|
||||
queryFn: () => getCertificates({ issuer_id: id! }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => testIssuerConnection(id!),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Issuer Details" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !issuer) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Issuer Details" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading issuer...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const safeConfig = issuer.config ? redactConfig(issuer.config) : {};
|
||||
|
||||
const certColumns: Column<Certificate>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Certificate',
|
||||
render: (c) => (
|
||||
<div>
|
||||
<div className="font-medium text-ink text-sm">{c.common_name}</div>
|
||||
<div className="text-xs text-ink-faint font-mono">{c.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
|
||||
{ key: 'expires', label: 'Expires', render: (c) => <span className="text-xs text-ink-muted">{formatDateTime(c.expires_at)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={issuer.name}
|
||||
subtitle={typeLabels[issuer.type] || issuer.type}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/issuers?edit=${issuer.id}`)}
|
||||
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{testMutation.isSuccess && (
|
||||
<div className="px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
|
||||
Connection test passed.
|
||||
</div>
|
||||
)}
|
||||
{testMutation.isError && (
|
||||
<div className="px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
Connection test failed: {(testMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Issuer info */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Issuer Information</h3>
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
|
||||
<InfoRow label="Name" value={issuer.name} />
|
||||
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
|
||||
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
|
||||
</div>
|
||||
|
||||
{/* Config (redacted) */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
|
||||
{Object.keys(safeConfig).length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{Object.entries(safeConfig).map(([key, val]) => (
|
||||
<InfoRow key={key} label={key} value={
|
||||
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
|
||||
} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issued certificates */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||
Issued Certificates {certsData ? `(${certsData.total})` : ''}
|
||||
</h3>
|
||||
<DataTable
|
||||
columns={certColumns}
|
||||
data={certsData?.data || []}
|
||||
isLoading={!certsData}
|
||||
emptyMessage="No certificates issued by this issuer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+206
-210
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -8,83 +9,27 @@ import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Issuer } from '../api/types';
|
||||
import { issuerTypes, typeLabels, getIssuerCatalogStatus, type IssuerTypeConfig } from '../config/issuerTypes';
|
||||
import TypeSelector from '../components/issuer/TypeSelector';
|
||||
import ConfigForm from '../components/issuer/ConfigForm';
|
||||
import ConfigDetailModal from '../components/issuer/ConfigDetailModal';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME',
|
||||
stepca: 'step-ca',
|
||||
openssl: 'OpenSSL/Custom',
|
||||
vault: 'Vault PKI',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
interface IssuerConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
type?: string;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
/** Derive display status from backend enabled boolean */
|
||||
function issuerStatus(issuer: Issuer): string {
|
||||
if (issuer.enabled !== undefined) {
|
||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
// Fallback for legacy data that may have status string
|
||||
return issuer.status || 'Unknown';
|
||||
}
|
||||
|
||||
interface IssuerTypeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
configFields: IssuerConfigField[];
|
||||
}
|
||||
|
||||
const issuerTypes: IssuerTypeConfig[] = [
|
||||
{
|
||||
id: 'local_ca',
|
||||
name: 'Local CA',
|
||||
description: 'Self-signed or subordinate CA for certificate issuance',
|
||||
configFields: [
|
||||
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
|
||||
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'acme',
|
||||
name: 'ACME',
|
||||
description: "Let's Encrypt or other ACME-compatible CA",
|
||||
configFields: [
|
||||
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
|
||||
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
|
||||
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stepca',
|
||||
name: 'step-ca',
|
||||
description: 'Smallstep private CA',
|
||||
configFields: [
|
||||
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
|
||||
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
|
||||
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openssl',
|
||||
name: 'OpenSSL/Custom',
|
||||
description: 'Script-based signing with your own CA',
|
||||
configFields: [
|
||||
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
|
||||
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
|
||||
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
|
||||
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function IssuersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createStep, setCreateStep] = useState<'type' | 'config'>('type');
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [createForm, setCreateForm] = useState<Record<string, unknown>>({});
|
||||
const [preselectedType, setPreselectedType] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuers'],
|
||||
@@ -108,19 +53,31 @@ export default function IssuersPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
setPreselectedType(null);
|
||||
},
|
||||
});
|
||||
|
||||
const catalogStatus = useMemo(
|
||||
() => getIssuerCatalogStatus(data?.data || []),
|
||||
[data?.data]
|
||||
);
|
||||
|
||||
// Filter issuers by type
|
||||
const filteredIssuers = useMemo(() => {
|
||||
if (!data?.data) return [];
|
||||
if (!typeFilter) return data.data;
|
||||
return data.data.filter(i => i.type === typeFilter);
|
||||
}, [data?.data, typeFilter]);
|
||||
|
||||
const columns: Column<Issuer>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Issuer',
|
||||
render: (i) => (
|
||||
<div>
|
||||
<div className="font-medium text-ink">{i.name}</div>
|
||||
<Link to={`/issuers/${i.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||
{i.name}
|
||||
</Link>
|
||||
<div className="text-xs text-ink-faint font-mono">{i.id}</div>
|
||||
</div>
|
||||
),
|
||||
@@ -135,7 +92,7 @@ export default function IssuersPage() {
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (i) => <StatusBadge status={i.status} />,
|
||||
render: (i) => <StatusBadge status={issuerStatus(i)} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
@@ -143,9 +100,15 @@ export default function IssuersPage() {
|
||||
render: (i) => {
|
||||
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">—</span>;
|
||||
return (
|
||||
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
|
||||
{JSON.stringify(i.config).slice(0, 60)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfigModal({ title: `${i.name} Configuration`, config: i.config });
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
View Config
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -181,14 +144,12 @@ export default function IssuersPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Issuers"
|
||||
subtitle={data ? `${data.total} issuers` : undefined}
|
||||
subtitle={data ? `${data.total} configured` : undefined}
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(null);
|
||||
setShowCreateModal(true);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
}}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded font-medium hover:bg-brand-700 transition-colors text-sm"
|
||||
>
|
||||
@@ -202,49 +163,83 @@ export default function IssuersPage() {
|
||||
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
|
||||
<>
|
||||
{/* Issuer Type Catalog Cards */}
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Issuer Types</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{catalogStatus.map(({ type, status, count }) => (
|
||||
<CatalogCard
|
||||
key={type.id}
|
||||
type={type}
|
||||
status={status}
|
||||
count={count}
|
||||
onConfigure={() => {
|
||||
setPreselectedType(type.id);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
onFilter={() => {
|
||||
// Match both the canonical id and aliases
|
||||
const filterValue = type.id === 'local' ? 'local' : type.id;
|
||||
setTypeFilter(prev => prev === filterValue ? '' : filterValue);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configured Issuers Table */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ink-muted">Configured Issuers</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="text-xs px-2 py-1.5 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{issuerTypes.filter(t => !t.comingSoon).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredIssuers}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={typeFilter ? `No ${typeLabels[typeFilter] || typeFilter} issuers configured` : 'No issuers configured'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Detail Modal */}
|
||||
{configModal && (
|
||||
<ConfigDetailModal
|
||||
title={configModal.title}
|
||||
config={configModal.config}
|
||||
onClose={() => setConfigModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Issuer Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateIssuerModal
|
||||
step={createStep}
|
||||
selectedType={selectedType}
|
||||
form={createForm}
|
||||
onTypeSelect={(type) => {
|
||||
setSelectedType(type);
|
||||
const typeConfig = issuerTypes.find((t) => t.id === type);
|
||||
const defaultConfig: Record<string, unknown> = {};
|
||||
if (typeConfig) {
|
||||
typeConfig.configFields.forEach((field) => {
|
||||
if (field.defaultValue) {
|
||||
defaultConfig[field.key] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
setCreateForm({ ...defaultConfig });
|
||||
setCreateStep('config');
|
||||
}}
|
||||
onFormChange={(field, value) => {
|
||||
setCreateForm({ ...createForm, [field]: value });
|
||||
}}
|
||||
onBack={() => setCreateStep('type')}
|
||||
onSubmit={() => {
|
||||
if (!selectedType || !createForm.name) return;
|
||||
const config: Record<string, unknown> = { ...createForm };
|
||||
const name = config.name as string;
|
||||
delete config.name;
|
||||
createMutation.mutate({ name, type: selectedType, config });
|
||||
preselectedType={preselectedType}
|
||||
onSubmit={(name, type, config) => {
|
||||
createMutation.mutate({ name, type, config });
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
setPreselectedType(null);
|
||||
}}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
@@ -253,30 +248,94 @@ export default function IssuersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Catalog Card ───────────────────────────────────────────────
|
||||
|
||||
interface CatalogCardProps {
|
||||
type: IssuerTypeConfig;
|
||||
status: 'connected' | 'available' | 'coming_soon';
|
||||
count: number;
|
||||
onConfigure: () => void;
|
||||
onFilter: () => void;
|
||||
}
|
||||
|
||||
function CatalogCard({ type, status, count, onConfigure, onFilter }: CatalogCardProps) {
|
||||
const statusConfig = {
|
||||
connected: { label: `${count} configured`, cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
available: { label: 'Available', cls: 'bg-brand-500/10 text-brand-400 border-brand-500/30' },
|
||||
coming_soon: { label: 'Coming Soon', cls: 'bg-gray-500/10 text-gray-400 border-gray-500/30' },
|
||||
};
|
||||
const { label, cls } = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div className={`p-4 border rounded-lg ${status === 'coming_soon' ? 'border-surface-border/50 opacity-60' : 'border-surface-border'}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{type.icon}</span>
|
||||
<span className="font-medium text-ink text-sm">{type.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink-muted mb-3">{type.description}</p>
|
||||
{status === 'connected' && (
|
||||
<button
|
||||
onClick={onFilter}
|
||||
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
View issuers
|
||||
</button>
|
||||
)}
|
||||
{status === 'available' && (
|
||||
<button
|
||||
onClick={onConfigure}
|
||||
className="text-xs px-3 py-1 bg-brand-600 text-white rounded hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Create Issuer Modal ────────────────────────────────────────
|
||||
|
||||
interface CreateIssuerModalProps {
|
||||
step: 'type' | 'config';
|
||||
selectedType: string | null;
|
||||
form: Record<string, unknown>;
|
||||
onTypeSelect: (type: string) => void;
|
||||
onFormChange: (field: string, value: unknown) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
preselectedType: string | null;
|
||||
onSubmit: (name: string, type: string, config: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateIssuerModal({
|
||||
step,
|
||||
selectedType,
|
||||
form,
|
||||
onTypeSelect,
|
||||
onFormChange,
|
||||
onBack,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting,
|
||||
}: CreateIssuerModalProps) {
|
||||
const selectedTypeConfig = issuerTypes.find((t) => t.id === selectedType);
|
||||
function CreateIssuerModal({ preselectedType, onSubmit, onCancel, isSubmitting }: CreateIssuerModalProps) {
|
||||
const [step, setStep] = useState<'type' | 'config'>(preselectedType ? 'config' : 'type');
|
||||
const [selectedType, setSelectedType] = useState<string | null>(preselectedType);
|
||||
const [form, setForm] = useState<Record<string, unknown>>(() => {
|
||||
if (preselectedType) {
|
||||
const tc = issuerTypes.find(t => t.id === preselectedType);
|
||||
const defaults: Record<string, unknown> = {};
|
||||
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
|
||||
return defaults;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const selectedTypeConfig = issuerTypes.find(t => t.id === selectedType);
|
||||
|
||||
function handleTypeSelect(typeId: string) {
|
||||
setSelectedType(typeId);
|
||||
const tc = issuerTypes.find(t => t.id === typeId);
|
||||
const defaults: Record<string, unknown> = {};
|
||||
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
|
||||
setForm(defaults);
|
||||
setStep('config');
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedType || !form.name) return;
|
||||
const config = { ...form };
|
||||
const name = config.name as string;
|
||||
delete config.name;
|
||||
onSubmit(name, selectedType, config);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
@@ -286,10 +345,7 @@ function CreateIssuerModal({
|
||||
<h2 className="text-lg font-semibold text-ink">
|
||||
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-ink-muted hover:text-ink transition-colors"
|
||||
>
|
||||
<button onClick={onCancel} className="text-ink-muted hover:text-ink transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -297,79 +353,28 @@ function CreateIssuerModal({
|
||||
{/* Content */}
|
||||
<div className="px-6 py-6">
|
||||
{step === 'type' ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{issuerTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onTypeSelect(type.id)}
|
||||
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-opacity-5 transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-ink">{type.name}</div>
|
||||
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TypeSelector onSelect={handleTypeSelect} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Name field always shown */}
|
||||
{/* Name field */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(form.name as string) || ''}
|
||||
onChange={(e) => onFormChange('name', e.target.value)}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g., Production CA"
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific fields */}
|
||||
{selectedTypeConfig?.configFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600 ml-1">*</span>}
|
||||
</label>
|
||||
{field.type === 'select' ? (
|
||||
<select
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
|
||||
>
|
||||
<option value="">Select {field.label}</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors font-mono text-xs"
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={(form[field.key] as number | string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Type-specific fields via ConfigForm */}
|
||||
{selectedTypeConfig && (
|
||||
<ConfigForm
|
||||
fields={selectedTypeConfig.configFields}
|
||||
values={form}
|
||||
onChange={(key, value) => setForm({ ...form, [key]: value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -378,7 +383,7 @@ function CreateIssuerModal({
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
onClick={() => setStep('type')}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Back
|
||||
@@ -392,22 +397,13 @@ function CreateIssuerModal({
|
||||
</button>
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !form.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Issuer'}
|
||||
</button>
|
||||
)}
|
||||
{step === 'type' && (
|
||||
<button
|
||||
onClick={() => selectedType && onTypeSelect(selectedType)}
|
||||
disabled={!selectedType}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime, timeAgo } from '../api/utils';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationBadge({ status }: { status?: string }) {
|
||||
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: job, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['job', id],
|
||||
queryFn: () => getJob(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: verification } = useQuery({
|
||||
queryKey: ['job-verification', id],
|
||||
queryFn: () => getJobVerification(id!),
|
||||
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: auditData } = useQuery({
|
||||
queryKey: ['audit', { resource_id: id }],
|
||||
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Job Details" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !job) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Job Details" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading job...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={`Job ${job.id}`}
|
||||
subtitle={`${job.type} job`}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Job details */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
|
||||
<InfoRow label="Type" value={job.type} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
|
||||
<InfoRow label="Certificate" value={
|
||||
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.certificate_id}
|
||||
</Link>
|
||||
} />
|
||||
{job.agent_id && (
|
||||
<InfoRow label="Agent" value={
|
||||
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.agent_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
{job.target_id && (
|
||||
<InfoRow label="Target" value={
|
||||
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{job.target_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
|
||||
{job.error_message && (
|
||||
<InfoRow label="Error" value={
|
||||
<span className="text-red-600 text-xs">{job.error_message}</span>
|
||||
} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
|
||||
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
|
||||
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
|
||||
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
|
||||
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
|
||||
{job.completed_at && job.started_at && (
|
||||
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification section — only for deployment jobs */}
|
||||
{job.type === 'Deployment' && (
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
|
||||
{job.verification_status ? (
|
||||
<div className="space-y-0">
|
||||
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
|
||||
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
|
||||
{job.verification_fingerprint && (
|
||||
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
|
||||
)}
|
||||
{job.verification_error && (
|
||||
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
|
||||
)}
|
||||
{verification && verification.verified && (
|
||||
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">
|
||||
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit trail */}
|
||||
{auditData && auditData.data.length > 0 && (
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
|
||||
<div className="space-y-2">
|
||||
{auditData.data.map(event => (
|
||||
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
|
||||
<div>
|
||||
<span className="text-sm text-ink">{event.action}</span>
|
||||
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
|
||||
</div>
|
||||
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -47,6 +48,27 @@ function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationBadge({ status }: { status?: string }) {
|
||||
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
@@ -89,20 +111,47 @@ export default function JobsPage() {
|
||||
label: 'Job',
|
||||
render: (j) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs text-ink">{j.id}</div>
|
||||
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||
{j.id}
|
||||
</Link>
|
||||
<div className="text-xs text-ink-faint">{j.type}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent',
|
||||
render: (j) => j.agent_id ? (
|
||||
<Link to={`/agents/${j.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono" onClick={(e) => e.stopPropagation()}>
|
||||
{j.agent_id}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-ink-faint">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attempts',
|
||||
label: 'Attempts',
|
||||
render: (j) => <span className="text-ink-muted">{j.attempts}/{j.max_attempts}</span>,
|
||||
},
|
||||
{
|
||||
key: 'error',
|
||||
label: 'Error',
|
||||
render: (j) => j.status === 'Failed' && j.error_message ? (
|
||||
<span className="text-xs text-red-600 truncate max-w-[200px] inline-block" title={j.error_message}>
|
||||
{j.error_message.length > 80 ? j.error_message.substring(0, 80) + '...' : j.error_message}
|
||||
</span>
|
||||
) : <span className="text-xs text-ink-faint">—</span>,
|
||||
},
|
||||
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
|
||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||
{
|
||||
key: 'verification',
|
||||
label: 'Verification',
|
||||
render: (j) => j.type === 'Deployment' ? <VerificationBadge status={j.verification_status} /> : <span className="text-xs text-ink-faint">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
|
||||
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||
<div className="text-xs text-ink-muted mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold text-ink">{value}</div>
|
||||
{sub && <div className="text-xs text-ink-faint mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default function ObservabilityPage() {
|
||||
const { data: metrics, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: getMetrics,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: getHealth,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: promText } = useQuery({
|
||||
queryKey: ['prometheus-metrics'],
|
||||
queryFn: getPrometheusMetrics,
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Observability" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Observability"
|
||||
subtitle={health ? `Server: ${health.status}` : undefined}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Health status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${health?.status === 'ok' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm text-ink font-medium">
|
||||
Server {health?.status === 'ok' ? 'Healthy' : 'Unhealthy'}
|
||||
</span>
|
||||
{metrics && (
|
||||
<span className="text-xs text-ink-faint ml-auto">
|
||||
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gauge metrics */}
|
||||
{isLoading && (
|
||||
<div className="text-sm text-ink-muted py-10 text-center">Loading metrics...</div>
|
||||
)}
|
||||
|
||||
{metrics && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Certificate Gauges</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<MetricCard label="Total" value={metrics.gauge.certificate_total} />
|
||||
<MetricCard label="Active" value={metrics.gauge.certificate_active} />
|
||||
<MetricCard label="Expiring Soon" value={metrics.gauge.certificate_expiring_soon} />
|
||||
<MetricCard label="Expired" value={metrics.gauge.certificate_expired} />
|
||||
<MetricCard label="Revoked" value={metrics.gauge.certificate_revoked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Agent & Job Gauges</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<MetricCard label="Total Agents" value={metrics.gauge.agent_total} />
|
||||
<MetricCard label="Online Agents" value={metrics.gauge.agent_online} />
|
||||
<MetricCard label="Pending Jobs" value={metrics.gauge.job_pending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Counters</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||
<MetricCard label="Jobs Completed (total)" value={metrics.counter.job_completed_total} />
|
||||
<MetricCard label="Jobs Failed (total)" value={metrics.counter.job_failed_total} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Prometheus config */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Prometheus Integration</h3>
|
||||
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||
<p className="text-sm text-ink mb-3">
|
||||
Add this scrape target to your <code className="text-xs bg-surface-muted px-1 py-0.5 rounded">prometheus.yml</code>:
|
||||
</p>
|
||||
<pre className="bg-ink text-white rounded p-4 text-xs overflow-x-auto font-mono">
|
||||
{`scrape_configs:
|
||||
- job_name: 'certctl'
|
||||
metrics_path: '/api/v1/metrics/prometheus'
|
||||
scheme: 'https'
|
||||
bearer_token: '<YOUR_API_KEY>'
|
||||
static_configs:
|
||||
- targets: ['<CERTCTL_HOST>:443']`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Prometheus output */}
|
||||
{promText && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Live Prometheus Output</h3>
|
||||
<div className="bg-surface border border-surface-border rounded shadow-sm">
|
||||
<div className="px-4 py-2 border-b border-surface-border flex items-center justify-between">
|
||||
<span className="text-xs text-ink-faint font-mono">GET /api/v1/metrics/prometheus</span>
|
||||
<span className="text-xs text-ink-faint">text/plain</span>
|
||||
</div>
|
||||
<pre className="p-4 text-xs text-ink-muted overflow-x-auto font-mono max-h-96 overflow-y-auto whitespace-pre">
|
||||
{promText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,11 +25,63 @@ interface CreateProfileModalProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AVAILABLE_ALGORITHMS = ['RSA', 'ECDSA', 'Ed25519'];
|
||||
const ALGORITHM_MIN_SIZES: Record<string, number[]> = {
|
||||
RSA: [2048, 3072, 4096],
|
||||
ECDSA: [256, 384],
|
||||
Ed25519: [0],
|
||||
};
|
||||
|
||||
const AVAILABLE_EKUS = [
|
||||
{ value: 'serverAuth', label: 'Server Authentication (TLS)' },
|
||||
{ value: 'clientAuth', label: 'Client Authentication' },
|
||||
{ value: 'codeSigning', label: 'Code Signing' },
|
||||
{ value: 'emailProtection', label: 'Email Protection (S/MIME)' },
|
||||
{ value: 'timeStamping', label: 'Time Stamping' },
|
||||
];
|
||||
|
||||
interface KeyAlgorithmEntry {
|
||||
algorithm: string;
|
||||
min_size: number;
|
||||
}
|
||||
|
||||
function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [ttl, setTtl] = useState('86400');
|
||||
const [shortLived, setShortLived] = useState(false);
|
||||
const [keyAlgorithms, setKeyAlgorithms] = useState<KeyAlgorithmEntry[]>([
|
||||
{ algorithm: 'ECDSA', min_size: 256 },
|
||||
{ algorithm: 'RSA', min_size: 2048 },
|
||||
]);
|
||||
const [selectedEkus, setSelectedEkus] = useState<string[]>(['serverAuth']);
|
||||
const [sanPatterns, setSanPatterns] = useState('');
|
||||
const [spiffePattern, setSpiffePattern] = useState('');
|
||||
|
||||
const addAlgorithm = () => {
|
||||
const unused = AVAILABLE_ALGORITHMS.find(a => !keyAlgorithms.some(ka => ka.algorithm === a));
|
||||
if (unused) {
|
||||
setKeyAlgorithms([...keyAlgorithms, { algorithm: unused, min_size: ALGORITHM_MIN_SIZES[unused][0] }]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAlgorithm = (idx: number) => {
|
||||
setKeyAlgorithms(keyAlgorithms.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateAlgorithm = (idx: number, field: 'algorithm' | 'min_size', value: string | number) => {
|
||||
const updated = [...keyAlgorithms];
|
||||
if (field === 'algorithm') {
|
||||
updated[idx] = { algorithm: value as string, min_size: ALGORITHM_MIN_SIZES[value as string]?.[0] || 0 };
|
||||
} else {
|
||||
updated[idx] = { ...updated[idx], min_size: value as number };
|
||||
}
|
||||
setKeyAlgorithms(updated);
|
||||
};
|
||||
|
||||
const toggleEku = (eku: string) => {
|
||||
setSelectedEkus(prev => prev.includes(eku) ? prev.filter(e => e !== eku) : [...prev, eku]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -39,20 +91,31 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
description: description.trim(),
|
||||
max_ttl_seconds: parseInt(ttl) || 86400,
|
||||
allow_short_lived: shortLived,
|
||||
allowed_key_algorithms: keyAlgorithms,
|
||||
allowed_ekus: selectedEkus,
|
||||
required_san_patterns: sanPatterns.trim() ? sanPatterns.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
spiffe_uri_pattern: spiffePattern.trim() || '',
|
||||
enabled: true,
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTtl('86400');
|
||||
setShortLived(false);
|
||||
setKeyAlgorithms([{ algorithm: 'ECDSA', min_size: 256 }, { algorithm: 'RSA', min_size: 2048 }]);
|
||||
setSelectedEkus(['serverAuth']);
|
||||
setSanPatterns('');
|
||||
setSpiffePattern('');
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const inputClass = 'w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
|
||||
const selectClass = 'bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Create Profile</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -61,7 +124,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
className={inputClass}
|
||||
placeholder="e.g., Web Server Certs"
|
||||
required
|
||||
/>
|
||||
@@ -71,7 +134,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
className={inputClass}
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
@@ -82,7 +145,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
type="number"
|
||||
value={ttl}
|
||||
onChange={e => setTtl(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
className={inputClass}
|
||||
placeholder="86400"
|
||||
/>
|
||||
<p className="text-xs text-ink-muted mt-1">
|
||||
@@ -109,6 +172,97 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
/>
|
||||
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
|
||||
</div>
|
||||
|
||||
{/* Allowed Key Algorithms */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-ink">Allowed Key Algorithms</label>
|
||||
{keyAlgorithms.length < AVAILABLE_ALGORITHMS.length && (
|
||||
<button type="button" onClick={addAlgorithm} className="text-xs text-brand-600 hover:text-brand-700 font-medium">
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{keyAlgorithms.map((ka, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<select
|
||||
value={ka.algorithm}
|
||||
onChange={e => updateAlgorithm(idx, 'algorithm', e.target.value)}
|
||||
className={selectClass + ' flex-1'}
|
||||
>
|
||||
{AVAILABLE_ALGORITHMS.map(a => (
|
||||
<option key={a} value={a} disabled={a !== ka.algorithm && keyAlgorithms.some(k => k.algorithm === a)}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{ka.algorithm !== 'Ed25519' ? (
|
||||
<select
|
||||
value={ka.min_size}
|
||||
onChange={e => updateAlgorithm(idx, 'min_size', parseInt(e.target.value))}
|
||||
className={selectClass + ' w-24'}
|
||||
>
|
||||
{(ALGORITHM_MIN_SIZES[ka.algorithm] || []).map(s => (
|
||||
<option key={s} value={s}>{s}+</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-ink-muted w-24 text-center">fixed</span>
|
||||
)}
|
||||
<button type="button" onClick={() => removeAlgorithm(idx)} className="text-xs text-red-500 hover:text-red-600">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{keyAlgorithms.length === 0 && (
|
||||
<p className="text-xs text-ink-faint">No algorithms configured. Click + Add to allow key types.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed EKUs */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Allowed Extended Key Usages</label>
|
||||
<div className="space-y-1.5">
|
||||
{AVAILABLE_EKUS.map(eku => (
|
||||
<label key={eku.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEkus.includes(eku.value)}
|
||||
onChange={() => toggleEku(eku.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-ink">{eku.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required SAN Patterns */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Required SAN Patterns</label>
|
||||
<input
|
||||
value={sanPatterns}
|
||||
onChange={e => setSanPatterns(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="e.g., *.example.com, api.internal"
|
||||
/>
|
||||
<p className="text-xs text-ink-muted mt-1">Comma-separated patterns. Leave empty for no constraints.</p>
|
||||
</div>
|
||||
|
||||
{/* SPIFFE URI Pattern */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">SPIFFE URI Pattern</label>
|
||||
<input
|
||||
value={spiffePattern}
|
||||
onChange={e => setSpiffePattern(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="e.g., spiffe://example.org/service/*"
|
||||
/>
|
||||
<p className="text-xs text-ink-muted mt-1">Optional workload identity URI SAN pattern.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTarget, getJobs, updateTarget } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Job } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
nginx: 'NGINX',
|
||||
apache: 'Apache',
|
||||
haproxy: 'HAProxy',
|
||||
traefik: 'Traefik',
|
||||
caddy: 'Caddy',
|
||||
f5_bigip: 'F5 BIG-IP',
|
||||
iis: 'IIS',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TargetDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editHostname, setEditHostname] = useState('');
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<{ name: string; hostname: string }>) => updateTarget(id!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['target', id] });
|
||||
setIsEditing(false);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: target, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['target', id],
|
||||
queryFn: () => getTarget(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Deployment jobs for this target
|
||||
const { data: jobsData } = useQuery({
|
||||
queryKey: ['jobs', { target_id: id, type: 'Deployment' }],
|
||||
queryFn: () => getJobs({ target_id: id! }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Target Details" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !target) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Target Details" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm text-ink-muted">Loading target...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const jobColumns: Column<Job>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'Job',
|
||||
render: (j) => (
|
||||
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright">
|
||||
{j.id}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||
{ key: 'cert', label: 'Certificate', render: (j) => (
|
||||
<Link to={`/certificates/${j.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{j.certificate_id}
|
||||
</Link>
|
||||
)},
|
||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||
{
|
||||
key: 'verification',
|
||||
label: 'Verification',
|
||||
render: (j) => {
|
||||
if (!j.verification_status) return <span className="text-xs text-ink-faint">—</span>;
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
skipped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Verified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[j.verification_status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{labels[j.verification_status] || j.verification_status}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={target.name}
|
||||
subtitle={typeLabels[target.type] || target.type}
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditName(target.name);
|
||||
setEditHostname(target.hostname || '');
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Target info */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Target Information</h3>
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
|
||||
<InfoRow label="Name" value={target.name} />
|
||||
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
|
||||
<InfoRow label="Hostname" value={target.hostname || '—'} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
|
||||
{target.agent_id && (
|
||||
<InfoRow label="Agent" value={
|
||||
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
{target.agent_id}
|
||||
</Link>
|
||||
} />
|
||||
)}
|
||||
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
|
||||
{target.config && Object.keys(target.config).length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{Object.entries(target.config).map(([key, val]) => {
|
||||
const sensitiveKeys = ['password', 'secret', 'token', 'key', 'winrm_password'];
|
||||
const isSensitive = sensitiveKeys.some(s => key.toLowerCase().includes(s));
|
||||
const displayVal = isSensitive && val ? '********' : String(val);
|
||||
return (
|
||||
<InfoRow key={key} label={key.replace(/_/g, ' ')} value={
|
||||
<span className="font-mono text-xs truncate max-w-xs inline-block">{displayVal}</span>
|
||||
} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployment history */}
|
||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||
Deployment History {jobsData ? `(${jobsData.total})` : ''}
|
||||
</h3>
|
||||
<DataTable
|
||||
columns={jobColumns}
|
||||
data={jobsData?.data || []}
|
||||
isLoading={!jobsData}
|
||||
emptyMessage="No deployments to this target"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{isEditing && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setIsEditing(false)}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Edit Target</h2>
|
||||
{updateMutation.isError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{(updateMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName, hostname: editHostname }); }} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Name</label>
|
||||
<input value={editName} onChange={e => setEditName(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Hostname</label>
|
||||
<input value={editHostname} onChange={e => setEditHostname(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={updateMutation.isPending} className="flex-1 btn btn-primary disabled:opacity-50">
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setIsEditing(false)} className="flex-1 btn btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTargets, createTarget, deleteTarget } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -15,6 +16,9 @@ const typeLabels: Record<string, string> = {
|
||||
haproxy: 'HAProxy',
|
||||
traefik: 'Traefik',
|
||||
caddy: 'Caddy',
|
||||
envoy: 'Envoy',
|
||||
postfix: 'Postfix',
|
||||
dovecot: 'Dovecot',
|
||||
f5_bigip: 'F5 BIG-IP',
|
||||
iis: 'IIS',
|
||||
};
|
||||
@@ -25,8 +29,11 @@ const TARGET_TYPES = [
|
||||
{ value: 'haproxy', label: 'HAProxy', description: 'Combined PEM file (cert+chain+key), optional validate, reload' },
|
||||
{ value: 'traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' },
|
||||
{ value: 'caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' },
|
||||
{ value: 'envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' },
|
||||
{ value: 'postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix reload' },
|
||||
{ value: 'dovecot', label: 'Dovecot', description: 'Dovecot IMAP/POP3 — file write + doveadm reload' },
|
||||
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST via proxy agent (V3 implementation)' },
|
||||
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or proxy WinRM (V3 implementation)' },
|
||||
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
|
||||
];
|
||||
|
||||
const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: string; required?: boolean }[]> = {
|
||||
@@ -59,6 +66,27 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
||||
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
||||
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
||||
],
|
||||
envoy: [
|
||||
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/envoy/certs', required: true },
|
||||
{ key: 'cert_filename', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
||||
{ key: 'key_filename', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
||||
{ key: 'chain_filename', label: 'Chain Filename (optional)', placeholder: 'chain.pem (leave empty to append to cert)' },
|
||||
{ key: 'sds_config', label: 'Generate SDS Config', placeholder: 'true or false' },
|
||||
],
|
||||
postfix: [
|
||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/postfix/certs/cert.pem' },
|
||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/postfix/certs/key.pem' },
|
||||
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/postfix/certs/chain.pem' },
|
||||
{ key: 'reload_command', label: 'Reload Command', placeholder: 'postfix reload' },
|
||||
{ key: 'validate_command', label: 'Validate Command', placeholder: 'postfix check' },
|
||||
],
|
||||
dovecot: [
|
||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/dovecot/certs/cert.pem' },
|
||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/dovecot/certs/key.pem' },
|
||||
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/dovecot/certs/chain.pem' },
|
||||
{ key: 'reload_command', label: 'Reload Command', placeholder: 'doveadm reload' },
|
||||
{ key: 'validate_command', label: 'Validate Command', placeholder: 'doveconf -n' },
|
||||
],
|
||||
f5_bigip: [
|
||||
{ key: 'management_ip', label: 'Management IP', placeholder: '192.168.1.100', required: true },
|
||||
{ key: 'partition', label: 'Partition', placeholder: 'Common' },
|
||||
@@ -66,9 +94,18 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
||||
],
|
||||
iis: [
|
||||
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },
|
||||
{ key: 'binding_ip', label: 'Binding IP', placeholder: '*' },
|
||||
{ key: 'binding_port', label: 'Binding Port', placeholder: '443' },
|
||||
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My' },
|
||||
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My', required: true },
|
||||
{ key: 'port', label: 'HTTPS Port', placeholder: '443' },
|
||||
{ key: 'ip_address', label: 'Binding IP', placeholder: '*' },
|
||||
{ key: 'binding_info', label: 'Host Header (SNI)', placeholder: 'www.example.com' },
|
||||
{ key: 'sni', label: 'Enable SNI', placeholder: 'true or false' },
|
||||
{ key: 'mode', label: 'Deployment Mode', placeholder: 'local (default) or winrm' },
|
||||
{ key: 'winrm.winrm_host', label: 'WinRM Host (remote mode)', placeholder: 'iis-server.example.com' },
|
||||
{ key: 'winrm.winrm_port', label: 'WinRM Port', placeholder: '5985 (HTTP) or 5986 (HTTPS)' },
|
||||
{ key: 'winrm.winrm_username', label: 'WinRM Username', placeholder: 'Administrator' },
|
||||
{ key: 'winrm.winrm_password', label: 'WinRM Password', placeholder: '(sensitive)' },
|
||||
{ key: 'winrm.winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
|
||||
{ key: 'winrm.winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -266,7 +303,9 @@ export default function TargetsPage() {
|
||||
label: 'Target',
|
||||
render: (t) => (
|
||||
<div>
|
||||
<div className="font-medium text-ink">{t.name}</div>
|
||||
<Link to={`/targets/${t.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||
{t.name}
|
||||
</Link>
|
||||
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user