mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 21:28:53 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a0b9285323 | |||
| 2655493ac8 |
@@ -62,6 +62,7 @@ certctl-agent
|
|||||||
certctl-cli
|
certctl-cli
|
||||||
/server
|
/server
|
||||||
/agent
|
/agent
|
||||||
|
/cli
|
||||||
|
|
||||||
# Private strategy docs
|
# Private strategy docs
|
||||||
roadmap.md
|
roadmap.md
|
||||||
|
|||||||
@@ -7,24 +7,34 @@
|
|||||||
|
|
||||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||||
|
|
||||||
```mermaid
|
[](LICENSE)
|
||||||
timeline
|
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||||
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
[](https://github.com/shankar0123/certctl/releases)
|
||||||
2015 : 5 years
|
[](https://github.com/shankar0123/certctl/stargazers)
|
||||||
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.
|
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.
|
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)
|
```mermaid
|
||||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
gantt
|
||||||
[](https://github.com/shankar0123/certctl/releases)
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -42,7 +52,7 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
|
|||||||
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
|
| [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 for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure |
|
||||||
|
|
||||||
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,471 Go tests + 193 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
|
> **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,300+ Go tests + 211 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
|
||||||
|
|
||||||
## Why certctl Exists
|
## Why certctl Exists
|
||||||
|
|
||||||
@@ -58,8 +68,8 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venaf
|
|||||||
|
|
||||||
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||||
|
|
||||||
- **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
|
- **Web dashboard** — 24 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
|
- **REST API** — 97 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)
|
- **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
|
- **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
|
- **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included
|
||||||
@@ -84,8 +94,10 @@ For the full capability breakdown — revocation infrastructure, policy engine,
|
|||||||
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
||||||
| step-ca | Implemented | `StepCA` |
|
| step-ca | Implemented | `StepCA` |
|
||||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||||
| Vault PKI | Future | — |
|
| Vault PKI | Beta | `VaultPKI` |
|
||||||
| DigiCert | Future | — |
|
| DigiCert CertCentral | Beta | `DigiCert` |
|
||||||
|
|
||||||
|
**Vault PKI and DigiCert 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.
|
**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.
|
||||||
|
|
||||||
@@ -128,7 +140,7 @@ All connectors are pluggable — build your own by implementing the [connector i
|
|||||||
<tr>
|
<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-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-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>
|
||||||
<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 deployment</sub></td>
|
||||||
@@ -142,7 +154,7 @@ All connectors are pluggable — build your own by implementing the [connector i
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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.
|
> **24 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (list + detail with approval workflow), notifications, policies, profiles, issuers (catalog + detail), targets (list + detail + wizard), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, network scan management, digest email preview, and observability metrics.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -163,7 +175,7 @@ docker compose -f deploy/docker-compose.yml up -d --build
|
|||||||
|
|
||||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
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:
|
Verify the API:
|
||||||
```bash
|
```bash
|
||||||
@@ -171,7 +183,7 @@ curl http://localhost:8443/health
|
|||||||
# {"status":"healthy"}
|
# {"status":"healthy"}
|
||||||
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||||
# 35
|
# 32
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent Install (One-Liner)
|
### Agent Install (One-Liner)
|
||||||
@@ -371,7 +383,7 @@ make docker-clean # Stop + remove volumes
|
|||||||
|
|
||||||
## API Overview
|
## 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).
|
97 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
|
### Key Endpoints
|
||||||
```
|
```
|
||||||
@@ -448,7 +460,7 @@ certctl-cli certs list --format json # JSON output (default: table)
|
|||||||
|
|
||||||
## MCP Server (AI Integration)
|
## 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 80 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install
|
# Install
|
||||||
@@ -484,7 +496,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
|||||||
|
|
||||||
### V2: Operational Maturity
|
### V2: Operational Maturity
|
||||||
|
|
||||||
30 milestones complete, 1500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
30+ milestones complete, 1,500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||||
|
|
||||||
**What shipped (all ✅):**
|
**What shipped (all ✅):**
|
||||||
|
|
||||||
@@ -496,7 +508,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
|||||||
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
- **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
|
- **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
|
- **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
|
- **CLI** — 10 subcommands (list/get/renew/revoke certs, list agents/jobs, import, status, health, metrics), JSON/table output
|
||||||
- **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
|
- **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
|
||||||
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
|
- **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
|
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
|
||||||
@@ -509,16 +521,17 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
|||||||
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; configurable daily/hourly/weekly briefings via SMTP
|
- **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
|
- **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:**
|
**Also shipped:**
|
||||||
- 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)
|
- Issuer catalog page (see all supported CAs, configure from dashboard)
|
||||||
- First-run onboarding wizard
|
- Vault PKI and DigiCert CertCentral issuer connectors (Beta)
|
||||||
- Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer)
|
- Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer)
|
||||||
- Migration guides (Certbot, acme.sh, cert-manager complement)
|
- Migration guides (Certbot, acme.sh, cert-manager complement)
|
||||||
- One-line agent install script with cross-compiled binaries
|
- 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
|
### 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, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views.
|
||||||
|
|||||||
+1
-1
@@ -2643,7 +2643,7 @@ components:
|
|||||||
# ─── Issuers ─────────────────────────────────────────────────────
|
# ─── Issuers ─────────────────────────────────────────────────────
|
||||||
IssuerType:
|
IssuerType:
|
||||||
type: string
|
type: string
|
||||||
enum: [ACME, GenericCA, StepCA]
|
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert]
|
||||||
|
|
||||||
Issuer:
|
Issuer:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+78
-6
@@ -19,8 +19,10 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
"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"
|
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||||
|
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
||||||
@@ -110,6 +112,7 @@ func main() {
|
|||||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||||
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
||||||
|
Insecure: cfg.ACME.Insecure,
|
||||||
}, logger)
|
}, logger)
|
||||||
logger.Info("initialized ACME issuer connector")
|
logger.Info("initialized ACME issuer connector")
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ func main() {
|
|||||||
// Uses the native /sign API with JWK provisioner authentication.
|
// Uses the native /sign API with JWK provisioner authentication.
|
||||||
stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{
|
stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{
|
||||||
CAURL: os.Getenv("CERTCTL_STEPCA_URL"),
|
CAURL: os.Getenv("CERTCTL_STEPCA_URL"),
|
||||||
|
RootCertPath: os.Getenv("CERTCTL_STEPCA_ROOT_CERT"),
|
||||||
ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"),
|
ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"),
|
||||||
ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"),
|
ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"),
|
||||||
ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"),
|
ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"),
|
||||||
@@ -133,6 +137,27 @@ func main() {
|
|||||||
}, logger)
|
}, logger)
|
||||||
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
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")
|
||||||
|
|
||||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
||||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
// "iss-local" matches the seed data issuer ID for the Local CA.
|
||||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
||||||
@@ -145,6 +170,19 @@ func main() {
|
|||||||
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
||||||
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
"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")
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||||
|
|
||||||
// Initialize revocation repository
|
// Initialize revocation repository
|
||||||
@@ -225,7 +263,10 @@ func main() {
|
|||||||
certificateService.SetRevocationSvc(revocationSvc)
|
certificateService.SetRevocationSvc(revocationSvc)
|
||||||
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||||
certificateService.SetTargetRepo(targetRepo)
|
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 := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||||
|
renewalService.SetTargetRepo(targetRepo)
|
||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
@@ -467,13 +508,28 @@ func main() {
|
|||||||
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
||||||
webDir = "./web"
|
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 {
|
if _, err := os.Stat(webDir + "/index.html"); err == nil {
|
||||||
fileServer := http.FileServer(http.Dir(webDir))
|
fileServer := http.FileServer(http.Dir(webDir))
|
||||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
// API, health, and EST routes go to the API handler
|
// Health/ready and auth/info bypass auth middleware.
|
||||||
if path == "/health" || path == "/ready" ||
|
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
||||||
(len(path) >= 8 && path[:8] == "/api/v1/") ||
|
// 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") {
|
(len(path) >= 16 && path[:16] == "/.well-known/est") {
|
||||||
apiHandler.ServeHTTP(w, r)
|
apiHandler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
@@ -488,7 +544,15 @@ func main() {
|
|||||||
})
|
})
|
||||||
logger.Info("dashboard available at /", "web_dir", webDir)
|
logger.Info("dashboard available at /", "web_dir", webDir)
|
||||||
} else {
|
} 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")
|
logger.Info("dashboard directory not found, serving API only")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,9 +561,9 @@ func main() {
|
|||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: finalHandler,
|
Handler: finalHandler,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
ReadHeaderTimeout: 5 * 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,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +607,14 @@ func main() {
|
|||||||
logger.Info("certctl server stopped")
|
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.
|
// getEnvIntDefault parses an integer from a string with a default fallback.
|
||||||
func getEnvIntDefault(s string, defaultVal int) int {
|
func getEnvIntDefault(s string, defaultVal int) int {
|
||||||
if s == "" {
|
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
|
||||||
+10
-6
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
|
|||||||
### Design Principles
|
### 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.
|
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.
|
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.
|
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
|
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
||||||
@@ -80,13 +80,16 @@ flowchart TB
|
|||||||
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
||||||
CA3["step-ca\n(/sign API)"]
|
CA3["step-ca\n(/sign API)"]
|
||||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||||
CA6["Vault PKI\n(planned)"]
|
CA6["Vault PKI\n(token auth, /sign API)"]
|
||||||
|
CA7["DigiCert CertCentral\n(async order model)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Target Systems"
|
subgraph "Target Systems"
|
||||||
T1["NGINX\n(file write + reload)"]
|
T1["NGINX\n(file write + reload)"]
|
||||||
T4["Apache httpd\n(file write + reload)"]
|
T4["Apache httpd\n(file write + reload)"]
|
||||||
T5["HAProxy\n(combined PEM + reload)"]
|
T5["HAProxy\n(combined PEM + reload)"]
|
||||||
|
T6["Traefik\n(file provider)"]
|
||||||
|
T7["Caddy\n(admin API / file)"]
|
||||||
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
||||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
T3["IIS\n(agent-local PowerShell, planned)"]
|
||||||
end
|
end
|
||||||
@@ -96,7 +99,7 @@ flowchart TB
|
|||||||
SVC --> REPO
|
SVC --> REPO
|
||||||
REPO --> PG
|
REPO --> PG
|
||||||
SCHED --> SVC
|
SCHED --> SVC
|
||||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3
|
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 & CA4 & CA6 & CA7
|
||||||
|
|
||||||
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
|
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
|
||||||
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
|
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
|
||||||
@@ -506,7 +509,8 @@ flowchart TB
|
|||||||
II --> ACME["ACME v2"]
|
II --> ACME["ACME v2"]
|
||||||
II --> SC["step-ca"]
|
II --> SC["step-ca"]
|
||||||
II --> OC["OpenSSL / Custom CA"]
|
II --> OC["OpenSSL / Custom CA"]
|
||||||
II --> VP["Vault PKI (planned)"]
|
II --> VP["Vault PKI"]
|
||||||
|
II --> DC["DigiCert CertCentral"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Target Connectors"
|
subgraph "Target Connectors"
|
||||||
@@ -570,7 +574,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.
|
**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 +651,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.
|
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Result:
|
|||||||
|
|
||||||
Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows:
|
Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows:
|
||||||
- **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly)
|
- **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly)
|
||||||
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (coming in v2.1), private CA)
|
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (planned), private CA)
|
||||||
- **Unified renewal and deployment** across both worlds
|
- **Unified renewal and deployment** across both worlds
|
||||||
- **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail
|
- **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail
|
||||||
|
|
||||||
@@ -39,8 +39,7 @@ Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or sel
|
|||||||
```bash
|
```bash
|
||||||
cd /opt/certctl
|
cd /opt/certctl
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
# Dashboard: http://localhost:3000
|
# Dashboard & API: http://localhost:8443
|
||||||
# API: http://localhost:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option B: Kubernetes** (recommended for prod)
|
**Option B: Kubernetes** (recommended for prod)
|
||||||
@@ -60,7 +59,7 @@ chmod +x /usr/local/bin/certctl-agent
|
|||||||
|
|
||||||
# Config
|
# Config
|
||||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||||
CERTCTL_SERVER_URL=https://certctl-control-plane:8080
|
CERTCTL_SERVER_URL=http://certctl-control-plane:8443
|
||||||
CERTCTL_API_KEY=your-api-key
|
CERTCTL_API_KEY=your-api-key
|
||||||
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
|
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
|
||||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||||
@@ -83,18 +82,20 @@ Agents scan configured directories and report back all existing certs. In the da
|
|||||||
Set up the same issuer certctl uses for non-Kubernetes certs:
|
Set up the same issuer certctl uses for non-Kubernetes certs:
|
||||||
- **ACME** (Let's Encrypt, for public certs)
|
- **ACME** (Let's Encrypt, for public certs)
|
||||||
- **step-ca** (Smallstep, for internal certs)
|
- **step-ca** (Smallstep, for internal certs)
|
||||||
- **Vault PKI** (coming in v2.1) (HashiCorp Vault, for enterprise PKI)
|
- **Vault PKI** (planned) (HashiCorp Vault, for enterprise PKI)
|
||||||
- **Private CA** (your own internal root CA)
|
- **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.
|
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
|
||||||
|
|
||||||
### 5. Create Policies for Non-Kubernetes Certs
|
### 5. Create Policies for Non-Kubernetes Certs
|
||||||
|
|
||||||
Go to **Policies** → **New Policy**:
|
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||||
- Issuer: shared (ACME, step-ca, Vault (coming in v2.1), private CA)
|
- **Name:** e.g., "VM Certificate Policy"
|
||||||
- Profile: serverAuth for NGINX/Apache/HAProxy, clientAuth for mTLS, emailProtection for S/MIME
|
- **Type:** `expiration_window` or `key_algorithm` (enforce renewal thresholds or crypto requirements)
|
||||||
- Renewal Threshold: 30 days (default, adjust per SLA)
|
- **Severity:** `high`
|
||||||
- Scope: agent groups (VMs, bare metal, appliances)
|
- **Config:** set your enforcement parameters
|
||||||
|
|
||||||
|
Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other compliance rules across your fleet.
|
||||||
|
|
||||||
### 6. View Unified Inventory
|
### 6. View Unified Inventory
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ Go to **Policies** → **New Policy**:
|
|||||||
If cert-manager and certctl both use the same CA:
|
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
|
- **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
|
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
|
||||||
- **Vault PKI** (coming in v2.1): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
- **Vault PKI** (planned): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||||
|
|
||||||
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
|
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,6 +139,6 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
|
|||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
|
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
|
||||||
2. Explore [Agents and Targets](./architecture.md#agents-and-targets) for deployment architecture
|
2. Explore [Architecture](./architecture.md#agents) for deployment architecture
|
||||||
3. Read about [Discovery Scanning](./quickstart.md#discovery) to auto-find certs
|
3. Read about [Discovery Scanning](./quickstart.md#certificate-discovery) to auto-find certs
|
||||||
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||||
|
|||||||
+47
-4
@@ -312,12 +312,55 @@ 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.
|
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.
|
**Configuration:**
|
||||||
- **DigiCert** — Commercial CA integration via DigiCert CertCentral REST API. Async order model (submit → poll for completion). OV/EV certificate support.
|
|
||||||
|
| 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`
|
||||||
|
|
||||||
|
### Coming in V2.2+
|
||||||
|
|
||||||
|
The following issuer connectors are planned for future releases:
|
||||||
|
|
||||||
|
- **Entrust** — Enterprise CA via Entrust API
|
||||||
|
- **Sectigo** — Commercial CA integration via Sectigo REST API
|
||||||
|
- **Google CAS** — Google Cloud Certificate Authority Service
|
||||||
|
- **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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1469,8 +1469,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
|||||||
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
||||||
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
||||||
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
||||||
| **DigiCert issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
| **DigiCert issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||||
| **Vault PKI issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
| **Vault PKI issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+30
-24
@@ -99,18 +99,23 @@ Environment="CERTCTL_DISCOVERY_DIRS=/etc/acme.sh"
|
|||||||
In the **Discovery** page:
|
In the **Discovery** page:
|
||||||
1. Review the "Unmanaged" certificates found by the agent
|
1. Review the "Unmanaged" certificates found by the agent
|
||||||
2. Click **Claim** on each acme.sh certificate
|
2. Click **Claim** on each acme.sh certificate
|
||||||
3. Map to the certificate ID (certctl auto-generates suggestions)
|
3. Enter the managed certificate ID to link it (e.g., `mc-api-prod`)
|
||||||
|
|
||||||
Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status.
|
Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status.
|
||||||
|
|
||||||
### 5. Create an ACME Issuer
|
### 5. Create an ACME Issuer
|
||||||
|
|
||||||
In **Issuers** → **Configure New Issuer:**
|
In **Issuers** → **+ New Issuer:**
|
||||||
|
|
||||||
- **Type:** ACME v2
|
1. Select **ACME** from the issuer type grid
|
||||||
- **Directory URL:** `https://acme-v02.api.letsencrypt.org/directory` (production) or staging for testing
|
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and config
|
||||||
- **Email:** Same email as your acme.sh account (required for ACME ToS)
|
|
||||||
- **Challenge Type:** DNS-01 (to match acme.sh's DNS validation)
|
Or configure via environment variables:
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
export CERTCTL_ACME_EMAIL=your-email@example.com # same as your acme.sh account
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||||
|
```
|
||||||
|
|
||||||
### 6. Adapt Your DNS Provider Scripts
|
### 6. Adapt Your DNS Provider Scripts
|
||||||
|
|
||||||
@@ -182,26 +187,28 @@ curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_record
|
|||||||
-H "X-Auth-Key: ${CF_KEY}"
|
-H "X-Auth-Key: ${CF_KEY}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure in the ACME issuer:
|
Configure the ACME issuer via environment variables:
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
"challenge_type": "dns-01",
|
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||||
"dns_present_script": "/etc/certctl/dns/cloudflare-present.sh",
|
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||||
"dns_cleanup_script": "/etc/certctl/dns/cloudflare-cleanup.sh",
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||||
"dns_propagation_wait": 30
|
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cloudflare-cleanup.sh
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or create the issuer through the dashboard: **Issuers** → **+ New Issuer** → select **ACME** → fill in the config fields.
|
||||||
|
|
||||||
### 7. Create Renewal Policies
|
### 7. Create Renewal Policies
|
||||||
|
|
||||||
In **Policies:**
|
In **Policies** → **+ New Policy:**
|
||||||
|
|
||||||
- **Certificate Profile:** Select the issuer and challenge type from step 5
|
- **Name:** e.g., "ACME DNS-01 Policy"
|
||||||
- **Renewal Threshold:** 30 days before expiry (or match your acme.sh cron settings)
|
- **Type:** `expiration_window` (enforces renewal thresholds)
|
||||||
- **Agent Group:** Select which agents should renew certificates
|
- **Severity:** `high`
|
||||||
|
- **Config:** set your renewal window (default: 30 days before expiry)
|
||||||
|
|
||||||
Set one policy per domain or domain pattern.
|
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails on top.
|
||||||
|
|
||||||
### 8. Phase Out acme.sh Cron
|
### 8. Phase Out acme.sh Cron
|
||||||
|
|
||||||
@@ -252,11 +259,10 @@ Benefits:
|
|||||||
|
|
||||||
To enable:
|
To enable:
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
export CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01
|
||||||
"challenge_type": "dns-persist-01",
|
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN=letsencrypt.org
|
||||||
"dns_persist_issuer_domain": "acme-v02.api.letsencrypt.org"
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Option A: Docker Compose (quickest for evaluation)
|
|||||||
```bash
|
```bash
|
||||||
cd /opt/certctl
|
cd /opt/certctl
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
# Dashboard & API: https://localhost:8443
|
# Dashboard & API: http://localhost:8443
|
||||||
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
|
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ chmod +x /usr/local/bin/certctl-agent
|
|||||||
# Create config
|
# Create config
|
||||||
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
|
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
|
||||||
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||||
CERTCTL_SERVER_URL=https://certctl-control-plane.example.com:8080
|
CERTCTL_SERVER_URL=http://certctl-control-plane.example.com:8443
|
||||||
CERTCTL_API_KEY=your-api-key-here
|
CERTCTL_API_KEY=your-api-key-here
|
||||||
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
|
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
|
||||||
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||||
@@ -71,24 +71,34 @@ The control plane now knows about all 50 certs and where they live.
|
|||||||
|
|
||||||
### 4. Configure ACME Issuer
|
### 4. Configure ACME Issuer
|
||||||
|
|
||||||
Go to **Issuers** → **Add Issuer**:
|
Go to **Issuers** → **+ New Issuer**:
|
||||||
- Type: ACME
|
1. Select **ACME** from the issuer type grid
|
||||||
- Directory URL: `https://acme-v02.api.letsencrypt.org/directory` (production)
|
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and any required config
|
||||||
- Email: your Let's Encrypt account email
|
|
||||||
- Challenge Type: `http-01` (if you have HTTP access) or `dns-01` (for wildcard/internal certs)
|
|
||||||
- For DNS-01, provide your DNS provider's script hook (Cloudflare, Route53, Azure DNS, etc.)
|
|
||||||
|
|
||||||
Test the connection. certctl uses the same Let's Encrypt account; no new credentials needed.
|
Alternatively, configure via environment variables before starting the server:
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE=http-01 # or dns-01 for wildcard certs
|
||||||
|
```
|
||||||
|
|
||||||
|
For DNS-01, also set:
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/present.sh
|
||||||
|
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cleanup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
certctl uses the same Let's Encrypt account; no new credentials needed.
|
||||||
|
|
||||||
### 5. Create Renewal Policies
|
### 5. Create Renewal Policies
|
||||||
|
|
||||||
Go to **Policies** → **New Policy**:
|
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||||
- Profile: ACME (or create a new one with `serverAuth` EKU)
|
- Name: e.g., "ACME Renewal Policy"
|
||||||
- Issuer: the ACME issuer you just created
|
- Type: `expiration_window` (to enforce renewal thresholds)
|
||||||
- Renewal Threshold: 30 days before expiry (default, adjust as needed)
|
- Severity: `high`
|
||||||
- Scope: select agent groups or individual agents managing your servers
|
- Config: set your renewal threshold (default: 30 days before expiry)
|
||||||
|
|
||||||
Assign this policy to your discovered certs.
|
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails (key algorithm requirements, expiration windows, etc.).
|
||||||
|
|
||||||
### 6. Disable Certbot Cron, One Server at a Time
|
### 6. Disable Certbot Cron, One Server at a Time
|
||||||
|
|
||||||
@@ -133,11 +143,11 @@ docker compose up -d
|
|||||||
# Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY
|
# Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you get 30/14/7-day warnings before any cert expires, across all 50 servers, in one place.
|
Now you get 30/14/7-day warnings before any cert expires, across all 10 servers, in one place.
|
||||||
|
|
||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 5 minutes vs. cron running once a day).
|
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 60 seconds vs. cron running once a day).
|
||||||
- **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently.
|
- **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently.
|
||||||
- **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds.
|
- **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds.
|
||||||
- **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires.
|
- **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires.
|
||||||
@@ -157,5 +167,5 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
|
|||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
||||||
- Explore [Network Discovery](./quickstart.md#network-discovery) to find certificates you didn't know about
|
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
||||||
- Set up [Kubernetes cert-manager integration](./cert-manager.md) if you manage in-cluster certs too
|
- Set up [Kubernetes cert-manager integration](./certctl-for-cert-manager-users.md) if you manage in-cluster certs too
|
||||||
|
|||||||
+1068
File diff suppressed because it is too large
Load Diff
+613
-4
@@ -39,6 +39,13 @@ 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 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
||||||
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
||||||
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
- [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)
|
||||||
- [Release Sign-Off](#release-sign-off)
|
- [Release Sign-Off](#release-sign-off)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -5069,6 +5076,487 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Release Sign-Off
|
## 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**).
|
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 +5570,7 @@ These must be green before starting manual QA:
|
|||||||
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
||||||
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
||||||
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
|
| 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
|
### Part 1: Infrastructure & Deployment
|
||||||
|
|
||||||
@@ -5574,14 +6062,135 @@ These must be green before starting manual QA:
|
|||||||
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
||||||
| 34.6 | CRL Signing in Sub-CA Mode | 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 | ☐ | | |
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|
||||||
| Category | Count |
|
| Category | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 121 |
|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
||||||
|
| ☐ Auto (not yet run) | 12 |
|
||||||
| — Skipped (preconditions not met in demo) | 5 |
|
| — Skipped (preconditions not met in demo) | 5 |
|
||||||
| ☐ Manual (requires hands-on verification) | 194 |
|
| ☐ Manual (requires hands-on verification) | 241 |
|
||||||
| **Total** | **320** |
|
| **Total** | **402** |
|
||||||
|
|
||||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ require (
|
|||||||
github.com/testcontainers/testcontainers-go v0.35.0
|
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 (
|
require (
|
||||||
dario.cat/mergo v1.0.0 // indirect
|
dario.cat/mergo v1.0.0 // indirect
|
||||||
@@ -63,5 +66,4 @@ require (
|
|||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
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)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to submit CSR", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -274,9 +275,10 @@ func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Requ
|
|||||||
requestID := middleware.GetRequestID(r.Context())
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
// Extract agent ID and certificate ID from path /api/v1/agents/{id}/certificates/{cert_id}
|
// 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/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
||||||
parts := strings.Split(path, "/")
|
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)
|
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID and Certificate ID are required", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
|||||||
|
|
||||||
created, err := h.svc.CreateCertificate(cert)
|
created, err := h.svc.CreateCertificate(cert)
|
||||||
if err != nil {
|
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)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ type ValidationError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValidateCommonName validates a certificate common name.
|
// ValidateCommonName validates a certificate common name.
|
||||||
|
// Accepts hostnames (TLS), IP addresses, and email addresses (S/MIME).
|
||||||
func ValidateCommonName(cn string) error {
|
func ValidateCommonName(cn string) error {
|
||||||
if cn == "" {
|
if cn == "" {
|
||||||
return ValidationError{Field: "common_name", Message: "common_name is required"}
|
return ValidationError{Field: "common_name", Message: "common_name is required"}
|
||||||
@@ -20,6 +22,13 @@ func ValidateCommonName(cn string) error {
|
|||||||
if len(cn) > 253 {
|
if len(cn) > 253 {
|
||||||
return ValidationError{Field: "common_name", Message: "common_name must be 253 characters or fewer"}
|
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
|
// Basic hostname validation: allow alphanumeric, dots, hyphens
|
||||||
if err := isValidHostname(cn); err != nil {
|
if err := isValidHostname(cn); err != nil {
|
||||||
return ValidationError{Field: "common_name", Message: fmt.Sprintf("invalid hostname format: %v", err)}
|
return ValidationError{Field: "common_name", Message: fmt.Sprintf("invalid hostname format: %v", err)}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type Config struct {
|
|||||||
EST ESTConfig
|
EST ESTConfig
|
||||||
Verification VerificationConfig
|
Verification VerificationConfig
|
||||||
ACME ACMEConfig
|
ACME ACMEConfig
|
||||||
|
Vault VaultConfig
|
||||||
|
DigiCert DigiCertConfig
|
||||||
Digest DigestConfig
|
Digest DigestConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +143,57 @@ type StepCAConfig struct {
|
|||||||
ProvisionerPassword string
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// DigestConfig controls the scheduled certificate digest email feature.
|
// DigestConfig controls the scheduled certificate digest email feature.
|
||||||
type DigestConfig struct {
|
type DigestConfig struct {
|
||||||
// Enabled controls whether periodic digest emails are generated and sent.
|
// Enabled controls whether periodic digest emails are generated and sent.
|
||||||
@@ -203,6 +256,11 @@ type ACMEConfig struct {
|
|||||||
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
|
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
|
||||||
// Setting: CERTCTL_ACME_ARI_ENABLED environment variable.
|
// Setting: CERTCTL_ACME_ARI_ENABLED environment variable.
|
||||||
ARIEnabled bool
|
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.
|
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
||||||
@@ -429,6 +487,19 @@ func Load() (*Config, error) {
|
|||||||
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
||||||
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*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"),
|
||||||
|
},
|
||||||
ACME: ACMEConfig{
|
ACME: ACMEConfig{
|
||||||
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
||||||
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
||||||
@@ -437,6 +508,7 @@ func Load() (*Config, error) {
|
|||||||
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
|
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
|
||||||
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
|
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
|
||||||
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
|
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
|
||||||
|
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", false),
|
||||||
},
|
},
|
||||||
Digest: DigestConfig{
|
Digest: DigestConfig{
|
||||||
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
|
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -58,6 +59,10 @@ type Config struct {
|
|||||||
// ARIEnabled enables ACME Renewal Information (RFC 9702) support per CERTCTL_ACME_ARI_ENABLED.
|
// 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.
|
// When enabled, the connector queries the CA's ARI endpoint to get CA-directed renewal timing.
|
||||||
ARIEnabled bool `json:"ari_enabled,omitempty"`
|
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
|
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||||
@@ -114,6 +119,18 @@ func New(config *Config, logger *slog.Logger) *Connector {
|
|||||||
return c
|
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.
|
// ValidateConfig checks that the ACME directory URL is reachable and valid.
|
||||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
var cfg Config
|
var cfg Config
|
||||||
@@ -129,10 +146,16 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
|||||||
return fmt.Errorf("ACME email is required")
|
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
|
// 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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.DirectoryURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
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{
|
c.client = &acme.Client{
|
||||||
Key: key,
|
Key: key,
|
||||||
DirectoryURL: c.config.DirectoryURL,
|
DirectoryURL: c.config.DirectoryURL,
|
||||||
|
HTTPClient: c.httpClient(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register or retrieve the ACME account
|
// 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)
|
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)
|
// Step 2: Solve authorizations (HTTP-01 challenges)
|
||||||
if order.Status == acme.StatusPending {
|
if order.Status == acme.StatusPending {
|
||||||
if err := c.solveAuthorizations(ctx, order.AuthzURLs); err != nil {
|
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
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("order failed after challenge: %w", err)
|
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 {
|
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)
|
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 {
|
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 {
|
if len(derChain) == 0 {
|
||||||
@@ -387,7 +455,7 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
|||||||
Serial: serial,
|
Serial: serial,
|
||||||
NotBefore: notBefore,
|
NotBefore: notBefore,
|
||||||
NotAfter: notAfter,
|
NotAfter: notAfter,
|
||||||
OrderID: order.URI,
|
OrderID: orderURI,
|
||||||
}, nil
|
}, 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,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/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -74,17 +75,37 @@ type Connector struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new step-ca connector with the given configuration and logger.
|
// 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 {
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
if config != nil && config.ValidityDays == 0 {
|
// Don't default ValidityDays — let step-ca use its own default duration.
|
||||||
config.ValidityDays = 90
|
// 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{
|
return &Connector{
|
||||||
config: config,
|
config: config,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
httpClient: &http.Client{
|
httpClient: httpClient,
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +124,7 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
|||||||
return fmt.Errorf("step-ca provisioner_name is required")
|
return fmt.Errorf("step-ca provisioner_name is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.ValidityDays == 0 {
|
// Don't default ValidityDays — 0 means "let step-ca use its own default duration"
|
||||||
cfg.ValidityDays = 90
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check CA health
|
// Check CA health
|
||||||
healthURL := cfg.CAURL + "/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)
|
return nil, fmt.Errorf("failed to generate provisioner token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the sign request
|
// Build the sign request.
|
||||||
now := time.Now()
|
// When ValidityDays is 0 (default), omit NotBefore/NotAfter so step-ca uses its
|
||||||
notAfter := now.AddDate(0, 0, c.config.ValidityDays)
|
// 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{
|
signReq := signRequest{
|
||||||
CsrPEM: request.CSRPEM,
|
CsrPEM: request.CSRPEM,
|
||||||
OTT: ott,
|
OTT: ott,
|
||||||
NotBefore: now,
|
}
|
||||||
NotAfter: notAfter,
|
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)
|
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.
|
// 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) {
|
func (c *Connector) generateProvisionerToken(subject string, sans []string) (string, error) {
|
||||||
// For the initial implementation, we generate a simple self-signed JWT.
|
var key *ecdsa.PrivateKey
|
||||||
// In production, the provisioner key would be loaded from the configured path.
|
var kid string
|
||||||
// step-ca expects a JWT with: sub=<CN>, iss=<provisioner>, aud=<ca-url>/sign
|
|
||||||
|
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()
|
now := time.Now()
|
||||||
|
|
||||||
|
// step-ca expects: aud = <ca-url>/1.0/sign (the sign endpoint audience)
|
||||||
claims := map[string]interface{}{
|
claims := map[string]interface{}{
|
||||||
"sub": subject,
|
"sub": subject,
|
||||||
"iss": c.config.ProvisionerName,
|
"iss": c.config.ProvisionerName,
|
||||||
"aud": c.config.CAURL + "/sign",
|
"aud": c.config.CAURL + "/1.0/sign",
|
||||||
"nbf": now.Unix(),
|
"nbf": now.Unix(),
|
||||||
"iat": now.Unix(),
|
"iat": now.Unix(),
|
||||||
"exp": now.Add(5 * time.Minute).Unix(),
|
"exp": now.Add(5 * time.Minute).Unix(),
|
||||||
"jti": generateJTI(),
|
"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 {
|
if len(sans) > 0 {
|
||||||
claims["sans"] = sans
|
claims["sans"] = sans
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate an ephemeral signing key for the token.
|
return signJWTWithKID(claims, key, kid)
|
||||||
// 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.
|
// loadProvisionerKey loads and decrypts the step-ca provisioner key from disk.
|
||||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
// Returns the ECDSA private key and the key ID (JWK thumbprint).
|
||||||
if err != nil {
|
func (c *Connector) loadProvisionerKey() (*ecdsa.PrivateKey, string, error) {
|
||||||
return "", fmt.Errorf("failed to generate token signing key: %w", err)
|
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.
|
// generateJTI creates a unique JWT ID.
|
||||||
@@ -360,14 +423,21 @@ func generateJTI() string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(b)
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// signJWT creates a minimal ES256 JWT from the given claims.
|
// signJWTWithKID creates an ES256 JWT with a key ID in the header.
|
||||||
func signJWT(claims map[string]interface{}, key *ecdsa.PrivateKey) (string, error) {
|
func signJWTWithKID(claims map[string]interface{}, key *ecdsa.PrivateKey, kid string) (string, error) {
|
||||||
// Header
|
// Header with kid so step-ca can look up the provisioner
|
||||||
header := map[string]string{
|
header := map[string]string{
|
||||||
"alg": "ES256",
|
"alg": "ES256",
|
||||||
"typ": "JWT",
|
"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)
|
headerJSON, err := json.Marshal(header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"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)
|
"chain_path", cfg.ChainPath)
|
||||||
|
|
||||||
// Verify directory exists and is writable
|
// 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) {
|
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("NGINX cert directory does not exist: %s", certDir)
|
return fmt.Errorf("NGINX cert directory does not exist: %s", certDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify validate command works
|
// Verify validate command works
|
||||||
cmd := exec.CommandContext(ctx, cfg.ValidateCommand)
|
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
c.logger.Warn("NGINX config validation failed during config check",
|
c.logger.Warn("NGINX config validation failed during config check",
|
||||||
"error", err,
|
"error", err,
|
||||||
@@ -115,20 +116,37 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write chain with same permissions
|
// Write chain with same permissions
|
||||||
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
if c.config.ChainPath != "" {
|
||||||
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
||||||
c.logger.Error("chain deployment failed", "error", err)
|
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
||||||
return &target.DeploymentResult{
|
c.logger.Error("chain deployment failed", "error", err)
|
||||||
Success: false,
|
return &target.DeploymentResult{
|
||||||
TargetAddress: c.config.ChainPath,
|
Success: false,
|
||||||
Message: errMsg,
|
TargetAddress: c.config.ChainPath,
|
||||||
DeployedAt: time.Now(),
|
Message: errMsg,
|
||||||
}, fmt.Errorf("%s", 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
|
// Validate NGINX configuration before reload
|
||||||
c.logger.Debug("validating NGINX configuration", "validate_command", c.config.ValidateCommand)
|
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 {
|
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||||
errMsg := fmt.Sprintf("NGINX config validation failed: %v (output: %s)", err, string(output))
|
errMsg := fmt.Sprintf("NGINX config validation failed: %v (output: %s)", err, string(output))
|
||||||
c.logger.Error("NGINX validation failed", "error", err, "output", 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
|
// Reload NGINX
|
||||||
c.logger.Debug("reloading NGINX", "reload_command", c.config.ReloadCommand)
|
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 {
|
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
||||||
errMsg := fmt.Sprintf("NGINX reload failed: %v (output: %s)", err, string(output))
|
errMsg := fmt.Sprintf("NGINX reload failed: %v (output: %s)", err, string(output))
|
||||||
c.logger.Error("NGINX reload failed", "error", err, "output", 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()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Validate NGINX configuration
|
// 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 {
|
if err := validateCmd.Run(); err != nil {
|
||||||
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
|
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
|
||||||
c.logger.Error("validation failed", "error", err)
|
c.logger.Error("validation failed", "error", err)
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ const (
|
|||||||
IssuerTypeGenericCA IssuerType = "GenericCA"
|
IssuerTypeGenericCA IssuerType = "GenericCA"
|
||||||
IssuerTypeStepCA IssuerType = "StepCA"
|
IssuerTypeStepCA IssuerType = "StepCA"
|
||||||
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
||||||
|
IssuerTypeVault IssuerType = "VaultPKI"
|
||||||
|
IssuerTypeDigiCert IssuerType = "DigiCert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TargetType represents the type of deployment target.
|
// TargetType represents the type of deployment target.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Job struct {
|
|||||||
Type JobType `json:"type"`
|
Type JobType `json:"type"`
|
||||||
CertificateID string `json:"certificate_id"`
|
CertificateID string `json:"certificate_id"`
|
||||||
TargetID *string `json:"target_id,omitempty"`
|
TargetID *string `json:"target_id,omitempty"`
|
||||||
|
AgentID *string `json:"agent_id,omitempty"`
|
||||||
Status JobStatus `json:"status"`
|
Status JobStatus `json:"status"`
|
||||||
Attempts int `json:"attempts"`
|
Attempts int `json:"attempts"`
|
||||||
MaxAttempts int `json:"max_attempts"`
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
|||||||
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
|
|||||||
return jobs, nil
|
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 {
|
type mockAuditRepository struct {
|
||||||
events []*domain.AuditEvent
|
events []*domain.AuditEvent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ type JobRepository interface {
|
|||||||
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
||||||
// GetPendingJobs returns jobs not yet processed of a specific type.
|
// GetPendingJobs returns jobs not yet processed of a specific type.
|
||||||
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
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.
|
// RenewalPolicyRepository defines operations for managing renewal policies.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func NewJobRepository(db *sql.DB) *JobRepository {
|
|||||||
// List returns all jobs
|
// List returns all jobs
|
||||||
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
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
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
ORDER BY created_at DESC
|
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
|
// Get retrieves a job by ID
|
||||||
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `
|
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
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -77,11 +77,11 @@ func (r *JobRepository) Create(ctx context.Context, job *domain.Job) error {
|
|||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO jobs (
|
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
|
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
|
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.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
|
||||||
job.CreatedAt).Scan(&job.ID)
|
job.CreatedAt).Scan(&job.ID)
|
||||||
|
|
||||||
@@ -99,15 +99,16 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
|
|||||||
type = $1,
|
type = $1,
|
||||||
certificate_id = $2,
|
certificate_id = $2,
|
||||||
target_id = $3,
|
target_id = $3,
|
||||||
status = $4,
|
agent_id = $4,
|
||||||
attempts = $5,
|
status = $5,
|
||||||
max_attempts = $6,
|
attempts = $6,
|
||||||
last_error = $7,
|
max_attempts = $7,
|
||||||
scheduled_at = $8,
|
last_error = $8,
|
||||||
started_at = $9,
|
scheduled_at = $9,
|
||||||
completed_at = $10
|
started_at = $10,
|
||||||
WHERE id = $11
|
completed_at = $11
|
||||||
`, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
WHERE id = $12
|
||||||
|
`, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
||||||
job.CompletedAt, job.ID)
|
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
|
// ListByStatus returns jobs with a specific status
|
||||||
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
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
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE status = $1
|
WHERE status = $1
|
||||||
@@ -181,7 +182,7 @@ func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatu
|
|||||||
// ListByCertificate returns all jobs for a certificate
|
// ListByCertificate returns all jobs for a certificate
|
||||||
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
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
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE certificate_id = $1
|
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
|
// GetPendingJobs returns jobs not yet processed of a specific type
|
||||||
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
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
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE type = $1 AND status = $2
|
WHERE type = $1 AND status = $2
|
||||||
@@ -267,13 +268,71 @@ func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobTy
|
|||||||
return jobs, nil
|
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
|
// scanJob scans a job from a row or rows
|
||||||
func scanJob(scanner interface {
|
func scanJob(scanner interface {
|
||||||
Scan(...interface{}) error
|
Scan(...interface{}) error
|
||||||
}) (*domain.Job, error) {
|
}) (*domain.Job, error) {
|
||||||
var job domain.Job
|
var job domain.Job
|
||||||
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
|
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)
|
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+14
-34
@@ -178,14 +178,15 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
version := &domain.CertificateVersion{
|
version := &domain.CertificateVersion{
|
||||||
ID: generateID("certver"),
|
ID: generateID("certver"),
|
||||||
CertificateID: certID,
|
CertificateID: certID,
|
||||||
SerialNumber: result.Serial,
|
SerialNumber: result.Serial,
|
||||||
NotBefore: result.NotBefore,
|
NotBefore: result.NotBefore,
|
||||||
NotAfter: result.NotAfter,
|
NotAfter: result.NotAfter,
|
||||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
FingerprintSHA256: computeCertFingerprint(result.CertPEM),
|
||||||
CSRPEM: string(csrPEM),
|
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||||
CreatedAt: time.Now(),
|
CSRPEM: string(csrPEM),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
|
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
|
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
||||||
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
// 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) {
|
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)
|
_, err := s.agentRepo.Get(ctx, agentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var workForAgent []*domain.Job
|
// Return only jobs assigned to this agent (via agent_id or target→agent relationship)
|
||||||
|
return s.jobRepo.ListPendingByAgentID(ctx, agentID)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportJobStatus updates a job's status based on agent feedback.
|
// 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) {
|
func TestGetPendingWork(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
agentID := "agent-001"
|
||||||
agent := &domain.Agent{
|
agent := &domain.Agent{
|
||||||
ID: "agent-001",
|
ID: agentID,
|
||||||
Name: "prod-agent",
|
Name: "prod-agent",
|
||||||
Hostname: "server-01",
|
Hostname: "server-01",
|
||||||
Status: domain.AgentStatusOnline,
|
Status: domain.AgentStatusOnline,
|
||||||
@@ -146,6 +147,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
Type: domain.JobTypeDeployment,
|
Type: domain.JobTypeDeployment,
|
||||||
CertificateID: "cert-001",
|
CertificateID: "cert-001",
|
||||||
Status: domain.JobStatusPending,
|
Status: domain.JobStatusPending,
|
||||||
|
AgentID: &agentID,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
job2 := &domain.Job{
|
job2 := &domain.Job{
|
||||||
@@ -157,7 +159,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agentRepo := &mockAgentRepo{
|
agentRepo := &mockAgentRepo{
|
||||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
Agents: map[string]*domain.Agent{agentID: agent},
|
||||||
HeartbeatUpdates: make(map[string]time.Time),
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
certRepo := &mockCertRepo{
|
certRepo := &mockCertRepo{
|
||||||
@@ -177,7 +179,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetPendingWork failed: %v", err)
|
t.Fatalf("GetPendingWork failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -185,11 +187,132 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
if len(jobs) != 1 {
|
if len(jobs) != 1 {
|
||||||
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
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)
|
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) {
|
func TestReportJobStatus(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import (
|
|||||||
type CertificateService struct {
|
type CertificateService struct {
|
||||||
certRepo repository.CertificateRepository
|
certRepo repository.CertificateRepository
|
||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
|
jobRepo repository.JobRepository
|
||||||
policyService *PolicyService
|
policyService *PolicyService
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
revSvc *RevocationSvc
|
revSvc *RevocationSvc
|
||||||
caSvc *CAOperationsSvc
|
caSvc *CAOperationsSvc
|
||||||
|
keygenMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCertificateService creates a new certificate service.
|
// NewCertificateService creates a new certificate service.
|
||||||
@@ -48,6 +50,16 @@ func (s *CertificateService) SetTargetRepo(repo repository.TargetRepository) {
|
|||||||
s.targetRepo = repo
|
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.
|
// List returns a paginated list of certificates matching the filter.
|
||||||
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
||||||
certs, total, err := s.certRepo.List(ctx, filter)
|
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.
|
// 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 {
|
func (s *CertificateService) TriggerRenewalWithActor(ctx context.Context, certID string, actor string) error {
|
||||||
cert, err := s.certRepo.Get(ctx, certID)
|
cert, err := s.certRepo.Get(ctx, certID)
|
||||||
if err != nil {
|
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)
|
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
|
// Record audit event
|
||||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||||
"renewal_triggered", "certificate", certID,
|
"renewal_triggered", "certificate", certID,
|
||||||
@@ -304,6 +357,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
|||||||
if cert.UpdatedAt.IsZero() {
|
if cert.UpdatedAt.IsZero() {
|
||||||
cert.UpdatedAt = now
|
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 {
|
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
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 != "" {
|
if target.ID != "" {
|
||||||
job.TargetID = &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 {
|
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||||
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
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 {
|
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
||||||
t.Errorf("expected job to have TargetID set")
|
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
|
// Process each job
|
||||||
for _, job := range pendingJobs {
|
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 {
|
if err := s.processJob(ctx, job); err != nil {
|
||||||
s.logger.Error("failed to process job",
|
s.logger.Error("failed to process job",
|
||||||
"job_id", job.ID,
|
"job_id", job.ID,
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ type RenewalService struct {
|
|||||||
jobRepo repository.JobRepository
|
jobRepo repository.JobRepository
|
||||||
renewalPolicyRepo repository.RenewalPolicyRepository
|
renewalPolicyRepo repository.RenewalPolicyRepository
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
|
targetRepo repository.TargetRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
notificationSvc *NotificationService
|
notificationSvc *NotificationService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry map[string]IssuerConnector
|
||||||
keygenMode string // "agent" (default) or "server" (demo only)
|
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.
|
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
|
||||||
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
|
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
|
||||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
// 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)
|
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
||||||
|
|
||||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
// 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
|
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
|
// Check for existing pending/running renewal jobs to avoid duplicates
|
||||||
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
|
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -206,9 +241,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Record audit event
|
// 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,
|
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||||
"renewal_job_created", "certificate", cert.ID,
|
"renewal_job_created", "certificate", cert.ID, auditMeta); auditErr != nil {
|
||||||
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}); auditErr != nil {
|
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
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.
|
// 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) {
|
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
|
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{
|
deployJob := &domain.Job{
|
||||||
ID: generateID("job"),
|
ID: generateID("job"),
|
||||||
CertificateID: cert.ID,
|
CertificateID: cert.ID,
|
||||||
Type: domain.JobTypeDeployment,
|
Type: domain.JobTypeDeployment,
|
||||||
Status: domain.JobStatusPending,
|
Status: domain.JobStatusPending,
|
||||||
TargetID: &tid,
|
TargetID: &tid,
|
||||||
|
AgentID: agentIDPtr,
|
||||||
MaxAttempts: 3,
|
MaxAttempts: 3,
|
||||||
ScheduledAt: time.Now(),
|
ScheduledAt: time.Now(),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if err := s.jobRepo.Create(ctx, deployJob); err != nil {
|
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
|
// stringPtr is defined in notification_test.go
|
||||||
|
|||||||
@@ -243,6 +243,25 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
|
|||||||
return jobs, nil
|
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) {
|
func (m *mockJobRepo) AddJob(job *domain.Job) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -660,8 +679,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
|
|||||||
|
|
||||||
// mockIssuerConnector is a test implementation of IssuerConnector
|
// mockIssuerConnector is a test implementation of IssuerConnector
|
||||||
type mockIssuerConnector struct {
|
type mockIssuerConnector struct {
|
||||||
Result *IssuanceResult
|
Result *IssuanceResult
|
||||||
Err error
|
Err error
|
||||||
|
getRenewalInfoResult *RenewalInfoResult
|
||||||
|
getRenewalInfoErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, 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) {
|
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
||||||
if m.Err != nil {
|
if m.getRenewalInfoErr != nil {
|
||||||
return nil, m.Err
|
return nil, m.getRenewalInfoErr
|
||||||
}
|
}
|
||||||
now := time.Now()
|
if m.getRenewalInfoResult != nil {
|
||||||
return &RenewalInfoResult{
|
return m.getRenewalInfoResult, nil
|
||||||
SuggestedWindowStart: now,
|
}
|
||||||
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
|
// Default: return nil, nil (issuer does not support ARI)
|
||||||
}, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constructor functions for mocks
|
// Constructor functions for mocks
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ func (m *mockVerificationJobRepo) GetPendingJobs(ctx context.Context, jobType do
|
|||||||
return nil, nil
|
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.
|
// newVerificationTestService creates a VerificationService wired with test doubles.
|
||||||
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
|
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
|
||||||
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
|
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA
|
|||||||
('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-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-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-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-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')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -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,
|
triggerNetworkScan,
|
||||||
previewDigest,
|
previewDigest,
|
||||||
sendDigest,
|
sendDigest,
|
||||||
|
getJob,
|
||||||
|
getJobVerification,
|
||||||
|
getIssuer,
|
||||||
|
getTarget,
|
||||||
|
getPrometheusMetrics,
|
||||||
|
getCertificateDeployments,
|
||||||
|
getCRL,
|
||||||
|
getOCSPStatus,
|
||||||
|
updateIssuer,
|
||||||
|
updateTarget,
|
||||||
|
getPolicy,
|
||||||
} from './client';
|
} from './client';
|
||||||
|
|
||||||
// Mock global fetch
|
// Mock global fetch
|
||||||
@@ -627,6 +638,50 @@ describe('API Client', () => {
|
|||||||
expect(url).toBe('/api/v1/issuers');
|
expect(url).toBe('/api/v1/issuers');
|
||||||
expect(init.method).toBe('POST');
|
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 ──────────────────────────────────────────
|
// ─── Audit ──────────────────────────────────────────
|
||||||
@@ -1006,4 +1061,148 @@ describe('API Client', () => {
|
|||||||
expect(result.message).toBe('digest sent');
|
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
|
// Agents
|
||||||
export const getAgents = (params: Record<string, string> = {}) => {
|
export const getAgents = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
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>) =>
|
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
|
||||||
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
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) =>
|
export const deletePolicy = (id: string) =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
|
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
@@ -188,6 +211,9 @@ export const createIssuer = (data: Partial<Issuer>) =>
|
|||||||
export const testIssuerConnection = (id: string) =>
|
export const testIssuerConnection = (id: string) =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
|
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) =>
|
export const deleteIssuer = (id: string) =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
|
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>) =>
|
export const createTarget = (data: Partial<Target>) =>
|
||||||
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
|
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) =>
|
export const deleteTarget = (id: string) =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
@@ -365,5 +394,32 @@ export const previewDigest = () => {
|
|||||||
export const sendDigest = () =>
|
export const sendDigest = () =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
|
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
|
// Health
|
||||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export interface Certificate {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
revoked_at?: string;
|
revoked_at?: string;
|
||||||
revocation_reason?: string;
|
revocation_reason?: string;
|
||||||
|
target_ids?: string[];
|
||||||
tags: Record<string, string>;
|
tags: Record<string, string>;
|
||||||
|
last_renewal_at?: string;
|
||||||
|
last_deployment_at?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,8 @@ export interface CertificateVersion {
|
|||||||
csr_pem: string;
|
csr_pem: string;
|
||||||
not_before: string;
|
not_before: string;
|
||||||
not_after: string;
|
not_after: string;
|
||||||
|
key_algorithm?: string;
|
||||||
|
key_size?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +75,8 @@ export interface Job {
|
|||||||
id: string;
|
id: string;
|
||||||
certificate_id: string;
|
certificate_id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
target_id?: string;
|
||||||
|
agent_id?: string;
|
||||||
status: string;
|
status: string;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
max_attempts: number;
|
max_attempts: number;
|
||||||
@@ -133,7 +140,10 @@ export interface Issuer {
|
|||||||
type: string;
|
type: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** Backend returns enabled boolean; status is derived from this */
|
||||||
|
enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
@@ -145,6 +155,7 @@ export interface Target {
|
|||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyAlgorithmRule {
|
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: '/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: '/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: '/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' },
|
{ 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>
|
</nav>
|
||||||
|
|
||||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
<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 && (
|
{authRequired && (
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const statusStyles: Record<string, string> = {
|
|||||||
Unmanaged: 'badge-warning',
|
Unmanaged: 'badge-warning',
|
||||||
Managed: 'badge-success',
|
Managed: 'badge-success',
|
||||||
Dismissed: 'badge-neutral',
|
Dismissed: 'badge-neutral',
|
||||||
|
// Issuer statuses
|
||||||
|
Enabled: 'badge-success',
|
||||||
|
Disabled: 'badge-neutral',
|
||||||
// Notification statuses
|
// Notification statuses
|
||||||
sent: 'badge-success',
|
sent: 'badge-success',
|
||||||
pending: 'badge-warning',
|
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,179 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
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',
|
||||||
|
description: 'Sectigo Certificate Manager \u2014 coming soon',
|
||||||
|
icon: '\uD83D\uDCE6',
|
||||||
|
configFields: [],
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 AgentFleetPage from './pages/AgentFleetPage';
|
||||||
import DiscoveryPage from './pages/DiscoveryPage';
|
import DiscoveryPage from './pages/DiscoveryPage';
|
||||||
import NetworkScanPage from './pages/NetworkScanPage';
|
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';
|
import './index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||||
<Route path="fleet" element={<AgentFleetPage />} />
|
<Route path="fleet" element={<AgentFleetPage />} />
|
||||||
<Route path="jobs" element={<JobsPage />} />
|
<Route path="jobs" element={<JobsPage />} />
|
||||||
|
<Route path="jobs/:id" element={<JobDetailPage />} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
<Route path="policies" element={<PoliciesPage />} />
|
<Route path="policies" element={<PoliciesPage />} />
|
||||||
<Route path="profiles" element={<ProfilesPage />} />
|
<Route path="profiles" element={<ProfilesPage />} />
|
||||||
<Route path="issuers" element={<IssuersPage />} />
|
<Route path="issuers" element={<IssuersPage />} />
|
||||||
|
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||||
<Route path="targets" element={<TargetsPage />} />
|
<Route path="targets" element={<TargetsPage />} />
|
||||||
|
<Route path="targets/:id" element={<TargetDetailPage />} />
|
||||||
<Route path="owners" element={<OwnersPage />} />
|
<Route path="owners" element={<OwnersPage />} />
|
||||||
<Route path="teams" element={<TeamsPage />} />
|
<Route path="teams" element={<TeamsPage />} />
|
||||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
||||||
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="short-lived" element={<ShortLivedPage />} />
|
<Route path="short-lived" element={<ShortLivedPage />} />
|
||||||
<Route path="discovery" element={<DiscoveryPage />} />
|
<Route path="discovery" element={<DiscoveryPage />} />
|
||||||
<Route path="network-scans" element={<NetworkScanPage />} />
|
<Route path="network-scans" element={<NetworkScanPage />} />
|
||||||
|
<Route path="digest" element={<DigestPage />} />
|
||||||
|
<Route path="observability" element={<ObservabilityPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ const OS_COLORS: Record<string, string> = {
|
|||||||
unknown: '#64748b',
|
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> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
Online: '#10b981',
|
Online: '#10b981',
|
||||||
Offline: '#ef4444',
|
Offline: '#ef4444',
|
||||||
@@ -86,7 +94,7 @@ export default function AgentFleetPage() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
|
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
|
||||||
name,
|
name: displayOS(name),
|
||||||
value,
|
value,
|
||||||
fill: OS_COLORS[name.toLowerCase()] || '#64748b',
|
fill: OS_COLORS[name.toLowerCase()] || '#64748b',
|
||||||
}));
|
}));
|
||||||
@@ -216,7 +224,7 @@ export default function AgentFleetPage() {
|
|||||||
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
|
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
|
||||||
/>
|
/>
|
||||||
<h4 className="text-sm font-medium text-ink">
|
<h4 className="text-sm font-medium text-ink">
|
||||||
{group.os} / {group.arch}
|
{displayOS(group.os)} / {group.arch}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-xs text-ink-faint">
|
<span className="text-xs text-ink-faint">
|
||||||
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
|
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { REVOCATION_REASONS } from '../api/types';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
@@ -13,83 +13,169 @@ import type { Certificate } from '../api/types';
|
|||||||
|
|
||||||
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
id: '',
|
id: '',
|
||||||
common_name: '',
|
common_name: '',
|
||||||
|
sans: '',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
issuer_id: '',
|
issuer_id: '',
|
||||||
|
certificate_profile_id: '',
|
||||||
owner_id: '',
|
owner_id: '',
|
||||||
team_id: '',
|
team_id: '',
|
||||||
renewal_policy_id: '',
|
renewal_policy_id: '',
|
||||||
|
tags: '',
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
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({
|
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(),
|
onSuccess: () => onSuccess(),
|
||||||
onError: (err: Error) => setError(err.message),
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
<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()}>
|
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
<h2 className="text-lg font-semibold text-ink mb-4">New Certificate</h2>
|
<h2 className="text-lg font-semibold text-ink mb-4">New Certificate</h2>
|
||||||
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
|
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-ink-muted block mb-1">Name *</label>
|
||||||
|
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="API Production Cert" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
|
<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 }))}
|
<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)" />
|
placeholder="mc-api-prod (auto-generated if empty)" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-ink-muted block mb-1">Common Name *</label>
|
<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 }))}
|
<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" />
|
placeholder="api.example.com" />
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-ink-muted block mb-1">Environment</label>
|
<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 }))}
|
<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="production">Production</option>
|
||||||
<option value="staging">Staging</option>
|
<option value="staging">Staging</option>
|
||||||
<option value="development">Development</option>
|
<option value="development">Development</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-ink-muted block mb-1">Issuer ID *</label>
|
<label className="text-xs text-ink-muted block mb-1">Policy</label>
|
||||||
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
|
<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"
|
className={inputClass}
|
||||||
placeholder="iss-local" />
|
placeholder="rp-standard" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<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 }))}
|
<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" />
|
placeholder="o-alice" />
|
||||||
</div>
|
</div>
|
||||||
<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 }))}
|
<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" />
|
placeholder="t-platform" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label className="text-xs text-ink-muted block mb-1">Policy ID</label>
|
<div>
|
||||||
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
|
<label className="text-xs text-ink-muted block mb-1">Tags</label>
|
||||||
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"
|
<input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||||
placeholder="rp-standard" />
|
className={inputClass}
|
||||||
</div>
|
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>
|
</div>
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
|
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
disabled={!form.common_name || !form.issuer_id || mutation.isPending}
|
disabled={!form.name || !form.common_name || !form.issuer_id || mutation.isPending}
|
||||||
className="btn btn-primary text-sm disabled:opacity-50"
|
className="btn btn-primary text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{mutation.isPending ? 'Creating...' : 'Create Certificate'}
|
{mutation.isPending ? 'Creating...' : 'Create Certificate'}
|
||||||
@@ -238,15 +324,25 @@ export default function CertificatesPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [envFilter, setEnvFilter] = useState('');
|
const [envFilter, setEnvFilter] = useState('');
|
||||||
|
const [issuerFilter, setIssuerFilter] = useState('');
|
||||||
|
const [ownerFilter, setOwnerFilter] = useState('');
|
||||||
|
const [profileFilter, setProfileFilter] = useState('');
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
|
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
|
||||||
const [showBulkReassign, setShowBulkReassign] = useState(false);
|
const [showBulkReassign, setShowBulkReassign] = useState(false);
|
||||||
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
|
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> = {};
|
const params: Record<string, string> = {};
|
||||||
if (statusFilter) params.status = statusFilter;
|
if (statusFilter) params.status = statusFilter;
|
||||||
if (envFilter) params.environment = envFilter;
|
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({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['certificates', params],
|
queryKey: ['certificates', params],
|
||||||
@@ -295,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: '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> },
|
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-ink-muted text-xs">{c.owner_id}</span> },
|
||||||
];
|
];
|
||||||
@@ -375,6 +472,36 @@ export default function CertificatesPage() {
|
|||||||
<option value="staging">Staging</option>
|
<option value="staging">Staging</option>
|
||||||
<option value="development">Development</option>
|
<option value="development">Development</option>
|
||||||
</select>
|
</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>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error ? (
|
{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',
|
label: 'Expiry',
|
||||||
render: (c) => <span className="text-xs">{formatExpiry(c.not_after)}</span>,
|
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',
|
key: 'fingerprint',
|
||||||
label: '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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -8,83 +9,27 @@ import StatusBadge from '../components/StatusBadge';
|
|||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { Issuer } from '../api/types';
|
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> = {
|
/** Derive display status from backend enabled boolean */
|
||||||
local_ca: 'Local CA',
|
function issuerStatus(issuer: Issuer): string {
|
||||||
acme: 'ACME',
|
if (issuer.enabled !== undefined) {
|
||||||
stepca: 'step-ca',
|
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||||
openssl: 'OpenSSL/Custom',
|
}
|
||||||
vault: 'Vault PKI',
|
// Fallback for legacy data that may have status string
|
||||||
manual: 'Manual',
|
return issuer.status || 'Unknown';
|
||||||
};
|
|
||||||
|
|
||||||
interface IssuerConfigField {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
placeholder?: string;
|
|
||||||
required: boolean;
|
|
||||||
type?: string;
|
|
||||||
options?: string[];
|
|
||||||
defaultValue?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export default function IssuersPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [createStep, setCreateStep] = useState<'type' | 'config'>('type');
|
const [preselectedType, setPreselectedType] = useState<string | null>(null);
|
||||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||||
const [createForm, setCreateForm] = useState<Record<string, unknown>>({});
|
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['issuers'],
|
queryKey: ['issuers'],
|
||||||
@@ -108,19 +53,31 @@ export default function IssuersPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setCreateStep('type');
|
setPreselectedType(null);
|
||||||
setSelectedType(null);
|
|
||||||
setCreateForm({});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>[] = [
|
const columns: Column<Issuer>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
label: 'Issuer',
|
label: 'Issuer',
|
||||||
render: (i) => (
|
render: (i) => (
|
||||||
<div>
|
<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 className="text-xs text-ink-faint font-mono">{i.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -135,7 +92,7 @@ export default function IssuersPage() {
|
|||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
render: (i) => <StatusBadge status={i.status} />,
|
render: (i) => <StatusBadge status={issuerStatus(i)} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'config',
|
key: 'config',
|
||||||
@@ -143,9 +100,15 @@ export default function IssuersPage() {
|
|||||||
render: (i) => {
|
render: (i) => {
|
||||||
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">—</span>;
|
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">—</span>;
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
|
<button
|
||||||
{JSON.stringify(i.config).slice(0, 60)}
|
onClick={(e) => {
|
||||||
</span>
|
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
|
<PageHeader
|
||||||
title="Issuers"
|
title="Issuers"
|
||||||
subtitle={data ? `${data.total} issuers` : undefined}
|
subtitle={data ? `${data.total} configured` : undefined}
|
||||||
action={
|
action={
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setPreselectedType(null);
|
||||||
setShowCreateModal(true);
|
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"
|
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>
|
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Config Detail Modal */}
|
||||||
|
{configModal && (
|
||||||
|
<ConfigDetailModal
|
||||||
|
title={configModal.title}
|
||||||
|
config={configModal.config}
|
||||||
|
onClose={() => setConfigModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Issuer Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<CreateIssuerModal
|
<CreateIssuerModal
|
||||||
step={createStep}
|
preselectedType={preselectedType}
|
||||||
selectedType={selectedType}
|
onSubmit={(name, type, config) => {
|
||||||
form={createForm}
|
createMutation.mutate({ name, type, config });
|
||||||
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 });
|
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setCreateStep('type');
|
setPreselectedType(null);
|
||||||
setSelectedType(null);
|
|
||||||
setCreateForm({});
|
|
||||||
}}
|
}}
|
||||||
isSubmitting={createMutation.isPending}
|
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 {
|
interface CreateIssuerModalProps {
|
||||||
step: 'type' | 'config';
|
preselectedType: string | null;
|
||||||
selectedType: string | null;
|
onSubmit: (name: string, type: string, config: Record<string, unknown>) => void;
|
||||||
form: Record<string, unknown>;
|
|
||||||
onTypeSelect: (type: string) => void;
|
|
||||||
onFormChange: (field: string, value: unknown) => void;
|
|
||||||
onBack: () => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateIssuerModal({
|
function CreateIssuerModal({ preselectedType, onSubmit, onCancel, isSubmitting }: CreateIssuerModalProps) {
|
||||||
step,
|
const [step, setStep] = useState<'type' | 'config'>(preselectedType ? 'config' : 'type');
|
||||||
selectedType,
|
const [selectedType, setSelectedType] = useState<string | null>(preselectedType);
|
||||||
form,
|
const [form, setForm] = useState<Record<string, unknown>>(() => {
|
||||||
onTypeSelect,
|
if (preselectedType) {
|
||||||
onFormChange,
|
const tc = issuerTypes.find(t => t.id === preselectedType);
|
||||||
onBack,
|
const defaults: Record<string, unknown> = {};
|
||||||
onSubmit,
|
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
|
||||||
onCancel,
|
return defaults;
|
||||||
isSubmitting,
|
}
|
||||||
}: CreateIssuerModalProps) {
|
return {};
|
||||||
const selectedTypeConfig = issuerTypes.find((t) => t.id === selectedType);
|
});
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<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">
|
<h2 className="text-lg font-semibold text-ink">
|
||||||
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
|
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button onClick={onCancel} className="text-ink-muted hover:text-ink transition-colors">
|
||||||
onClick={onCancel}
|
|
||||||
className="text-ink-muted hover:text-ink transition-colors"
|
|
||||||
>
|
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,79 +353,28 @@ function CreateIssuerModal({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 py-6">
|
<div className="px-6 py-6">
|
||||||
{step === 'type' ? (
|
{step === 'type' ? (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<TypeSelector onSelect={handleTypeSelect} />
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Name field always shown */}
|
{/* Name field */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
|
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(form.name as string) || ''}
|
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"
|
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"
|
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>
|
</div>
|
||||||
|
{/* Type-specific fields via ConfigForm */}
|
||||||
{/* Type-specific fields */}
|
{selectedTypeConfig && (
|
||||||
{selectedTypeConfig?.configFields.map((field) => (
|
<ConfigForm
|
||||||
<div key={field.key}>
|
fields={selectedTypeConfig.configFields}
|
||||||
<label className="block text-sm font-medium text-ink mb-2">
|
values={form}
|
||||||
{field.label}
|
onChange={(key, value) => setForm({ ...form, [key]: value })}
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -378,7 +383,7 @@ function CreateIssuerModal({
|
|||||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
|
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
|
||||||
{step === 'config' && (
|
{step === 'config' && (
|
||||||
<button
|
<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"
|
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
@@ -392,22 +397,13 @@ function CreateIssuerModal({
|
|||||||
</button>
|
</button>
|
||||||
{step === 'config' && (
|
{step === 'config' && (
|
||||||
<button
|
<button
|
||||||
onClick={onSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting || !form.name}
|
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"
|
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'}
|
{isSubmitting ? 'Creating...' : 'Create Issuer'}
|
||||||
</button>
|
</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>
|
</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 { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
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() {
|
export default function JobsPage() {
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState('');
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
@@ -89,20 +111,47 @@ export default function JobsPage() {
|
|||||||
label: 'Job',
|
label: 'Job',
|
||||||
render: (j) => (
|
render: (j) => (
|
||||||
<div>
|
<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 className="text-xs text-ink-faint">{j.type}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
{ 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: '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',
|
key: 'attempts',
|
||||||
label: 'Attempts',
|
label: 'Attempts',
|
||||||
render: (j) => <span className="text-ink-muted">{j.attempts}/{j.max_attempts}</span>,
|
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: '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: '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',
|
key: 'actions',
|
||||||
label: '',
|
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;
|
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) {
|
function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [ttl, setTtl] = useState('86400');
|
const [ttl, setTtl] = useState('86400');
|
||||||
const [shortLived, setShortLived] = useState(false);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,20 +91,31 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
|||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
max_ttl_seconds: parseInt(ttl) || 86400,
|
max_ttl_seconds: parseInt(ttl) || 86400,
|
||||||
allow_short_lived: shortLived,
|
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,
|
enabled: true,
|
||||||
});
|
});
|
||||||
setName('');
|
setName('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setTtl('86400');
|
setTtl('86400');
|
||||||
setShortLived(false);
|
setShortLived(false);
|
||||||
|
setKeyAlgorithms([{ algorithm: 'ECDSA', min_size: 256 }, { algorithm: 'RSA', min_size: 2048 }]);
|
||||||
|
setSelectedEkus(['serverAuth']);
|
||||||
|
setSanPatterns('');
|
||||||
|
setSpiffePattern('');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
<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>
|
<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>}
|
{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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -61,7 +124,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
|||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
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"
|
placeholder="e.g., Web Server Certs"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -71,7 +134,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
|||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={e => setDescription(e.target.value)}
|
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"
|
placeholder="Optional description"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
@@ -82,7 +145,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
|||||||
type="number"
|
type="number"
|
||||||
value={ttl}
|
value={ttl}
|
||||||
onChange={e => setTtl(e.target.value)}
|
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"
|
placeholder="86400"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-ink-muted mt-1">
|
<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>
|
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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]) => (
|
||||||
|
<InfoRow key={key} label={key.replace(/_/g, ' ')} value={
|
||||||
|
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
|
||||||
|
} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployment history */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||||
|
Deployment History {jobsData ? `(${jobsData.total})` : ''}
|
||||||
|
</h3>
|
||||||
|
<DataTable
|
||||||
|
columns={jobColumns}
|
||||||
|
data={jobsData?.data || []}
|
||||||
|
isLoading={!jobsData}
|
||||||
|
emptyMessage="No deployments to this target"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getTargets, createTarget, deleteTarget } from '../api/client';
|
import { getTargets, createTarget, deleteTarget } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -266,7 +267,9 @@ export default function TargetsPage() {
|
|||||||
label: 'Target',
|
label: 'Target',
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<div>
|
<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 className="text-xs text-ink-faint font-mono">{t.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user