Compare commits

...

10 Commits

Author SHA1 Message Date
shankar0123 adfb682754 feat: Go integration test suite replacing bash end-to-end tests
Refactors deploy/test/run-test.sh into a typed Go test file with
crypto/x509 certificate parsing, eliminating fragile openssl text
scraping. 12 phases, 35 subtests covering Local CA, ACME, step-ca,
revocation, discovery, renewal, EST, S/MIME, and API spot checks.

- testClient HTTP helper with Bearer auth
- testDB PostgreSQL helper (port 5432 now exposed)
- waitFor/waitForJobsDone polling helpers
- crypto/x509 for EKU, KeyUsage, SAN verification
- crypto/tls for NGINX deployment verification
- //go:build integration tag (not in CI yet)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 19:04:26 -04:00
shankar0123 0822f748a5 feat: S/MIME certificate support in integration tests + test env docs
Add S/MIME (emailProtection EKU) end-to-end test coverage:
- ValidateCommonName() now accepts email addresses for S/MIME certs
- S/MIME test profile (prof-test-smime) in seed data
- Phase 11 test: issuance, EKU, KeyUsage, email SAN verification
- EST config enabled in test Docker Compose
- Portable KeyUsage parsing (awk, works on BSD/GNU)
- Full test environment documentation (docs/test-env.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:32:57 -04:00
shankar0123 368ea681a5 fix: remove unused functions flagged by golangci-lint
Remove signJWT (replaced by signJWTWithKID) and ecdsaPublicKeyToJWK
(dead code from JWE implementation) to pass CI lint checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:07:52 -04:00
shankar0123 b059ec930f fix: end-to-end certificate lifecycle bugs + integration test environment
Fixes 12 production bugs preventing the full issuance→deployment flow
from working with ACME (Pebble/Let's Encrypt) and step-ca issuers:

ACME connector (acme.go):
- Save orderURI before WaitOrder overwrites it (Go crypto/acme bug)
- Add CreateOrderCert fallback via WaitOrder+FetchCert
- Remove defer-reset in ValidateConfig that caused nil pointer panic
- Add Insecure TLS option for self-signed ACME servers (Pebble)

step-ca connector (stepca.go, jwe.go):
- Real JWE provisioner key loading + decryption (was using ephemeral keys)
- Fix JWT audience (/1.0/sign), sha claim (key fingerprint), kid header
- Custom root CA trust via RootCertPath config
- Remove hardcoded 90-day validity default (let step-ca decide)

NGINX target connector (nginx.go):
- Use sh -c for validate/reload commands (shell interpretation)
- Use filepath.Dir instead of fragile string slicing
- Add private key file writing (agent-mode keys were never deployed)
- Make chain_path write conditional

Server/service layer:
- TriggerRenewalWithActor now creates actual Job records (was no-op)
- createDeploymentJobs falls back to DB query when cert.TargetIDs empty
- ProcessPendingJobs skips agent-routed deployment jobs
- Agent cert pickup path parsing: len(parts)<4 → len(parts)<3
- Health/ready/auth-info endpoints bypass auth middleware
- Write timeout 15s→120s for ACME issuance
- Cert fingerprint computed on CSR submission

Integration test environment (deploy/test/):
- 10-phase test script covering Local CA, ACME, step-ca, revocation,
  discovery, renewal, and API spot checks
- Docker Compose with 7 containers (server, agent, postgres, nginx,
  pebble, challtestsrv, step-ca) on isolated network
- TLS verification checks SAN (not just Subject CN) for modern CA compat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:02:20 -04:00
shankar0123 2238f28610 fix: left-align gantt bars for visual lifespan comparison
All bars start from the same point so the shrinking from 1825
days to 47 days is visually obvious. Section labels indicate
the policy year, bar length shows the max certificate lifespan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:23:20 -04:00
shankar0123 bbba618beb fix: gantt chart bars now represent actual certificate lifespans
Each bar starts at the policy effective date and its length equals
the max certificate lifespan in days. The visual shrinking from
1825 days (2015) to 47 days (2029) tells the story accurately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:22:00 -04:00
shankar0123 cfc4d3f3e8 revert: restore timeline diagram, gantt chart was misleading
The gantt bars spanned between date ranges which misrepresented
the data. The timeline diagram correctly maps each date to its
maximum certificate lifespan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:20:50 -04:00
shankar0123 c06d23dd7a chore: replace timeline diagram with gantt chart to remove arrows
Mermaid timeline diagrams render dashed downward arrows that can't
be hidden. Switched to gantt chart for a cleaner horizontal bar
visualization showing TLS certificate lifespan reduction from
5 years (2015) to 47 days (2029).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:19:40 -04:00
shankar0123 6c8d4eca40 feat: frontend audit fixes, README accuracy pass, doc updates
Frontend audit (10 categories): lifecycle fields in types, new API
functions (CRL, OCSP, deployments, updateIssuer/Target, getPolicy),
issuer/owner/profile filters on CertificatesPage, last_renewal_at
column, error_message column on JobsPage, full crypto policy UI on
ProfilesPage (key algorithms, EKUs, SAN patterns), key info + CA
badge on DiscoveryPage, edit modal on TargetDetailPage, tags field
on certificate creation, darwin→macOS mapping on AgentFleetPage.
211 Vitest tests passing.

README accuracy: test counts (1300+ Go, 211 frontend), page count
(24), demo data (32 certs, 7 issuers, 180 days), endpoint count
(97), MCP tools (80), CLI subcommands (10), moved shipped items
out of "Coming in v2.1.0".

Docs: architecture.md diagrams updated (Vault PKI, DigiCert,
Traefik, Caddy added), features.md Vault/DigiCert status updated.
Version bumped to v2.0.20. cli binary removed from git tracking.
Testing guide Part 41 added (12 auto + 9 manual tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:10:45 -04:00
shankar0123 836534f2a7 feat: add issuer catalog page with type discovery + fix cert creation defaults (M33)
Issuer Catalog (M33):
- Shared issuer type config (issuerTypes.ts) with 6 supported + 2 coming-soon types
- Composable wizard components (TypeSelector, ConfigForm, ConfigDetailModal)
- Catalog card layout with Connected/Available/Coming Soon badges
- VaultPKI and DigiCert added to create wizard with full config fields
- ACME EAB fields (eab_kid, eab_hmac with sensitive flag)
- Issuer type filter dropdown on configured issuers table
- Config detail modal replacing 60-char truncation
- IssuerDetailPage uses shared typeLabels/redactConfig, Edit button, enabled/disabled status
- StatusBadge extended with Enabled/Disabled styles
- 2 new frontend tests (VaultPKI + DigiCert create payload verification)

Bug fixes:
- CertificateService.CreateCertificate now defaults Status to Pending and Tags to
  empty map when not set (DB column DEFAULTs only apply when columns are omitted
  from INSERT, but our repo always includes all columns)
- CreateCertificate handler now logs actual error via slog.Error before returning
  generic 500, enabling root cause debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:58:23 -04:00
46 changed files with 5908 additions and 380 deletions
+1
View File
@@ -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
+34 -22
View File
@@ -17,14 +17,23 @@ TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballo
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.
```mermaid ```mermaid
timeline gantt
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3) title TLS Certificate Maximum Lifespan CA/Browser Forum Ballot SC-081v3
2015 : 5 years dateFormat YYYY-MM-DD
2018 : 825 days axisFormat
2020 : 398 days todayMarker off
March 2026 : 200 days section 2015
March 2027 : 100 days 5 years (1825 days) :done, 2020-01-01, 1825d
March 2029 : 47 days 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
@@ -43,7 +52,7 @@ timeline
| [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
@@ -59,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
@@ -131,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>
@@ -145,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
@@ -166,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
@@ -174,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)
@@ -374,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
``` ```
@@ -451,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
@@ -487,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 ✅):**
@@ -499,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
@@ -512,14 +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:**
- 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.
BIN
View File
Binary file not shown.
+33 -6
View File
@@ -112,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")
@@ -119,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"),
@@ -261,6 +263,8 @@ 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) renewalService.SetTargetRepo(targetRepo)
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
@@ -504,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
@@ -525,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")
} }
@@ -534,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,
} }
+309
View File
@@ -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
+27
View File
@@ -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;"
+42
View File
@@ -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';
}
}
}
+16
View File
@@ -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
}
}
}
+937
View File
@@ -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
+140
View File
@@ -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
+7 -3
View File
@@ -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"
+2 -2
View File
@@ -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) |
--- ---
+1068
View File
File diff suppressed because it is too large Load Diff
+234 -3
View File
@@ -44,6 +44,8 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e) - [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 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37) - [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)
--- ---
@@ -5372,6 +5374,189 @@ curl -s -X POST -H "$AUTH" \
--- ---
## 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**).
@@ -5952,14 +6137,60 @@ These must be green before starting manual QA:
| 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox | | 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox |
| 39.5 | Revocation records locally | 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`) | 136 | | ☑ 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) | 226 | | ☐ Manual (requires hands-on verification) | 241 |
| **Total** | **367** | | **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.
+4 -2
View File
@@ -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
) )
+3 -1
View File
@@ -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
} }
+1
View File
@@ -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
} }
+9
View File
@@ -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)}
+6
View File
@@ -256,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.
@@ -503,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),
+74 -6
View File
@@ -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
} }
+264
View File
@@ -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
}
+105 -35
View File
@@ -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
+32 -14
View File
@@ -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)
+9 -8
View File
@@ -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 {
+61
View File
@@ -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)
} }
+10
View File
@@ -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,
+42 -13
View File
@@ -636,23 +636,50 @@ 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
// Resolve agent_id from target for job routing for _, t := range targets {
tid := t.id
var agentIDPtr *string var agentIDPtr *string
if s.targetRepo != nil { if t.agentID != "" {
target, err := s.targetRepo.Get(ctx, tid) aid := t.agentID
if err != nil { agentIDPtr = &aid
slog.Warn("failed to resolve agent for deployment job", "target_id", tid, "error", err)
} else if target.AgentID != "" {
agentID := target.AgentID
agentIDPtr = &agentID
}
} }
deployJob := &domain.Job{ deployJob := &domain.Job{
@@ -667,7 +694,9 @@ func (s *RenewalService) createDeploymentJobs(ctx context.Context, cert *domain.
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)
} }
} }
} }
+143
View File
@@ -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;
+99
View File
@@ -83,6 +83,12 @@ import {
getIssuer, getIssuer,
getTarget, getTarget,
getPrometheusMetrics, getPrometheusMetrics,
getCertificateDeployments,
getCRL,
getOCSPStatus,
updateIssuer,
updateTarget,
getPolicy,
} from './client'; } from './client';
// Mock global fetch // Mock global fetch
@@ -632,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 ──────────────────────────────────────────
@@ -1106,4 +1156,53 @@ describe('API Client', () => {
expect(init.headers['Authorization']).toBe('Bearer prom-key'); 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');
});
});
}); });
+29
View File
@@ -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' });
+9
View File
@@ -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;
} }
@@ -135,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 {
@@ -147,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 {
+1 -1
View File
@@ -71,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}
+3
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+179
View File
@@ -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,
};
});
}
+10 -2
View File
@@ -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' : ''}
+142 -22
View File
@@ -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';
@@ -16,20 +16,66 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
name: '', 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()}>
@@ -39,57 +85,90 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Name *</label> <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 }))} <input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20" className={inputClass}
placeholder="API Production Cert" /> placeholder="API Production Cert" />
</div> </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">
@@ -245,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],
@@ -302,7 +391,8 @@ export default function CertificatesPage() {
); );
}, },
}, },
{ key: 'env', label: 'Environment', render: (c) => <span className="text-ink-muted">{c.environment || '—'}</span> }, { key: 'last_renewal', label: 'Last Renewal', render: (c) => <span className="text-xs text-ink-muted">{c.last_renewal_at ? formatDate(c.last_renewal_at) : '—'}</span> },
{ key: 'last_deploy', label: 'Last Deploy', render: (c) => <span className="text-xs text-ink-muted">{c.last_deployment_at ? formatDate(c.last_deployment_at) : '—'}</span> },
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> }, { key: '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> },
]; ];
@@ -382,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 ? (
+12
View File
@@ -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',
+29 -25
View File
@@ -1,4 +1,4 @@
import { useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client'; import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
@@ -7,15 +7,8 @@ import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState'; import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils'; import { formatDateTime } from '../api/utils';
import type { Certificate } from '../api/types'; import type { Certificate, Issuer } from '../api/types';
import { typeLabels, redactConfig } from '../config/issuerTypes';
const typeLabels: Record<string, string> = {
local_ca: 'Local CA',
acme: 'ACME (Let\'s Encrypt)',
step_ca: 'step-ca',
openssl: 'OpenSSL / Custom',
vault: 'Vault PKI',
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return ( return (
@@ -26,8 +19,17 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
); );
} }
/** 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() { export default function IssuerDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: issuer, isLoading, error, refetch } = useQuery({ const { data: issuer, isLoading, error, refetch } = useQuery({
queryKey: ['issuer', id], queryKey: ['issuer', id],
@@ -65,13 +67,7 @@ export default function IssuerDetailPage() {
); );
} }
// Redact sensitive config fields const safeConfig = issuer.config ? redactConfig(issuer.config) : {};
const safeConfig = issuer.config ? Object.fromEntries(
Object.entries(issuer.config).map(([k, v]) => {
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
return [k, sensitive ? '********' : v];
})
) : {};
const certColumns: Column<Certificate>[] = [ const certColumns: Column<Certificate>[] = [
{ {
@@ -94,13 +90,21 @@ export default function IssuerDetailPage() {
title={issuer.name} title={issuer.name}
subtitle={typeLabels[issuer.type] || issuer.type} subtitle={typeLabels[issuer.type] || issuer.type}
action={ action={
<button <div className="flex gap-2">
onClick={() => testMutation.mutate()} <button
disabled={testMutation.isPending} onClick={() => navigate(`/issuers?edit=${issuer.id}`)}
className="btn btn-primary text-xs disabled:opacity-50" className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
> >
{testMutation.isPending ? 'Testing...' : 'Test Connection'} Edit
</button> </button>
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="btn btn-primary text-xs disabled:opacity-50"
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
</div>
} }
/> />
@@ -123,7 +127,7 @@ export default function IssuerDetailPage() {
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} /> <InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
<InfoRow label="Name" value={issuer.name} /> <InfoRow label="Name" value={issuer.name} />
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} /> <InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
<InfoRow label="Status" value={<StatusBadge status={issuer.status} />} /> <InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} /> <InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
</div> </div>
+202 -209
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; 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';
@@ -9,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'],
@@ -109,12 +53,22 @@ 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',
@@ -138,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',
@@ -146,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">&mdash;</span>; if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">&mdash;</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>
); );
}, },
}, },
@@ -184,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"
> >
@@ -205,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}
/> />
@@ -256,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">
@@ -289,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>
@@ -300,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>
@@ -381,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
@@ -395,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>
+9
View File
@@ -136,6 +136,15 @@ export default function JobsPage() {
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> },
{ {
+158 -4
View File
@@ -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"
+57 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTarget, getJobs } from '../api/client'; import { getTarget, getJobs, updateTarget } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
@@ -30,6 +31,18 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
export default function TargetDetailPage() { export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>(); 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({ const { data: target, isLoading, error, refetch } = useQuery({
queryKey: ['target', id], queryKey: ['target', id],
@@ -112,6 +125,18 @@ export default function TargetDetailPage() {
<PageHeader <PageHeader
title={target.name} title={target.name}
subtitle={typeLabels[target.type] || target.type} 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="flex-1 overflow-y-auto px-6 py-4 space-y-6">
@@ -164,6 +189,36 @@ export default function TargetDetailPage() {
/> />
</div> </div>
</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>
)}
</> </>
); );
} }